Repository: Infinite-Chess/infinitechess.org Branch: main Commit: 267ccca74304 Files: 664 Total size: 4.4 MB Directory structure: gitextract_2jn2wipu/ ├── .github/ │ ├── copilot-instructions.md │ ├── pull_request_template.md │ └── workflows/ │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── CLAUDE.md ├── LICENSE ├── README.md ├── build/ │ ├── client.ts │ ├── engine-wasm.ts │ ├── env.ts │ ├── index.ts │ ├── plugins.ts │ ├── server.ts │ └── views.ts ├── dev-utils/ │ ├── ICN_METADATA_TRANSLATIONS.md │ ├── REDESIGN/ │ │ ├── design.md │ │ ├── runner_setup.md │ │ ├── stack.md │ │ └── todo.md │ ├── SKELETON.css │ ├── SKELETON.html │ ├── live-game-persistence.md │ ├── pieces/ │ │ ├── spritesheet 512/ │ │ │ └── How to create spritesheet.md │ │ └── svg/ │ │ └── Converting PNG to SVG.md │ ├── post_processing_effects/ │ │ ├── posterize/ │ │ │ ├── PosterizePass.ts │ │ │ └── fragment.glsl │ │ ├── radial_distortion/ │ │ │ ├── RadialDistortionPass.ts │ │ │ └── fragment.glsl │ │ └── rolling_hills/ │ │ ├── RollingHillsPass.ts │ │ └── fragment.glsl │ ├── readme.md │ ├── scripts/ │ │ ├── PatreonAPI.ts │ │ ├── audio/ │ │ │ └── processors/ │ │ │ └── bitcrusher/ │ │ │ ├── BitcrusherNode.ts │ │ │ └── BitcrusherProcessor.ts │ │ ├── clientEventDispatcher.ts │ │ ├── events.ts │ │ ├── gl-matrix.js │ │ ├── icn-regex-matching.ts │ │ ├── meshSimplification.ts │ │ ├── positionnormalizer/ │ │ │ ├── moveexpander.ts │ │ │ ├── normalizertester.ts │ │ │ ├── positioncompressor.ts │ │ │ ├── positioncompressorplusintersections.ts │ │ │ └── unusedpositionnormalizermethods.ts │ │ └── vertexdatatotexture.ts │ ├── shaders/ │ │ ├── texture/ │ │ │ ├── instanced/ │ │ │ │ └── tint/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ └── tint/ │ │ │ ├── fragment.glsl │ │ │ └── vertex.glsl │ │ └── voronoi/ │ │ └── fragment.glsl │ ├── sounds/ │ │ └── SoundscapeGenerator.html │ └── spritesheet_generator/ │ ├── spritesheet.ts │ └── spritesheetGenerator.ts ├── docs/ │ ├── COPYING.md │ ├── GRAPHICS.md │ ├── GUIDELINES.md │ ├── NAVIGATING.md │ ├── SETUP.md │ └── TRANSLATIONS.md ├── ecosystem.config.cjs ├── eslint.config.js ├── nodemon.json ├── package.json ├── scripts/ │ ├── add-file-paths.ts │ ├── generate-translation-types.ts │ ├── optimize-images.ts │ ├── organize-imports.ts │ └── readme.md ├── src/ │ ├── client/ │ │ ├── css/ │ │ │ ├── 404.css │ │ │ ├── admin.css │ │ │ ├── createaccount.css │ │ │ ├── credits.css │ │ │ ├── footer.css │ │ │ ├── guide.css │ │ │ ├── header.css │ │ │ ├── icnvalidator.css │ │ │ ├── index.css │ │ │ ├── leaderboard.css │ │ │ ├── login.css │ │ │ ├── member.css │ │ │ ├── news.css │ │ │ ├── play.css │ │ │ └── termsofservice.css │ │ ├── img/ │ │ │ ├── badges/ │ │ │ │ ├── checkmate-badge-bronze.avif │ │ │ │ ├── checkmate-badge-gold.avif │ │ │ │ └── checkmate-badge-silver.avif │ │ │ ├── blank_board.avif │ │ │ ├── game/ │ │ │ │ └── guide/ │ │ │ │ ├── arrowindicators.avif │ │ │ │ ├── fairy/ │ │ │ │ │ ├── amazon.avif │ │ │ │ │ ├── archbishop.avif │ │ │ │ │ ├── centaur.avif │ │ │ │ │ ├── chancellor.avif │ │ │ │ │ ├── guard.avif │ │ │ │ │ ├── hawk.avif │ │ │ │ │ ├── huygen.avif │ │ │ │ │ ├── knightrider.avif │ │ │ │ │ ├── obstacle.avif │ │ │ │ │ ├── rose.avif │ │ │ │ │ └── void.avif │ │ │ │ ├── kingrookfork.avif │ │ │ │ └── promotionlines.avif │ │ │ ├── king_w.avif │ │ │ ├── logo/ │ │ │ │ ├── dark-theme.avif │ │ │ │ └── light-theme.avif │ │ │ ├── member_default.avif │ │ │ └── queen_w.avif │ │ ├── scripts/ │ │ │ ├── cjs/ │ │ │ │ └── game/ │ │ │ │ └── htmlscript.ts │ │ │ └── esm/ │ │ │ ├── audio/ │ │ │ │ ├── AudioEffects.ts │ │ │ │ ├── AudioManager.ts │ │ │ │ ├── AudioUtils.ts │ │ │ │ ├── LFOFactory.ts │ │ │ │ ├── SoundLayer.ts │ │ │ │ ├── SoundscapePlayer.ts │ │ │ │ └── processors/ │ │ │ │ ├── downsampler/ │ │ │ │ │ ├── DownsamplerNode.ts │ │ │ │ │ └── DownsamplerProcessor.ts │ │ │ │ └── worklet-types.ts │ │ │ ├── chess/ │ │ │ │ └── rendering/ │ │ │ │ ├── checkerboardgenerator.ts │ │ │ │ ├── imagecache.ts │ │ │ │ ├── svgcache.ts │ │ │ │ └── texturecache.ts │ │ │ ├── components/ │ │ │ │ └── header/ │ │ │ │ ├── currpage-greyer.ts │ │ │ │ ├── dropdowns/ │ │ │ │ │ ├── appearancedropdown.ts │ │ │ │ │ ├── gameplaydropdown.ts │ │ │ │ │ ├── languagedropdown.ts │ │ │ │ │ ├── legalmovedropdown.ts │ │ │ │ │ ├── perspectivedropdown.ts │ │ │ │ │ └── sounddropdown.ts │ │ │ │ ├── faviconselector.ts │ │ │ │ ├── header.ts │ │ │ │ ├── news-notification.ts │ │ │ │ ├── pingmeter.ts │ │ │ │ ├── preferences.ts │ │ │ │ ├── settings.ts │ │ │ │ └── spacing.ts │ │ │ ├── game/ │ │ │ │ ├── GameBus.ts │ │ │ │ ├── boardeditor/ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ ├── eactions.ts │ │ │ │ │ │ ├── eautosave.ts │ │ │ │ │ │ ├── ecloud.ts │ │ │ │ │ │ ├── editorSavesAPI.ts │ │ │ │ │ │ └── esave.ts │ │ │ │ │ ├── boardeditor.ts │ │ │ │ │ ├── eclipboard.ts │ │ │ │ │ ├── edithistory.ts │ │ │ │ │ ├── editortypes.ts │ │ │ │ │ ├── egamerules.ts │ │ │ │ │ └── tools/ │ │ │ │ │ ├── drawingtool.ts │ │ │ │ │ ├── etoolmanager.ts │ │ │ │ │ ├── normaltool.ts │ │ │ │ │ └── selection/ │ │ │ │ │ ├── scursor.ts │ │ │ │ │ ├── sdrag.ts │ │ │ │ │ ├── selectiontool.ts │ │ │ │ │ ├── sfill.ts │ │ │ │ │ ├── stoolgraphics.ts │ │ │ │ │ └── stransformations.ts │ │ │ │ ├── chess/ │ │ │ │ │ ├── checkmatepractice.ts │ │ │ │ │ ├── clientmetadatautil.ts │ │ │ │ │ ├── copygame.ts │ │ │ │ │ ├── engines/ │ │ │ │ │ │ ├── engine.ts │ │ │ │ │ │ ├── engineCheckmatePractice.ts │ │ │ │ │ │ ├── enginecards/ │ │ │ │ │ │ │ └── hydrochess_card.ts │ │ │ │ │ │ └── hydrochess.ts │ │ │ │ │ ├── game.ts │ │ │ │ │ ├── gamecompressor.ts │ │ │ │ │ ├── gamecompressor.unit.test.ts │ │ │ │ │ ├── gameformulator.ts │ │ │ │ │ ├── gameloader.ts │ │ │ │ │ ├── gameslot.ts │ │ │ │ │ ├── graphicalchanges.ts │ │ │ │ │ ├── movesequence.ts │ │ │ │ │ ├── pastegame.ts │ │ │ │ │ ├── premoves.ts │ │ │ │ │ └── selection.ts │ │ │ │ ├── config.ts │ │ │ │ ├── gui/ │ │ │ │ │ ├── boardeditor/ │ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ │ ├── guiclearposition.ts │ │ │ │ │ │ │ ├── guigamerules.ts │ │ │ │ │ │ │ ├── guiresetposition.ts │ │ │ │ │ │ │ ├── guistartenginegame.ts │ │ │ │ │ │ │ ├── guistartlocalgame.ts │ │ │ │ │ │ │ └── loadposition/ │ │ │ │ │ │ │ ├── guiloadposition.ts │ │ │ │ │ │ │ ├── guiloadpositionmodal.ts │ │ │ │ │ │ │ └── guiloadpositionsavelist.ts │ │ │ │ │ │ ├── guiboardeditor.ts │ │ │ │ │ │ ├── guifloatingwindow.ts │ │ │ │ │ │ ├── guipalette.ts │ │ │ │ │ │ ├── guipositionheader.ts │ │ │ │ │ │ └── guitoolbar.ts │ │ │ │ │ ├── gui.ts │ │ │ │ │ ├── guiclock.ts │ │ │ │ │ ├── guidrawoffer.ts │ │ │ │ │ ├── guigameinfo.ts │ │ │ │ │ ├── guiloading.ts │ │ │ │ │ ├── guinavigation.ts │ │ │ │ │ ├── guipause.ts │ │ │ │ │ ├── guiplay.ts │ │ │ │ │ ├── guipractice.ts │ │ │ │ │ ├── guipromotion.ts │ │ │ │ │ ├── guititle.ts │ │ │ │ │ ├── loadingscreen.ts │ │ │ │ │ ├── stats.ts │ │ │ │ │ ├── style.ts │ │ │ │ │ └── toast.ts │ │ │ │ ├── input.ts │ │ │ │ ├── main.ts │ │ │ │ ├── misc/ │ │ │ │ │ ├── controls.ts │ │ │ │ │ ├── enginegame.ts │ │ │ │ │ ├── gamesound.ts │ │ │ │ │ ├── invites.ts │ │ │ │ │ ├── keybinds.ts │ │ │ │ │ ├── loadbalancer.ts │ │ │ │ │ ├── onlinegame/ │ │ │ │ │ │ ├── afk.ts │ │ │ │ │ │ ├── disconnect.ts │ │ │ │ │ │ ├── drawoffers.ts │ │ │ │ │ │ ├── movesendreceive.ts │ │ │ │ │ │ ├── onlinegame.ts │ │ │ │ │ │ ├── onlinegamerouter.ts │ │ │ │ │ │ ├── resyncer.ts │ │ │ │ │ │ └── tabnameflash.ts │ │ │ │ │ └── space.ts │ │ │ │ ├── rendering/ │ │ │ │ │ ├── ColorFlowRenderer.ts │ │ │ │ │ ├── WaterRipples.ts │ │ │ │ │ ├── animation.ts │ │ │ │ │ ├── area.ts │ │ │ │ │ ├── arrows/ │ │ │ │ │ │ ├── arrowlegalmovehighlights.ts │ │ │ │ │ │ ├── arrows.ts │ │ │ │ │ │ ├── arrowscalculator.ts │ │ │ │ │ │ ├── arrowsgraphics.ts │ │ │ │ │ │ └── arrowshifts.ts │ │ │ │ │ ├── boarddrag.ts │ │ │ │ │ ├── boardpos.ts │ │ │ │ │ ├── boardtiles.ts │ │ │ │ │ ├── border.ts │ │ │ │ │ ├── camera.ts │ │ │ │ │ ├── coordinates.ts │ │ │ │ │ ├── dragging/ │ │ │ │ │ │ ├── draganimation.ts │ │ │ │ │ │ ├── dragarrows.ts │ │ │ │ │ │ └── droparrows.ts │ │ │ │ │ ├── effect_zone/ │ │ │ │ │ │ ├── EffectZoneManager.ts │ │ │ │ │ │ ├── soundscapes/ │ │ │ │ │ │ │ ├── IridescenceSoundscape.ts │ │ │ │ │ │ │ └── UndercurrentSoundscape.ts │ │ │ │ │ │ └── zones/ │ │ │ │ │ │ ├── AshfallVocsZone.ts │ │ │ │ │ │ ├── ContortionFieldZone.ts │ │ │ │ │ │ ├── DustyWastesZone.ts │ │ │ │ │ │ ├── EchoRiftZone.ts │ │ │ │ │ │ ├── EmberVergeZone.ts │ │ │ │ │ │ ├── IridescenceZone.ts │ │ │ │ │ │ ├── OceanZone.ts │ │ │ │ │ │ ├── SpectralEdgeZone.ts │ │ │ │ │ │ ├── StaticZone.ts │ │ │ │ │ │ ├── TheBeginningZone.ts │ │ │ │ │ │ └── UndercurrentZone.ts │ │ │ │ │ ├── frameratelimiter.ts │ │ │ │ │ ├── frametracker.ts │ │ │ │ │ ├── gl-matrix.js │ │ │ │ │ ├── highlights/ │ │ │ │ │ │ ├── annotations/ │ │ │ │ │ │ │ ├── annotations.ts │ │ │ │ │ │ │ ├── drawarrows.ts │ │ │ │ │ │ │ ├── drawrays.ts │ │ │ │ │ │ │ └── drawsquares.ts │ │ │ │ │ │ ├── checkhighlight.ts │ │ │ │ │ │ ├── highlightline.ts │ │ │ │ │ │ ├── highlights.ts │ │ │ │ │ │ ├── legalmovehighlights.ts │ │ │ │ │ │ ├── legalmovemodel.ts │ │ │ │ │ │ ├── movehints.ts │ │ │ │ │ │ ├── selectedpiecehighlightline.ts │ │ │ │ │ │ ├── snapping.ts │ │ │ │ │ │ ├── specialrighthighlights.ts │ │ │ │ │ │ └── squarerendering.ts │ │ │ │ │ ├── instancedshapes.ts │ │ │ │ │ ├── meshes.ts │ │ │ │ │ ├── miniimage.ts │ │ │ │ │ ├── perspective.ts │ │ │ │ │ ├── piecemodels.ts │ │ │ │ │ ├── pieces.ts │ │ │ │ │ ├── primitives.ts │ │ │ │ │ ├── promotionlines.ts │ │ │ │ │ ├── screenshake.ts │ │ │ │ │ ├── starfield.ts │ │ │ │ │ ├── text/ │ │ │ │ │ │ ├── glyphatlas.ts │ │ │ │ │ │ └── textrenderer.ts │ │ │ │ │ ├── transitions/ │ │ │ │ │ │ └── Transition.ts │ │ │ │ │ └── webgl.ts │ │ │ │ └── websocket/ │ │ │ │ ├── socketclose.ts │ │ │ │ ├── socketman.ts │ │ │ │ ├── socketmessages.ts │ │ │ │ ├── socketrouter.ts │ │ │ │ ├── socketschemas.ts │ │ │ │ └── socketsubs.ts │ │ │ ├── util/ │ │ │ │ ├── ImageLoader.ts │ │ │ │ ├── IndexedDB.ts │ │ │ │ ├── LocalStorage.ts │ │ │ │ ├── PerlinNoise.ts │ │ │ │ ├── compression.ts │ │ │ │ ├── docutil.ts │ │ │ │ ├── httputils.ts │ │ │ │ ├── indexeddb.unit.test.ts │ │ │ │ ├── mouse.ts │ │ │ │ ├── pingManager.ts │ │ │ │ ├── splines.ts │ │ │ │ ├── svgtoimageconverter.ts │ │ │ │ ├── thread.ts │ │ │ │ ├── tooltips.ts │ │ │ │ ├── usernamecontainer.ts │ │ │ │ └── validatorama.ts │ │ │ ├── views/ │ │ │ │ ├── admin.ts │ │ │ │ ├── createaccount.ts │ │ │ │ ├── guide.ts │ │ │ │ ├── icnvalidator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── leaderboard.ts │ │ │ │ ├── login.ts │ │ │ │ ├── member.ts │ │ │ │ ├── news.ts │ │ │ │ └── resetpassword.ts │ │ │ ├── webgl/ │ │ │ │ ├── BufferUtil.ts │ │ │ │ ├── ProgramManager.ts │ │ │ │ ├── Renderable.ts │ │ │ │ ├── ShaderProgram.ts │ │ │ │ ├── TextureLoader.ts │ │ │ │ ├── maskedDraw.ts │ │ │ │ └── post_processing/ │ │ │ │ ├── PostProcessingPipeline.ts │ │ │ │ └── passes/ │ │ │ │ ├── ColorGradePass.ts │ │ │ │ ├── GlitchPass.ts │ │ │ │ ├── HeatWavePass.ts │ │ │ │ ├── PassThroughPass.ts │ │ │ │ ├── SineWavePass.ts │ │ │ │ ├── VignettePass.ts │ │ │ │ ├── VoronoiDistortionPass.ts │ │ │ │ ├── WaterPass.ts │ │ │ │ └── WaterRipplePass.ts │ │ │ └── workers/ │ │ │ └── icnvalidator.worker.ts │ │ ├── shaders/ │ │ │ ├── arrow_images/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── arrows/ │ │ │ │ └── vertex.glsl │ │ │ ├── board_uber_shader/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── color/ │ │ │ │ ├── fragment.glsl │ │ │ │ ├── instanced/ │ │ │ │ │ └── vertex.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── color_grade/ │ │ │ │ └── fragment.glsl │ │ │ ├── color_texture/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── fullscreen_colorflow/ │ │ │ │ └── fragment.glsl │ │ │ ├── glitch/ │ │ │ │ └── fragment.glsl │ │ │ ├── heat_wave/ │ │ │ │ └── fragment.glsl │ │ │ ├── highlights/ │ │ │ │ └── vertex.glsl │ │ │ ├── mini_images/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── post_pass/ │ │ │ │ ├── fragment.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── sine_wave/ │ │ │ │ └── fragment.glsl │ │ │ ├── starfield/ │ │ │ │ └── vertex.glsl │ │ │ ├── texture/ │ │ │ │ ├── fragment.glsl │ │ │ │ ├── instanced/ │ │ │ │ │ └── vertex.glsl │ │ │ │ └── vertex.glsl │ │ │ ├── vignette/ │ │ │ │ └── fragment.glsl │ │ │ ├── voronoi_distortion/ │ │ │ │ └── fragment.glsl │ │ │ ├── water/ │ │ │ │ └── fragment.glsl │ │ │ └── water_ripple/ │ │ │ └── fragment.glsl │ │ ├── sounds/ │ │ │ └── spritesheet/ │ │ │ ├── note.txt │ │ │ └── soundspritesheet.opus │ │ └── views/ │ │ ├── admin.ejs │ │ ├── components/ │ │ │ ├── footer.ejs │ │ │ └── header.ejs │ │ ├── createaccount.ejs │ │ ├── credits.ejs │ │ ├── errors/ │ │ │ ├── 400.ejs │ │ │ ├── 401.ejs │ │ │ ├── 404.ejs │ │ │ ├── 409.ejs │ │ │ └── 500.ejs │ │ ├── guide.ejs │ │ ├── icnvalidator.html │ │ ├── index.ejs │ │ ├── leaderboard.ejs │ │ ├── login.ejs │ │ ├── member.ejs │ │ ├── news.ejs │ │ ├── play.ejs │ │ ├── resetpassword.ejs │ │ └── termsofservice.ejs │ ├── server/ │ │ ├── api/ │ │ │ ├── AdminPanel.ts │ │ │ ├── EditorSavesAPI.int.test.ts │ │ │ ├── EditorSavesAPI.ts │ │ │ ├── GitHub.ts │ │ │ ├── LeaderboardAPI.ts │ │ │ ├── MemberAPI.ts │ │ │ ├── NewsAPI.ts │ │ │ ├── PracticeProgress.int.test.ts │ │ │ ├── PracticeProgress.ts │ │ │ ├── Prefs.int.test.ts │ │ │ └── Prefs.ts │ │ ├── app.ts │ │ ├── config/ │ │ │ ├── certOptions.ts │ │ │ ├── dateLocales.ts │ │ │ ├── generateCert.ts │ │ │ ├── i18n.ts │ │ │ ├── paths.ts │ │ │ ├── setupDev.ts │ │ │ └── translationLoader.ts │ │ ├── controllers/ │ │ │ ├── authController.ts │ │ │ ├── authRatelimiter.ts │ │ │ ├── authenticationTokens/ │ │ │ │ ├── accessTokenIssuer.ts │ │ │ │ ├── sessionManager.ts │ │ │ │ ├── tokenSigner.ts │ │ │ │ └── tokenValidator.ts │ │ │ ├── awsWebhook.ts │ │ │ ├── browserIDManager.ts │ │ │ ├── createAccountController.ts │ │ │ ├── createAccountController.unit.test.ts │ │ │ ├── deleteAccountController.ts │ │ │ ├── deployController.ts │ │ │ ├── emailController.ts │ │ │ ├── loginController.int.test.ts │ │ │ ├── loginController.ts │ │ │ ├── logoutController.ts │ │ │ ├── passwordResetController.ts │ │ │ ├── roles.ts │ │ │ └── verifyAccountController.ts │ │ ├── database/ │ │ │ ├── backupManager.ts │ │ │ ├── blacklistManager.ts │ │ │ ├── cleanupTasks.ts │ │ │ ├── database.ts │ │ │ ├── databaseTables.ts │ │ │ ├── editorSavesManager.ts │ │ │ ├── gamesManager.ts │ │ │ ├── leaderboardsManager.ts │ │ │ ├── liveGamesManager.ts │ │ │ ├── livePlayerGamesManager.ts │ │ │ ├── memberManager.ts │ │ │ ├── playerGamesManager.ts │ │ │ ├── ratingAbuseManager.ts │ │ │ └── refreshTokenManager.ts │ │ ├── game/ │ │ │ ├── gamemanager/ │ │ │ │ ├── abortresigngame.ts │ │ │ │ ├── activeplayers.ts │ │ │ │ ├── afkdisconnect.ts │ │ │ │ ├── cheatreport.ts │ │ │ │ ├── drawoffers.ts │ │ │ │ ├── gamecount.ts │ │ │ │ ├── gamelogger.ts │ │ │ │ ├── gamemanager.ts │ │ │ │ ├── gamerouter.ts │ │ │ │ ├── gameutility.ts │ │ │ │ ├── joingame.ts │ │ │ │ ├── liveGameRestore.ts │ │ │ │ ├── liveGameValues.ts │ │ │ │ ├── movesubmission.ts │ │ │ │ ├── onAFK.ts │ │ │ │ ├── onOfferDraw.ts │ │ │ │ ├── pastereport.ts │ │ │ │ ├── ratingabuse.ts │ │ │ │ ├── ratingcalculation.ts │ │ │ │ └── resync.ts │ │ │ ├── invitesmanager/ │ │ │ │ ├── acceptinvite.ts │ │ │ │ ├── cancelinvite.ts │ │ │ │ ├── createinvite.ts │ │ │ │ ├── invitesmanager.ts │ │ │ │ ├── invitesrouter.ts │ │ │ │ ├── invitessubscribers.ts │ │ │ │ └── inviteutility.ts │ │ │ ├── servermetadatautil.ts │ │ │ ├── statlogger.ts │ │ │ └── timecontrol.ts │ │ ├── middleware/ │ │ │ ├── banned.ts │ │ │ ├── errorHandler.ts │ │ │ ├── logEvents.ts │ │ │ ├── middleware.ts │ │ │ ├── rateLimit.ts │ │ │ ├── rateLimiters.ts │ │ │ ├── secureRedirect.ts │ │ │ ├── send404.ts │ │ │ └── verifyJWT.ts │ │ ├── routes/ │ │ │ └── root.ts │ │ ├── server.ts │ │ ├── socket/ │ │ │ ├── closeSocket.ts │ │ │ ├── echoTracker.ts │ │ │ ├── generalrouter.ts │ │ │ ├── openSocket.ts │ │ │ ├── receiveSocketMessage.ts │ │ │ ├── sendSocketMessage.ts │ │ │ ├── socketManager.ts │ │ │ ├── socketRouter.ts │ │ │ ├── socketServer.ts │ │ │ └── socketUtility.ts │ │ ├── types.ts │ │ └── utility/ │ │ ├── IP.ts │ │ ├── errorGuard.ts │ │ ├── generateDependancyGraph.ts │ │ ├── lockFile.ts │ │ ├── mailer.ts │ │ ├── newsUtil.ts │ │ ├── startupLogger.ts │ │ ├── translate.ts │ │ ├── urlUtils.ts │ │ └── zodlogger.ts │ ├── shared/ │ │ ├── chess/ │ │ │ ├── logic/ │ │ │ │ ├── boardchanges.ts │ │ │ │ ├── checkdetection.ts │ │ │ │ ├── checkmate.ts │ │ │ │ ├── checkresolver.ts │ │ │ │ ├── clock.ts │ │ │ │ ├── fourdimensionalmoves.ts │ │ │ │ ├── gamefile.ts │ │ │ │ ├── icn/ │ │ │ │ │ ├── icncommentutils.ts │ │ │ │ │ └── icnconverter.ts │ │ │ │ ├── initvariant.ts │ │ │ │ ├── insufficientmaterial.ts │ │ │ │ ├── legalmoves.ts │ │ │ │ ├── movepiece.ts │ │ │ │ ├── movesets.ts │ │ │ │ ├── movevalidation.ts │ │ │ │ ├── organizedpieces.ts │ │ │ │ ├── repetition.ts │ │ │ │ ├── specialdetect.ts │ │ │ │ ├── specialmove.ts │ │ │ │ ├── state.ts │ │ │ │ └── wincondition.ts │ │ │ ├── util/ │ │ │ │ ├── bdcoords.ts │ │ │ │ ├── boardutil.ts │ │ │ │ ├── clockutil.ts │ │ │ │ ├── coordutil.ts │ │ │ │ ├── gamefileutility.ts │ │ │ │ ├── gamerules.ts │ │ │ │ ├── metadatautil.ts │ │ │ │ ├── moveutil.ts │ │ │ │ ├── typeutil.ts │ │ │ │ ├── validcheckmates.ts │ │ │ │ └── winconutil.ts │ │ │ └── variants/ │ │ │ ├── fourdimensionalgenerator.ts │ │ │ ├── omega3generator.ts │ │ │ ├── omega4generator.ts │ │ │ ├── servervalidation.ts │ │ │ ├── validleaderboard.ts │ │ │ ├── variant.ts │ │ │ └── variantdictionary.ts │ │ ├── components/ │ │ │ └── header/ │ │ │ ├── pieceThemes.ts │ │ │ └── themes.ts │ │ ├── game_version.ts │ │ ├── types.ts │ │ └── util/ │ │ ├── EventBus.ts │ │ ├── editorutil.ts │ │ ├── isprime.ts │ │ ├── jsutil.ts │ │ ├── math/ │ │ │ ├── bimath.ts │ │ │ ├── bounds.ts │ │ │ ├── geometry.ts │ │ │ ├── math.ts │ │ │ └── vectors.ts │ │ ├── timeutil.ts │ │ ├── tokenConfig.ts │ │ ├── uuid.ts │ │ ├── validators.ts │ │ └── wsutil.ts │ ├── tests/ │ │ ├── integrationUtils.ts │ │ ├── testRequest.ts │ │ └── tests-setup.ts │ └── types/ │ ├── globals.d.ts │ ├── shaders.d.ts │ └── translations.ts ├── translation/ │ ├── changes.json │ ├── de-DE.toml │ ├── el-GR.toml │ ├── en-US.toml │ ├── es-ES.toml │ ├── fi-FI.toml │ ├── fr-FR.toml │ ├── news/ │ │ ├── en-US/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ ├── 2024-09-11.md │ │ │ ├── 2024-11-22.md │ │ │ ├── 2025-03-12.md │ │ │ ├── 2025-03-17.md │ │ │ ├── 2025-05-21.md │ │ │ ├── 2025-06-16.md │ │ │ ├── 2025-11-28.md │ │ │ ├── 2026-01-08.md │ │ │ ├── 2026-03-09.md │ │ │ └── 2026-04-24.md │ │ ├── es-ES/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ ├── 2024-09-11.md │ │ │ ├── 2024-11-22.md │ │ │ ├── 2025-03-12.md │ │ │ ├── 2025-03-17.md │ │ │ ├── 2025-05-21.md │ │ │ ├── 2025-06-16.md │ │ │ └── 2025-11-28.md │ │ ├── fi-FI/ │ │ │ ├── 2026-01-08.md │ │ │ ├── 2026-03-09.md │ │ │ └── 2026-04-24.md │ │ ├── fr-FR/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ ├── 2024-09-11.md │ │ │ ├── 2024-11-22.md │ │ │ ├── 2025-03-12.md │ │ │ ├── 2025-03-17.md │ │ │ ├── 2025-05-21.md │ │ │ ├── 2025-06-16.md │ │ │ ├── 2025-11-28.md │ │ │ ├── 2026-01-08.md │ │ │ └── 2026-03-09.md │ │ ├── pl-PL/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ └── 2024-09-11.md │ │ ├── pt-BR/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ └── 2024-09-11.md │ │ ├── zh-CN/ │ │ │ ├── 2024-01-29.md │ │ │ ├── 2024-05-14.md │ │ │ ├── 2024-05-24.md │ │ │ ├── 2024-05-27.md │ │ │ ├── 2024-07-09.md │ │ │ ├── 2024-07-13.md │ │ │ ├── 2024-07-22.md │ │ │ ├── 2024-08-01.md │ │ │ └── 2024-09-11.md │ │ └── zh-TW/ │ │ ├── 2024-01-29.md │ │ ├── 2024-05-14.md │ │ ├── 2024-05-24.md │ │ ├── 2024-05-27.md │ │ ├── 2024-07-09.md │ │ ├── 2024-07-13.md │ │ ├── 2024-07-22.md │ │ ├── 2024-08-01.md │ │ └── 2024-09-11.md │ ├── pl-PL.toml │ ├── pt-BR.toml │ ├── ru-RU.toml │ ├── zh-CN.toml │ └── zh-TW.toml ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/copilot-instructions.md ================================================ # Copilot Instructions for infinitechess.org ### ABOVE ALL: Follow the requirements and guidelines for pull requests found in `docs/GUIDELINES.md`! Each non-local session requires installing dependancies via `npm i --silent`. Check the working directory: if it contains Users, it's local; if it contains /home/runner/ or /github/, it's a GitHub Actions runner. BEFORE commiting any new changes, and before responding to review feedback, always ensure all workflow checks pass: `npm run lint --silent`, `npx tsc --noEmit`, and `npm test`. You must repeat each of these commands, even if you only made a minor code change since your last check to fix one of their errors. ## Key Guidelines 1. Follow industry standards and best code practices of today. 2. Maintain existing code structure, organization, and consistency. 3. Perform testing for new complex functions to ensure their output is as expected. 4. Actual unit/integration tests are not required, unless explicitly asked for. 5. Remember before committing changes, that all pull requests must follow the guidelines in `docs/GUIDELINES.md`. 6. No types should ever be re-exported inside scripts. All imports of a type should reference the source. ## Project Architecture - **Frontend:** TS, CSS, and assets in `src/client`. No major frameworks detected; uses vanilla and modular scripts. - **Backend:** Node.js server in `src/server/server.js`, with API, game logic, and socket communication. ## Key Files & Directories - `src/client/` — Frontend code - `src/server/` — Backend code - `src/shared/` — Shared utilities and chess logic - `dev-utils/` — Depricated code. Do not maintain. It is not imported by the source code. - `translation/` — Localization ## Conventions & Patterns - **Translations:** TOML files in `translation/` for i18n. News per locale in `translation/news/`. Any modification to the en-US.toml requires you update the version number at the top of the file, and reflect the change in `translation/changes.json`. Change notes in `changes.json` should be clear and concise, not containing more information than necessary, and always indicate the line numbers of the removed/added keys. - **UI Changes:** When asked to make UI changes, please verify the changes look good via the integrated browser. - **Rendering:** When asked to add new graphics and visuals to the game (canvas), refer to the Graphics Rendering Guide in `docs/GRAPHICS.md`. - When determining which imports can safely be removed, the command `npm run lint --silent` automatically tells you what imports are unused. ## Integration Points - **Database:** Uses SQLite via the `better-sqlite3` package. - **Socket Communication:** Real-time features via `src/server/socket/`. ## VS Code Tool Notes - **Rename Symbol:** To rename a symbol across all files that import it, point the rename symbol tool at the symbol's name inside a named `export { }` or `export type { }` block — this works for named exports only; `export default { }` object-style exports require manual renaming of all external call sites regardless of where the rename is applied. ## Integrated Browser - **Game interaction:** The infinite chess game board & pieces are on a canvas, which contents is only visible to you in screenshots. drag_element won't work on the canvas as it requires a DOM ref. Use run_playwright_code to probe board coordinates: hover page.mouse.move(sx, sy) at candidate screen positions and read await page.locator('#x').inputValue() / await page.locator('#y').inputValue() to map screen pixels to board squares. - **Moving pieces:** Use explicit mouse.down()+mouse.up() pairs, not page.mouse.click() — the game's input loop polls isKeyDown per frame and click() is too fast. After clicking "Start Game" to start a local game, wait at least 2000ms before making any moves — the canvas game loop needs time to initialize. - **Reading the board position:** press Digit5 (hold down for ~200ms so the game loop detects it) to trigger a clipboard copy of the ICN position string. Intercept it via: (1) inject `window._capturedClipboard=null; const orig=navigator.clipboard.writeText.bind(navigator.clipboard); navigator.clipboard.writeText=async(t)=>{window._capturedClipboard=t;navigator.clipboard.writeText=orig;return orig(t);}` into the page before pressing the key, then (2) `await page.keyboard.down('Digit5'); await page.waitForTimeout(200); await page.keyboard.up('Digit5');`, then (3) read `await page.evaluate(()=>window._capturedClipboard)`. navigator.clipboard.readText() will fail with permission denied — do not use it. ================================================ FILE: .github/pull_request_template.md ================================================ ### Type of Change (new feature, quality of life, bug fix, refactor, tooling, chore, tests, translation, or documentation): ### Scope (what part of the website or game does it apply to): ### Details of what it does (if it makes css style changes, include screenshots of the before & after): ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main - prod - distance-display paths-ignore: - '*.md' - 'docs/**' - 'LICENSE' pull_request: paths-ignore: - '*.md' - 'docs/**' - 'LICENSE' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: lint: name: Lint & Format runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Check formatting run: npm run format:check - name: Check linter rules run: npm run lint type-check: name: Type Check runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Run TypeScript compiler run: npx tsc --noEmit test: name: Tests runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test build: name: Build runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Run build run: npm run build - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 1 smoke-test: name: Server Smoke Test runs-on: ubuntu-latest timeout-minutes: 10 needs: build steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Download build artifact uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Smoke test server startup run: | node dist/server/server.js > server.log 2>&1 & SERVER_PID=$! sleep 5 if ! kill -0 $SERVER_PID 2>/dev/null; then echo "Server process exited prematurely. Log output:" cat server.log exit 1 fi response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$HTTPPORT_LOCAL/) kill $SERVER_PID 2>/dev/null || true wait $SERVER_PID 2>/dev/null || true if [ "$response" = "000" ]; then echo "Health check failed (no response). Log output:" cat server.log exit 1 fi echo "Server is healthy." env: NODE_ENV: development HTTPPORT_LOCAL: 3000 ACCESS_TOKEN_SECRET: ci-smoke-test-placeholder REFRESH_TOKEN_SECRET: ci-smoke-test-placeholder ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy on: push: branches: - prod paths-ignore: - '*.md' - 'docs/**' - 'LICENSE' workflow_dispatch: # Only one deploy may run at a time. If a second is triggered while one is in # progress, it waits in the queue (cancel-in-progress: false) rather than # being dropped — so no deploy is ever silently skipped. # Deploys are rare anyway, only ever happening when code is merged into `prod` # or triggered manually / from HydroChess via workflow_dispatch. concurrency: group: deploy cancel-in-progress: false # The deploy job only runs shell commands on the self-hosted machine and makes # no calls to the GitHub API, so no token permissions are needed. permissions: {} jobs: deploy: name: Deploy to Production runs-on: self-hosted timeout-minutes: 15 steps: # Step 1 — Pre-deploy DB backup. # Calls the live server over loopback. The server validates the secret, # performs a SQLite backup, and returns 200 on success. # The runner aborts the deploy if the backup fails. # Skipped for manual and API-triggered deploys. - name: Pre-deploy DB backup if: github.event_name == 'push' run: | response=$(curl -sk -o /dev/null -w "%{http_code}" \ -X POST "https://localhost:${{ vars.HTTPSPORT }}/api/prepare-restart" \ -H "X-Restart-Secret: ${{ secrets.RESTART_SECRET }}") if [ "$response" != "200" ]; then echo "Pre-deploy backup failed (HTTP $response). Aborting deploy." exit 1 fi echo "Pre-deploy backup succeeded." # Step 2 — Pull and install. # Skipped for manual and API-triggered deploys. - name: Pull latest code and install dependencies if: github.event_name == 'push' working-directory: ${{ secrets.DEPLOY_DIR }} run: git pull && npm ci --silent # Step 3 — Build. # Always runs. For HydroChess-triggered deploys this re-runs esbuild so # the new WASM binaries (fetched at build time) are bundled into the output. - name: Build working-directory: ${{ secrets.DEPLOY_DIR }} run: npm run build # Step 4 — Reload. # pm2 reload sends SIGINT to the current process, waits for it to exit # gracefully, then starts a new process from the freshly built files. # Clients whose WebSockets drop reconnect automatically within ~2.5s. - name: Reload server working-directory: ${{ secrets.DEPLOY_DIR }} run: pm2 reload infinitechess # Step 5 — Health check. # Waits 5 seconds for the new process to start, then hits the homepage # over HTTPS. -k skips hostname verification (cert is issued for the # domain, not localhost). Only HTTP 200 is treated as healthy. - name: Health check run: | sleep 5 response=$(curl -sk -o /dev/null -w "%{http_code}" \ "https://localhost:${{ vars.HTTPSPORT }}/") if [ "$response" != "200" ]; then echo "Health check failed (HTTP $response)." exit 1 fi echo "Server is healthy." ================================================ FILE: .gitignore ================================================ # .gitignore .env cert logs dist # Old json data storage "database" /database # SQLite Database Files *.db *.sqlite *.sqlite3 # SQLite Journal Files *.db-journal *.sqlite-journal *.sqlite3-journal # SQLite WAL mode sidecar files (created when journal_mode = WAL is enabled) *.db-wal *.db-shm *.sqlite-wal *.sqlite-shm *.sqlite3-wal *.sqlite3-shm # DB backups directory backups/ # Visual Studio automatically generated directories node_modules .vs # VSCode automatically generated directory .vscode # JetBrains IDEs .idea/ # PNPM pnpm-lock.yaml # Hidden mac file storing attributes of the directory .DS_Store # Speckit - FirePlank .specify specs .agent # HydroChess Engine build output src/client/pkg/ ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .prettierignore ================================================ # .prettierignore dist/ dev-utils/ src/client/pkg/ *.ejs ================================================ FILE: .prettierrc.json ================================================ { "semi": true, "useTabs": true, "tabWidth": 4, "singleQuote": true, "bracketSpacing": true, "trailingComma": "all", "arrowParens": "always", "printWidth": 100, "endOfLine": "lf", "overrides": [ { "files": ["*.yml", "*.json"], "options": { "tabWidth": 2, "useTabs": false } } ] } ================================================ FILE: CLAUDE.md ================================================ # Claude Instructions for infinitechess.org ### ABOVE ALL: Follow the requirements and guidelines for pull requests found in `docs/GUIDELINES.md`! When you finish making any new changes, always ensure all workflow checks pass: `npm run lint --silent`, `npx tsc --noEmit`, and `npm test`. You must repeat each of these commands, even if you only made a minor code change since your last check to fix one of their errors. ## Key Guidelines 1. Follow industry standards and best code practices of today. 2. Maintain existing code structure, organization, and consistency. 3. Perform testing for new complex functions to ensure their output is as expected. 4. Actual unit/integration tests are not required, unless explicitly asked for. 5. Remember before committing changes, that all pull requests must follow the guidelines in `docs/GUIDELINES.md`. 6. No types should ever be re-exported inside scripts. All imports of a type should reference the source. ## Project Architecture - **Frontend:** TS, CSS, and assets in `src/client`. No major frameworks detected; uses vanilla and modular scripts. - **Backend:** Node.js server in `src/server/server.js`, with API, game logic, and socket communication. ## Key Files & Directories - `src/client/` — Frontend code - `src/server/` — Backend code - `src/shared/` — Shared utilities and chess logic - `dev-utils/` — Depricated code. Do not maintain. It is not imported by the source code. - `translation/` — Localization ## Conventions & Patterns - **Translations:** TOML files in `translation/` for i18n. News per locale in `translation/news/`. Any modification to the en-US.toml requires you update the version number at the top of the file, and reflect the change in `translation/changes.json`. Change notes in `changes.json` should be clear and concise, not containing more information than necessary, and always indicate the line numbers of the removed/added keys. - **Rendering:** When asked to add new graphics and visuals to the game (canvas), refer to the Graphics Rendering Guide in `docs/GRAPHICS.md`. - When determining which imports can safely be removed, the command `npm run lint --silent` automatically tells you what imports are unused. ## Integration Points - **Database:** Uses SQLite via the `better-sqlite3` package. - **Socket Communication:** Real-time features via `src/server/socket/`. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # Infinite Chess Web Server [InfiniteChess.org](https://www.infinitechess.org) is a free and ad-less website for playing several variants on an infinite, boundless board. What began as an indie project by [Naviary](https://www.youtube.com/@Naviary) in 2022 has been growing since. What drives this project is the concept of Chess and Infinity intertwined! There shall be no more limits, only freedom! That being said, there are many interesting tactics that arise from the size of the board, not to mention lots of wild mathematics surrounding it, just check out a couple of the [YouTube videos](https://www.youtube.com/@Naviary)! [Join us on Discord](https://discord.gg/NFWFGZeNh5) for more info, or just to chat about the game! ## Contributing This project is open source! If you have skills in HTML, CSS, JavaScript, TypeScript, or Node.js, we welcome contributions! | Document | Description | | ------------------------------------------------------------------------ | ----------------------------------------- | | **[Setup Guide](./docs/SETUP.md)** | Setup your contribution workflow | | **[Navigation Guide](./docs/NAVIGATING.md)** | Get an overview of the codebase | | **[Contributing Guide](./docs/GUIDELINES.md)** | PR requirements and guidelines | | **[Issues](https://github.com/Infinite-Chess/infinitechess.org/issues)** | Inquire available tasks | | **[Translation Guide](./docs/TRANSLATIONS.md)** | Translate the website to another language | | **[Graphics Guide](./docs/GRAPHICS.md)** | Learn how the game renders graphics | ## Roadmap There are still MANY more items I have planned for this project. Just a few of them are: - Modern website design - Analysis Board - Spectating - Games with infinitely many pieces - 4 Player - Massive Multiplayer Online Because I want Infinite Chess to grow to as big of an audience as possible, it's licensed with a goal of keeping this game free forever! Check out [Copying](./docs/COPYING.md) for more details. ================================================ FILE: build/client.ts ================================================ // build/client.ts import fs from 'fs'; import swc from '@swc/core'; import path from 'node:path'; import { glob } from 'glob'; import { readFile } from 'node:fs/promises'; import browserslist from 'browserslist'; // @ts-ignore this package doesn't have a declaration file import stripComments from 'glsl-strip-comments'; import { transform, browserslistToTargets } from 'lightningcss'; import esbuild, { BuildOptions, Plugin, PluginBuild } from 'esbuild'; import { getESBuildLogStatusLogger } from './plugins.js'; // ================================= CONSTANTS ================================= // Targetted browsers for CSS transpilation // Format: https://github.com/browserslist/browserslist?tab=readme-ov-file#query-composition const cssTargets = browserslistToTargets(browserslist('defaults')); /** * Any ES Module that any HTML document IMPORTS directly! * ADD TO THIS when we create new modules that nothing else depends on! * ESBuild has to build each of them and their dependancies * into their own bundle! */ const ESMEntryPoints = [ 'src/client/scripts/esm/game/main.ts', 'src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts', 'src/client/scripts/esm/components/header/header.ts', 'src/client/scripts/esm/views/index.ts', 'src/client/scripts/esm/views/member.ts', 'src/client/scripts/esm/views/leaderboard.ts', 'src/client/scripts/esm/views/login.ts', 'src/client/scripts/esm/views/news.ts', 'src/client/scripts/esm/views/createaccount.ts', 'src/client/scripts/esm/views/resetpassword.ts', 'src/client/scripts/esm/views/guide.ts', 'src/client/scripts/esm/views/admin.ts', 'src/client/scripts/esm/views/icnvalidator.ts', 'src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts', 'src/client/scripts/esm/game/chess/engines/hydrochess.ts', 'src/client/scripts/esm/workers/icnvalidator.worker.ts', ]; /** CommonJS modules imported by html pages. */ const CJSEntryPoints = ['src/client/scripts/cjs/game/htmlscript.ts']; // ================================= PLUGINS =================================== const ESMBuildPlugin = getESBuildLogStatusLogger( '✅ Client ESM Build successful.', '❌ Client ESM Build failed.', ); const CJSBuildPlugin = getESBuildLogStatusLogger( '✅ Client CJS Build successful.', '❌ Client CJS Build failed.', ); /** * Returns an esbuild plugin that resolves a promise once the initial esbuild build is complete. * BuildContext.watch() resolves only once the watch mode is SET UP, not when the first build is DONE, * so this provides a promise that ONLY resolves once the first build is done. * @returns An object containing the esbuild plugin and the promise to await. */ function getInitialBuildPlugin(): { plugin: Plugin; initialBuild: Promise } { let isFirstBuild = true; let resolve: () => void; const promise = new Promise((r) => { resolve = r; }); const plugin: Plugin = { name: 'initial-build-waiter', setup(build: PluginBuild) { // This hook runs when a build has finished build.onEnd(() => { if (!isFirstBuild) return; isFirstBuild = false; // Signal that the first build is done, even if // there was an error, so that watch mode can continue. resolve(); }); }, }; return { plugin, initialBuild: promise }; } /** An esbuild plugin object that minifies GLSL shader files by stripping comments. */ const GLSLMinifyPlugin = { name: 'glsl-minify', setup(build: PluginBuild) { // Intercept .glsl files and minify them build.onLoad({ filter: /\.glsl$/ }, async (args) => { try { // Read the GLSL file const source = await readFile(args.path, 'utf8'); // Strip comments from the GLSL source const minified = stripComments(source); // Return the minified content as text return { contents: minified, loader: 'text', }; } catch (error: unknown) { return { errors: [ { text: `Failed to minify GLSL file: ${error instanceof Error ? error.message : String(error)}`, location: { file: args.path }, }, ], }; } }); }, }; const ESMBuildOptions: BuildOptions = { bundle: true, entryPoints: ESMEntryPoints, outdir: './dist/client/scripts/esm', /** * Enable code splitting, which means if multiple entry points require the same module, * that dependancy will be separated out of both of them which means it isn't duplicated, * and there's only one instance of it per page. * This also means more requests to the server, but not many. * If this is false, multiple copies of the same code may be loaded onto a page, * each belonging to a separate entry point module. */ splitting: true, format: 'esm', sourcemap: true, // Enables sourcemaps for debugging in the browser. // minify: true, // Enable minification. SWC is more compact so we don't use esbuild's loader: { '.wasm': 'file' }, }; const CJSBuildOptions: BuildOptions = { bundle: true, entryPoints: CJSEntryPoints, outdir: './dist/client/scripts/cjs', outbase: 'src/client/scripts/cjs', // Without this, htmlscript.js gets put in cjs/ instead of cjs/game/ format: 'cjs', sourcemap: true, }; // ================================= BUILDING =================================== /** Builds the client's scripts and minifies css. */ export async function buildClient(isDev: boolean): Promise { // console.log(`Building client in ${isDev ? 'DEVELOPMENT' : 'PRODUCTION'} mode...`); // Create signaling plugins to wait for the initial build in watch mode const { plugin: esmInitialBuildPlugin, initialBuild: esmInitialBuild } = getInitialBuildPlugin(); const { plugin: cjsInitialBuildPlugin, initialBuild: cjsInitialBuild } = getInitialBuildPlugin(); const ESMContext = await esbuild.context({ ...ESMBuildOptions, legalComments: isDev ? undefined : 'none', // Only strip copyright notices in production. plugins: [ESMBuildPlugin, GLSLMinifyPlugin, esmInitialBuildPlugin], }); const CJSContext = await esbuild.context({ ...CJSBuildOptions, legalComments: isDev ? undefined : 'none', // Only strip copyright notices in production. plugins: [CJSBuildPlugin, GLSLMinifyPlugin, cjsInitialBuildPlugin], }); if (isDev) { // Start watch mode. This kicks off the initial builds ESMContext.watch(); CJSContext.watch(); // Wait for both of the initial builds to complete await Promise.all([esmInitialBuild, cjsInitialBuild]); } else { /** * ESBuild takes each entry point and all of their dependencies and merges them bundling them into one file. * If multiple entry points share dependencies, then those dependencies will be split into separate modules, * which means they aren't duplicated, and there's only one instance of it per page. * This also means more requests to the server, but not many. */ // Production // Build once and exit since not in watch mode await ESMContext.rebuild(); ESMContext.dispose(); await CJSContext.rebuild(); CJSContext.dispose(); // Minify JS and CSS // console.log('Minifying production assets...'); // Further minify them. This cuts off their size a further 60%!!! await minifyScriptDirectory( './dist/client/scripts/cjs/', './dist/client/scripts/cjs/', false, ); await minifyScriptDirectory( './dist/client/scripts/esm/', './dist/client/scripts/esm/', true, ); await minifyCSSFiles(); } } /** * Minifies all JavaScript files in a directory and writes them to an output directory. * @param inputDir - The directory to scan for scripts. * @param outputDir - The directory where the minified files will be written. * @param module - True if the scripts are ES Modules instead of CommonJS. * @returns Resolves when all files are minified. */ async function minifyScriptDirectory( inputDir: string, outputDir: string, module: boolean, ): Promise { const files = await glob('**/*.js', { cwd: inputDir, nodir: true }); for (const file of files) { const inputFilePath = path.join(inputDir, file); const outputFilePath = path.join(outputDir, file); const content = await readFile(inputFilePath, 'utf-8'); const minified = await swc.minify(content, { mangle: true, // Enable variable name mangling compress: true, // Enable compression sourceMap: false, module, // Include if we're minifying ES Modules instead of Common JS }); // Write the minified file to the output directory fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); fs.writeFileSync(outputFilePath, minified.code); // console.log(`Minified: ${outputFilePath}`); } } /** * Minifies all CSS files from src/client/css/ directory * to the distribution directory, preserving the original structure. * @returns Resolves when all CSS files are processed. */ async function minifyCSSFiles(): Promise { // Bundle and compress all css files const cssFiles = await glob('**/*.css', { cwd: './dist/client/css', nodir: true }); for (const file of cssFiles) { // Minify css files const outputFilePath = `./dist/client/css/${file}`; const { code } = transform({ targets: cssTargets, code: Buffer.from(await readFile(outputFilePath, 'utf8')), minify: true, filename: path.basename(outputFilePath), }); // Write into /dist fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); fs.writeFileSync(outputFilePath, code); } } ================================================ FILE: build/engine-wasm.ts ================================================ // build/engine-wasm.ts /** * HydroChess WASM Engine Setup Script * * This ensures that the HydroChess WASM engine is available. */ import fs from 'node:fs'; import path from 'node:path'; import * as z from 'zod'; import { logZodError } from '../src/server/utility/zodlogger'; // Constants ------------------------------------------------------------------- /** Absolute path to the HydroChess WASM engine pkg directory */ const HYDROCHESS_WASM_DIR = path.join(process.cwd(), 'src', 'client', 'pkg', 'hydrochess'); /** API URL to check the latest released version */ const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/Infinite-Chess/hydrochess/releases/latest'; /** Zod schema for validating GitHub release API response */ const releaseDataSchema = z.object({ tag_name: z.string(), assets: z.array( z.object({ name: z.string(), browser_download_url: z.string(), }), ), }); // Functions ------------------------------------------------------------------- /** * Ensures the HydroChess WASM engine is available and up-to-date. * Automatically downloads the pre-built WASM if there is a new release. */ export async function setupEngineWasm(): Promise { const label = '[hydrochess]'; const pkgDir = path.join(HYDROCHESS_WASM_DIR, 'pkg'); const wasmFile = path.join(pkgDir, 'hydrochess_wasm_bg.wasm'); const jsFile = path.join(pkgDir, 'hydrochess_wasm.js'); // Note: If you are manually rebuilding the engine binaries on a separate // vscode window with the hydrochess repo open, and have setup a symlink // for this submodule to point to that project, then this file will be innacurate. // But it works because the local build process thinks we're already on the latest version. const versionFile = path.join(pkgDir, '.engine-version'); // Download pre-built binary if new version available let localVersion = ''; if (fs.existsSync(versionFile)) { localVersion = fs.readFileSync(versionFile, 'utf-8').trim(); } let releaseData: z.infer; try { const response = await fetch(LATEST_RELEASE_API_URL, { headers: { 'User-Agent': 'Infinite-Chess-Build-Script' }, }); if (!response.ok) throw new Error(`GitHub API failed: ${response.statusText}`); const rawReleaseData = await response.json(); const parseResult = releaseDataSchema.safeParse(rawReleaseData); if (!parseResult.success) { logZodError( rawReleaseData, parseResult.error, `${label} GitHub API returned invalid data.`, ); throw new Error(`GitHub API returned invalid data: ${parseResult.error.message}`); } releaseData = parseResult.data; } catch (error: unknown) { console.warn( `${label} Could not check for new version:`, error instanceof Error ? error.message : String(error), ); if (fs.existsSync(wasmFile)) { console.log(`${label} Using existing local version.`); return; } // If we can't check and have no local copy, fail and inform the user. console.error(`${label} Automatic download failed and no local copy exists.`); return; } const remoteVersion = releaseData.tag_name; if ( localVersion && localVersion === remoteVersion && fs.existsSync(wasmFile) && fs.existsSync(jsFile) ) { console.log(`${label} Engine is up-to-date (${localVersion}).`); return; } console.log(`${label} New version detected (${remoteVersion}). Downloading release...`); // Extract dynamic download URLs from the API response const wasmAsset = releaseData.assets.find((a) => a.name === 'hydrochess_wasm_bg.wasm'); const jsAsset = releaseData.assets.find((a) => a.name === 'hydrochess_wasm.js'); if (!wasmAsset || !jsAsset) { console.error(`${label} Release ${remoteVersion} is missing required asset files.`); return; } try { await fs.promises.mkdir(pkgDir, { recursive: true }); const downloadFile = async (url: string, dest: string): Promise => { const response = await fetch(url); if (!response.ok) throw new Error(`Failed to download ${url}: ${response.statusText}`); const buffer = Buffer.from(await response.arrayBuffer()); await fs.promises.writeFile(dest, buffer); console.log(`${label} Downloaded ${path.basename(dest)}`); }; await Promise.all([ downloadFile(wasmAsset.browser_download_url, wasmFile), downloadFile(jsAsset.browser_download_url, jsFile), ]); // Stamp the downloaded version await fs.promises.writeFile(versionFile, remoteVersion); console.log(`${label} Hydrochess engine is ready (${remoteVersion}).`); } catch (error) { console.error( `${label} Automatic download failed:`, error instanceof Error ? error.message : String(error), ); } } ================================================ FILE: build/env.ts ================================================ // build/env.ts /** * Ensures the .env file exists, generating it with default values if it doesn't. * And ensures its contents are valid. */ import fs from 'fs'; import crypto from 'crypto'; import dotenv from 'dotenv'; const envPath = '.env'; /** Ensure .env file exists and is valid. */ export function setupEnv(): void { ensureExists(); ensureValid(); } /** Ensure .env exists, generating it with default values if it doesn't. */ function ensureExists(): void { if (fs.existsSync(envPath)) return; // Doesn't exist, generate it with default values const ACCESS_TOKEN_SECRET = generateSecret(32); // 32 bytes = 64 characters in hex const REFRESH_TOKEN_SECRET = generateSecret(32); const content = ` NODE_ENV=development ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET} REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET} RESTART_SECRET= CERT_PATH= AWS_REGION= EMAIL_FROM_ADDRESS= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= HTTPPORT=80 # In production, must match the HTTPSPORT repository variable for the Deploy workflow to work correctly. HTTPSPORT=443 HTTPPORT_LOCAL=3000 HTTPSPORT_LOCAL=3443 GITHUB_API_KEY= GITHUB_REPO=Infinite-Chess/infinitechess.org APP_BASE_URL=https://www.infinitechess.org `; fs.writeFileSync(envPath, content.trim()); console.log('Generated .env file'); // Immediately UPDATE the contents of process.env dotenv.config(); } /** * Generate a random string of specified length. * @param length - The length of the generated string, in bytes. The resulting string will be double this amount in characters. * @returns The generated random string */ function generateSecret(length: number): string { return crypto.randomBytes(length).toString('hex'); } /** Ensures some existing environment variables are valid. */ function ensureValid(): void { const NODE_ENV = process.env['NODE_ENV']; const validValues = ['development', 'production', 'test']; // 'test' only appears during Vitest unit testing. if (NODE_ENV === undefined || !validValues.includes(NODE_ENV)) { throw new Error( `NODE_ENV environment variable must be either 'development', 'production', or 'test', received '${NODE_ENV}'.`, ); } } ================================================ FILE: build/index.ts ================================================ // build/index.ts /** * This script deploys all files and assets from /src/client to /dist in order to run the website. * * Development mode: Transpile all TypeScript files to JavaScript. * Production mode: Transpile and bundle all TypeScript files to JavaScript, and minify via @swc/core. * Further, all css files are minified by lightningcss. */ import { setupEnv } from './env'; import { buildViews } from './views'; import { buildClient } from './client'; import { buildServer } from './server'; import { setupEngineWasm } from './engine-wasm'; import 'dotenv/config'; // Imports all properties of process.env, if it exists // Ensure .env file exists and has valid contents setupEnv(); /** Whether additional minifying of bundled scripts and css files should be skipped. */ const USE_DEVELOPMENT_BUILD = process.argv.includes('--dev'); if (USE_DEVELOPMENT_BUILD && process.env['NODE_ENV'] === 'production') { throw new Error( "Cannot run build process with --dev flag when NODE_ENV environment variable is 'production'!", ); } // Ensure the HydroChess WASM engine is available // Must be awaited since client build has a .wasm dependency on it. await setupEngineWasm(); // Build both client and server scripts // Await all so the script doesn't finish and node terminate before esbuild is done. await Promise.all([buildClient(USE_DEVELOPMENT_BUILD), buildServer(USE_DEVELOPMENT_BUILD)]); // Generate Static Views (HTML) await buildViews(); // console.log('Build process finished.'); ================================================ FILE: build/plugins.ts ================================================ // build/plugins.ts /** * Contains shared esbuild plugins used in both client and server builds. */ import type { Plugin, PluginBuild } from 'esbuild'; /** Returns an esbuild plugin that logs whenever a build finishes/fails. */ export function getESBuildLogStatusLogger(successMessage: string, failureMessage: string): Plugin { return { name: 'log-rebuild', setup(build: PluginBuild) { // This hook runs when a build has finished build.onEnd((result) => { if (result.errors.length > 0) console.error(failureMessage); else console.log(successMessage); }); }, }; } ================================================ FILE: build/server.ts ================================================ // build/server.ts import { glob } from 'glob'; import esbuild, { BuildOptions } from 'esbuild'; import { getESBuildLogStatusLogger } from './plugins.js'; // ================================= CONSTANTS ================================= const entryPoints = await glob(['src/server/**/*.{ts,js}', 'src/shared/**/*.{ts,js}'], { ignore: ['**/*.test.{ts,js}'], }); // ================================= BUILDING =================================== const esbuildServerRebuildPlugin = getESBuildLogStatusLogger( '✅ Server Build successful.', '❌ Server Build failed.', ); const esbuildOptions: BuildOptions = { // Transpile all TS files from BOTH directories entryPoints: entryPoints, platform: 'node', bundle: false, // No bundling for the server. Just transpile each file individually outdir: 'dist', format: 'esm', sourcemap: true, // Patches file paths from server console errors to the correct src/ file plugins: [esbuildServerRebuildPlugin], }; // ================================= BUILDING =================================== /** Builds the server's scripts, transpiling them all into javascript (no bundling). */ export async function buildServer(isDev: boolean): Promise { // console.log(`Building server in ${isDev ? 'DEVELOPMENT' : 'PRODUCTION'} mode...`); const context = await esbuild.context(esbuildOptions); if (isDev) { await context.watch(); // console.log('esbuild is watching for SERVER changes...'); } else { await context.rebuild(); context.dispose(); // console.log('Server build complete.'); } } ================================================ FILE: build/views.ts ================================================ // build/views.ts /** * Generates static HTML views from EJS templates and translation files. */ import fs from 'fs'; import path from 'path'; import i18next from 'i18next'; import ejs, { Data } from 'ejs'; import { fileURLToPath } from 'node:url'; import editorutil from '../src/shared/util/editorutil.js'; import translationLoader from '../src/server/config/translationLoader.js'; import { DEFAULT_LANGUAGE } from '../src/server/utility/translate.js'; import { UNCERTAIN_LEADERBOARD_RD } from '../src/server/game/gamemanager/ratingcalculation.js'; // Constants ----------------------------------------------------------------- const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Templates without any external data other than translations. * Don't insert names with file extensions. */ const staticTranslatedTemplates = [ 'createaccount', 'credits', 'guide', 'index', 'login', 'member', 'news', 'leaderboard', 'play', 'termsofservice', 'resetpassword', 'admin', 'errors/400', 'errors/401', 'errors/404', 'errors/409', 'errors/500', ]; // Functions ----------------------------------------------------------------- /** Generates translated versions of templates in {@link staticTranslatedTemplates}. */ export async function buildViews(): Promise { // Load data const translations = translationLoader.loadTranslations(); // Grab supported languages from the loaded translations const supportedLanguages = Object.keys(translations); const news = translationLoader.loadNews(supportedLanguages); // Initialize i18next for the build process so the 't' function works during render await i18next.init({ resources: translations, defaultNS: 'default', fallbackLng: DEFAULT_LANGUAGE, // debug: true, // Enable debug mode to see logs for missing keys and other details }); const languages_list = Object.entries(translations).map( ([languageCode, languageTranslations]) => ({ code: languageCode, name: languageTranslations.default['name'] as string, englishName: languageTranslations.default['english_name'] as string, }), ); const templatesPath = path.join(__dirname, '../dist/client/views'); for (const languageCode of Object.keys(translations)) { // Specific ejsOptions for rendering this language const ejsData: Data = { // Function for translations t: function (key: string, options: Record = {}) { options['lng'] = languageCode; // Make sure language is correct return i18next.t(key, options); }, languages: languages_list, language: languageCode, distfolder: path.join(__dirname, '../dist'), viewsfolder: templatesPath, // Inject the news HTML newsHTML: news[languageCode], // Custom included variables ratingDeviationUncertaintyThreshold: UNCERTAIN_LEADERBOARD_RD, editorPositionNameMaxLength: editorutil.MAX_POSITION_NAME_LENGTH, }; // The output directory for this language's rendered templates const renderDirectory = path.join(templatesPath, languageCode); // Render each of this language's static translated templates for (const template of staticTranslatedTemplates) { const templatePath = path.join(templatesPath, template + '.ejs'); const templateFile = fs.readFileSync(templatePath).toString(); const renderedPath = path.join(renderDirectory, template + '.html'); const renderedFile = ejs.render(templateFile, ejsData); // Render the file fs.mkdirSync(path.dirname(renderedPath), { recursive: true }); // Ensure directory exists fs.writeFileSync(renderedPath, renderedFile); // Write the rendered file } } } ================================================ FILE: dev-utils/ICN_METADATA_TRANSLATIONS.md ================================================ # English Translations Required for ICN Metadata ICN metadata must **always** be written in English, regardless of the user's language. --- ## 1. Player Names (`White` / `Black` metadata) Used when a player is not signed in, or is the local user in engine games. | TOML Key | English Value | Used In | | --------------------------------- | ------------- | ------------------------------------------------------------------------------- | | `play.javascript.you_indicator` | `(You)` | Engine & board-editor engine games — assigned to the human player's color | | `play.javascript.guest_indicator` | `(Guest)` | Online games — assigned to non-signed-in players (server-side, already English) | --- ## 2. Variant Names (`Variant` metadata / `Event` metadata) The variant code (e.g. `CoaIP`) is translated to its English spoken name when writing the `Event` string and when copying a game to ICN (the `Variant` metadata field). | TOML Key | English Value | | ----------------------------------- | -------------------------------------------------- | | `play.play-menu.Classical` | `Classical` | | `play.play-menu.Confined_Classical` | `Confined Classical` | | `play.play-menu.Classical_Plus` | `Classical+` | | `play.play-menu.CoaIP` | `Chess on an Infinite Plane` | | `play.play-menu.Pawndard` | `Pawndard` | | `play.play-menu.Knighted_Chess` | `Knighted Chess` | | `play.play-menu.Palace` | `Palace` | | `play.play-menu.Knightline` | `Knightline` | | `play.play-menu.Core` | `Core` | | `play.play-menu.Standarch` | `Standarch` | | `play.play-menu.Pawn_Horde` | `Pawn Horde` | | `play.play-menu.Space_Classic` | `Space Classic` | | `play.play-menu.Space` | `Space` | | `play.play-menu.Obstocean` | `Obstocean` | | `play.play-menu.Abundance` | `Abundance` | | `play.play-menu.Amazon_Chandelier` | `Amazon Chandelier` | | `play.play-menu.Containment` | `Containment` | | `play.play-menu.Classical_Limit_7` | `Classical - Limit 7` | | `play.play-menu.CoaIP_Limit_7` | `Coaip - Limit 7` | | `play.play-menu.Chess` | `Chess` | | `play.play-menu.Classical_KOTH` | `Experimental: Classical - KOTH` | | `play.play-menu.CoaIP_KOTH` | `Experimental: Coaip - KOTH` | | `play.play-menu.CoaIP_HO` | `Chess on an Infinite Plane - Huygens Option` | | `play.play-menu.CoaIP_RO` | `Chess on an Infinite Plane - Roses Option` | | `play.play-menu.CoaIP_NO` | `Chess on an Infinite Plane - Knightriders Option` | | `play.play-menu.Omega` | `Showcase: Omega` | | `play.play-menu.Omega_Squared` | `Showcase: Omega^2` | | `play.play-menu.Omega_Cubed` | `Showcase: Omega^3` | | `play.play-menu.Omega_Fourth` | `Showcase: Omega^4` | | `play.play-menu.4x4x4x4_Chess` | `4×4×4×4 Chess` | | `play.play-menu.5D_Chess` | `5D Chess` | --- ## In the Future During the website redesign, all of these required keys should be better restructured (they should more apparently be for the javascript, and not for any play-menu). In addition, these are the only keys for which all English translations should be sent to the client, on top of their existing language-specific translations which should already be sent. ================================================ FILE: dev-utils/REDESIGN/design.md ================================================ # Summary of what should go on each page/component ## Header - Site name + logo -> Home page - News - Practice - Editor - Analysis - Leaderboard - Donate - Profile/Login - Register/Logout - Settings ## Footer - About Infinite Chess - Contact - Terms of Service - Privacy - GitHub - Discord - Youtube ## Homepage - Scrolling perspective mode board? Generally across the site though, a static 2D checkerboard background like that of the chess stack exchange. - Lobby sits on the homepage. - Below that: Spectate live games. ## Lobby - [ ] Determine the maximum piece count where images are barely below recognizable. Convert that to characters - Modal for creating an invite. Public/Private option. Private creates a url your friend can visit to view the invite and its properties. Option to provide custom position via ICN. Button to take selected variant to the editor. Maximum piece count prevents dirty images. Game modes available: Chess, 4 Dimensions, Showcases. Each has their own dropdowns with respective variants. - Hovering over invites renders a small tooltip-popup window that previews the board, and custom gamerules, if any. ## Games - Online games navigatable to via a link. Allows spectating if still live. Allows accepting a private invite if not yet joined. - One vertical bar with clocks, moves, and chat, and material lost per side. Does chat have reporting? Do laws require it have reporting? - The moves bar uses silhouettes of the piece svgs. ## Analysis Board - Make, undo, change move history to perform analysis on positions. - Turn on the engine to display the top move, and the score. ## Board Editor - Share games via url. Next to the link to copy notation. Maximum piece count / icn length prevents dirty images. - Create an invite from the position. Maximum piece count / icn length prevents dirty images. Same model popup as creating an invite from the lobby. - Move to Analysis Board ## Profile - Game history - Change username ## Donation Page Anyone that becomes a patron gets a cool badge next to their username. Any monthly donation gives you the badge. $1+ When monthly dontations stop, badge is removed. Maybe a lifetime donation amount where the badge is permanent? Lichess offers golden wings after 5 years of active patron status. And instantly after a liftime donation, unlocking all colors. ## Light and Dark Theme For light and dark themes, store colors once per theme as a small set of semantic variables, and every element in the entire codebase references those variables. EXAMPLE THEME (for us we will have significantly fewer variables to start out): /* src/client/css/themes.css */ :root, [data-theme="light"] { --c-bg: #f0efea; --c-surface: #ffffff; --c-surface-raise: #e8e7e2; --c-surface-sink: #dddcd6; --c-text: #1a1a1a; --c-text-2: #4a4a4a; --c-text-muted: #757575; --c-text-inv: #f0efea; --c-border: #cccccc; --c-border-focus: #5a9a5a; --c-brand: #5a9a5a; --c-brand-hover: #4a8a4a; --c-link: #2060a0; --c-focus-ring: rgba(90, 154, 90, 0.4); --c-error: #cc2222; --c-warning: #b06000; --c-success: #2a7a2a; } [data-theme="dark"] { --c-bg: #18181a; --c-surface: #222226; --c-surface-raise: #2c2c30; --c-surface-sink: #141416; --c-text: #e2e2da; --c-text-2: #b0b0a8; --c-text-muted: #787870; --c-text-inv: #18181a; --c-border: #3a3a3e; --c-border-focus: #70ba70; --c-brand: #6aaa6a; --c-brand-hover: #7aba7a; --c-link: #6090d0; --c-focus-ring: rgba(106, 170, 106, 0.4); --c-error: #ee5555; --c-warning: #e09020; --c-success: #50aa50; } ================================================ FILE: dev-utils/REDESIGN/runner_setup.md ================================================ # GitHub Actions Runner Setup [← Back to Navigation Guide](./NAVIGATING.md) This guide covers everything needed to bring up automated deployment for `infinitechess.org`: 1. [Install the self-hosted runner on the production Mac](#part-1-install-the-self-hosted-runner) 2. [Configure repository secrets and variables](#part-2-configure-repository-secrets-and-variables) 3. [Add `RESTART_SECRET` to the production `.env`](#part-3-add-restart_secret-to-the-production-env) 4. [Add the `workflow_dispatch` trigger to the HydroChess workflow](#part-4-add-the-workflow_dispatch-trigger-to-the-hydrochess-workflow) 5. [Verify all three triggers work](#part-5-verify-each-trigger) > **Note:** PM2 is assumed to be fully configured and running `infinitechess` before starting these steps. --- ## Part 1: Install the Self-Hosted Runner The self-hosted runner is a small background process that holds a persistent long-poll connection to GitHub. When a deploy workflow is triggered, GitHub wakes the runner and it executes the workflow steps as shell commands on the production machine. ### 1.1 Open the runner registration page 1. Go to `https://github.com/Infinite-Chess/infinitechess.org` on GitHub. 2. Click **Settings → Actions → Runners → New self-hosted runner**. 3. Select **macOS** as the operating system and choose the correct architecture: - **x64** for Intel Macs - **ARM64** for Apple Silicon (M1/M2/M3) 4. GitHub displays a set of shell commands with the exact download URL, checksum, and a one-time registration token. **Do not close this page** — the token expires after one hour. ### 1.2 Create the runner directory and download the runner Run the commands shown on the GitHub setup page. They will look like the following (use the exact URLs and checksums from GitHub, not these examples): ```bash # Create a dedicated directory for the runner, outside the production code directory mkdir ~/actions-runner && cd ~/actions-runner # Download the runner package (use the URL shown on the GitHub page) curl -o actions-runner-osx-arm64-X.Y.Z.tar.gz -L https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-osx-arm64-X.Y.Z.tar.gz # Verify the downloaded file's integrity (use the checksum shown on the GitHub page) echo "CHECKSUM actions-runner-osx-arm64-X.Y.Z.tar.gz" | shasum -a 256 -c # Extract the archive tar xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz ``` - **`curl -o … -L`**: Downloads the runner binary. `-o` sets the output filename; `-L` follows redirects. - **`shasum -a 256 -c`**: Verifies the SHA-256 checksum to ensure the download was not corrupted. - **`tar xzf`**: Extracts the gzipped tar archive into the current directory. ### 1.3 Configure the runner Run the configuration command shown on the GitHub setup page. It will look like: ```bash ./config.sh --url https://github.com/Infinite-Chess/infinitechess.org --token ``` - **`--url`**: The repository the runner will serve jobs for. - **`--token`**: The one-time registration token from step 1.1. When prompted: - **Runner name**: Press **Enter** to accept the default (the machine's hostname), or type a custom name. - **Additional labels**: Press **Enter** to skip. - **Work folder**: Press **Enter** to accept the default (`_work`). ### 1.4 Install the runner as a launchd service Installing as a service means the runner starts automatically on login and survives terminal sessions. ```bash # Register the runner as a macOS launchd user service ./svc.sh install # Start the service immediately (no need to log out and back in) ./svc.sh start ``` - **`./svc.sh install`**: Creates a `launchd` plist under `~/Library/LaunchAgents/` so the runner starts every time you log in. - **`./svc.sh start`**: Starts the service right now. To check the runner's status at any time (make sure you're cd'd into `~/actions-runner/`): ```bash ./svc.sh status ``` To view runner logs if something goes wrong: ```bash # Tail the last 50 lines of the runner log tail -50 ~/actions-runner/_diag/Runner_*.log ``` Once installed, the runner appears as **Online** in **Settings → Actions → Runners** on GitHub. --- ## Part 2: Configure Repository Secrets and Variables Go to **Settings → Secrets and variables → Actions** on the `infinitechess.org` repository to add the following. ### 2.1 `RESTART_SECRET` (Secret) The server's `deployController.ts` checks the `X-Restart-Secret` header against this value before performing the pre-deploy database backup. Only the runner (which has this secret injected as an environment variable at runtime) can call that endpoint. **Generate the secret** by running this command in any terminal: ```bash openssl rand -hex 32 ``` - **`openssl rand -hex 32`**: Generates 32 cryptographically random bytes and encodes them as a 64-character hexadecimal string. Never share or commit this value. Add the output as a **Secret** named `RESTART_SECRET`. You will also need to add the same value to the production `.env` file — see [Part 3](#part-3-add-restart_secret-to-the-production-env). ### 2.2 `DEPLOY_DIR` (Secret) The absolute path to the production code directory on the server — the directory where PM2 currently runs the app from and where `git pull` / `npm ci` / `npm run build` should execute. Example value: `/Users/naviary/infinitechess.org` Add this as a **Secret** (under the "Secrets" tab) named `DEPLOY_DIR`. Storing it as a secret keeps the server's filesystem layout out of public workflow logs. ### 2.3 `HTTPSPORT` (Variable — not a secret) The HTTPS port the production server listens on. The deploy workflow uses when connecting to the server — it hits `https://localhost:$HTTPSPORT/[endpoint]`. This must match the `HTTPSPORT` value in the production `.env` file. Add this as an Actions **Variable** named `HTTPSPORT`. --- ## Part 3: Add `RESTART_SECRET` to the Production `.env` Open the production `.env` file (in the root of the `DEPLOY_DIR` directory) and add: ``` RESTART_SECRET= ``` Then reload the server and force PM2 to flush its environment cache, ensuring your app's `dotenv` package picks up the changes: ```bash pm2 reload infinitechess --update-env pm2 save ``` - `pm2 reload infinitechess --update-env`: Performs a graceful reload — starts a new process with the freshly updated system environment, then shuts down the old one. - `pm2 save`: Freezes the current PM2 state so that if the Mac server ever reboots, the app starts back up with the correct environment variables. --- ## Part 4: Add the `workflow_dispatch` Trigger to the HydroChess Workflow The HydroChess `build-wasm.yml` workflow must trigger the `deploy.yml` workflow on `infinitechess.org` **after** the engine release is fully published. Placing this as the very last step guarantees the new WASM binaries are available before the infinitechess.org build runs. ### 4.1 Create a fine-grained Personal Access Token (PAT) The dispatch API call needs a token with permission to trigger Actions workflows on `infinitechess.org`. 1. Go to **GitHub.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens**. 2. Click **Generate new token**. 3. Fill in: - **Token name**: `hydrochess-dispatch` - **Expiration**: Set to **1 year** and add a calendar reminder to rotate it before it expires. If the token expires, the dispatch step in HydroChess will silently fail with no other visible error. - **Resource owner**: `Infinite-Chess` - **Repository access**: Only selected repositories → `infinitechess.org` - **Repository permissions**: Set **Actions** to **Read and write** (this automatically selects Metadata: Read). Do **not** grant Contents access — it is not needed and would allow pushing code to the repository. 4. Click **Generate token** and **copy the value immediately** — it is shown only once. ### 4.2 Add the PAT as a secret in the HydroChess repository 1. Go to `https://github.com/Infinite-Chess/HydroChess` → **Settings → Secrets and variables → Actions**. 2. Click **New repository secret**. 3. **Name**: `INFINITECHESS_DISPATCH_TOKEN` 4. **Value**: the PAT generated in step 4.1. ### 4.3 Add the dispatch step to `build-wasm.yml` Open `.github/workflows/build-wasm.yml` in the HydroChess repository. Append the following as the **last step** of the `build-and-release` job, after the "Create Release" step: ```yaml - name: Trigger infinitechess.org deploy run: | curl -s -X POST \ -H "Authorization: Bearer ${{ secrets.INFINITECHESS_DISPATCH_TOKEN }}" \ -H "Accept: application/vnd.github.v3+json" \ https://api.github.com/repos/Infinite-Chess/infinitechess.org/actions/workflows/deploy.yml/dispatches \ -d '{"ref":"prod"}' ``` **What this does**: Sends an authenticated `POST` to GitHub's API directly triggering the `deploy.yml` workflow on `infinitechess.org`. This uses the `workflow_dispatch` endpoint, which only requires `Actions: Read and write` — unlike the `repository_dispatch` endpoint which requires `Contents: Read and write` (a far broader permission that allows pushing code). The self-hosted runner skips `git pull`/`npm ci` (since no new commits or dependencies changed on this repo), re-runs the build (which fetches the freshly published WASM files), and reloads PM2. --- ## Part 5: Verify Each Trigger ### 5.1 Trigger 1 — push to `prod` Merge any real (non-markdown) change from `main` into `prod`. Watch the **Actions** tab on the `infinitechess.org` repository — the "Deploy" workflow should appear, run on the self-hosted runner, and complete successfully. ### 5.2 Trigger 2 — `workflow_dispatch` 1. Go to **Actions → Deploy** on the `infinitechess.org` repository. 2. Click **Run workflow → Run workflow**. 3. Confirm the workflow runs on `self-hosted` and succeeds. ### 5.3 Trigger 3 — HydroChess `workflow_dispatch` Push a commit to the `main` branch of HydroChess (or manually trigger the `build-wasm.yml` workflow via `workflow_dispatch`). After the HydroChess workflow finishes and the release is published, the "Deploy" workflow on `infinitechess.org` should appear in the Actions tab and run. ### 5.4 Verify near-zero downtime While a deploy is in progress, open the play page in a browser with the console visible. You should observe the WebSocket disconnect and reconnect within approximately 2.5 seconds, with the game resuming normally. --- ================================================ FILE: dev-utils/REDESIGN/stack.md ================================================ # Website Redesign Plan The website will undergo a complete redesign to modernize its look and feel, making it much more professional and expandable. Every single page is going to be overhauled- their look, and their underlying code. ## Deployment Environment Self-hosted on a Mac, no VPS. SSD storage. Cloudflare in front. Low traffic — a few hundred unique visitors per day. ## Technical Stack & Decisions ### Build Pipeline - **esbuild:** Extend the existing pipeline in `build/`. Two additions to `build/client.ts`: (1) `entryNames: '[dir]/[name]-[hash]'` for content-hashed output filenames; (2) `metafile: true` plus a `writeManifest()` post-build function that reads esbuild's input→output map and writes `dist/manifest.json`. The server loads this manifest at startup and injects the correct hashed filenames into every Nunjucks render. - **Content-hashed asset caching:** JS and CSS files are emitted as `main.[hash].js` / `styles.[hash].css` and served with `Cache-Control: immutable, max-age=31536000` — browsers cache them forever and fetch a new URL automatically when content (and thus the file fingerprint) changes. HTML is served with `Cache-Control: no-store` and always embeds the current hashed filesnames. For images and other static assets referenced directly in templates (e.g. ``), use `Cache-Control: max-age=31536000` (without `immutable`) and append a `?v=2` query string manually in the template when the file changes. Reserve `immutable` only for build-pipeline-hashed files. - **Nunjucks** replaces EJS as the server-side templating engine. Layout inheritance is the key benefit: one `layout.njk` defines the full ``/``/`` shell with named `{% block %}` slots, and every page file just `{% extends "layout.njk" %}` and fills in its title, styles, and body content. Changing a favicon or global meta tag means editing one file. Logic stays in route handlers; templates only use `{% for %}` / `{% if %}`. `build/views.ts` is deleted entirely — it only existed to pre-render every EJS template × every language to static `.html` files because the old server had no SSR capability. With Nunjucks, HTML is rendered at request time, `dist/client/views/` no longer exists, `root.ts` switches from `res.sendFile()` to `res.render()`, and `nodemon.json` no longer needs to watch `src/client/views`. ### Page Architecture - **Proper MPA.** Each major feature lives on its own page — no cramming everything into one giant page. Pages are bandwidth-aware: each page only loads the JS it needs. This matters slightly less now that scripts are indefinitely cached after the first load, but it still keeps things clean and fast on first visit. - **SSR (server-side render) everything that affects the first paint.** The server renders the full HTML — header auth state, notification badge count, member profile data, news "NEW" badges — before sending the response. The client never needs to fetch these or patch the DOM on load. Use client-side fetching only for things triggered by user interaction or that need live updates (e.g. leaderboard "Show More", editor saves, preferences writes). - **Snabbdom for data-driven in-page reactivity.** Use it when DOM content is generated from data at runtime — leaderboard lists, chat windows, live game panels. Don't use it for static content known at author time (e.g. the fairy piece carousel in the Guide), or for pre-authored fixed elements like modals and tab panels that are simply shown/hidden. Each Snabbdom component needs a plain module-level `state` object and a `render(state)` function that should return a virtual DOM tree via `h()`. State is a plain JS object updated directly on socket events or user interactions. ### CSS & Styling - **CSS methodology:** One shared stylesheet for global styles, plus a per-page stylesheet for each page. Short, descriptive class names scoped with native CSS nesting — no BEM, no prefixes. Each page's stylesheet has one top-level block matching its `
` class (e.g. `.login { .form-field {} }`), preventing any bleed between pages. lightningcss in the existing build pipeline handles transpilation for older browsers. Utility classes (`.hidden`, `.italic`, `.flex`, etc.) are hand-rolled and added to the shared stylesheet when redundancy appears — no Tailwind. CSS files are colocated with the component they style (e.g. `src/client/components/header/header.css`). - **CSS custom property light/dark theme system.** A `[data-theme]` attribute on `` (e.g. `data-theme="dark"`) selects a block of several semantic CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-brand`, `--c-border`, etc.) defined in the shared stylesheet. Switching themes is one `setAttribute` call plus a `localStorage` write. A small inline `

Skeleton

================================================ FILE: dev-utils/live-game-persistence.md ================================================ # Live Game Persistence Active games are persisted to the database so they survive server restarts instead of being aborted. This document describes the two-table schema, what each column stores, and the event matrix that drives every DB write. --- ## Database Schema: Two Tables Following the pattern of `games` + `player_games` for ended games, live state is split across two tables to support an arbitrary number of players per game: - **`live_games`** — One row per active game. Contains game-level state. - **`live_player_games`** — One row per player per active game. Contains per-player state. ### Table 1: `live_games` #### Group 1: Game Identity | Column | Type | Notes | | -------------- | ------------------------------------------ | ---------------------------------------- | | `game_id` | INTEGER PRIMARY KEY | Unique across live and logged games | | `time_created` | INTEGER NOT NULL | Epoch milliseconds | | `variant` | TEXT NOT NULL | e.g. `"Classical"`, `"Omega^3"` | | `clock` | TEXT NOT NULL | e.g. `"600+5"` or `"-"` for untimed | | `rated` | BOOLEAN NOT NULL CHECK (rated IN (0, 1)) | 0 = casual, 1 = rated | | `private` | BOOLEAN NOT NULL CHECK (private IN (0, 1)) | 0 = public, 1 = private | #### Group 2: Move History | Column | Type | Notes | | ------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `moves` | TEXT NOT NULL DEFAULT `''` | Pipe-delimited compact moves with embedded clock comments via ICN format (e.g. `1,2>3,4{[%clk 0:09:56.7]}`). See below. | **Move format:** Produced by `getShortFormMovesFromMoves()` in `icnconverter.ts` with `{ compact: true, spaces: false, comments: true, move_numbers: false }`. Each move encodes `startCoords > endCoords`, optional promotion, and a clock comment. Parsed back via `parseShortFormMoves()`. The entire column is rewritten on each move submission. #### Group 3: Clock State | Column | Type | Notes | | --------------------- | ------- | ----------------------------------------------------------------------------------- | | `color_ticking` | INTEGER | Player number whose clock is running. NULL if untimed, < 2 moves, or game over. | | `clock_snapshot_time` | INTEGER | Epoch ms when clock values were snapshotted. Used to adjust the ticking player's time on restoration: `actual = stored_remaining - (Date.now() - clock_snapshot_time)`. | Per-player `time_remaining_ms` lives in `live_player_games`. #### Group 4: Draw Offer State | Column | Type | Notes | | ------------------ | ------- | ------------------------------------------------------------- | | `draw_offer_state` | INTEGER | Player number who extended the current offer. NULL if none. | Per-player `last_draw_offer_ply` lives in `live_player_games`. #### Group 5: Game Conclusion | Column | Type | Notes | | ---------------------- | ------- | -------------------------------------------------------------------------------------------------- | | `conclusion_condition` | TEXT | e.g. `"checkmate"`, `"time"`, `"resignation"`, `"aborted"`, `"agreement"`. NULL if ongoing. | | `conclusion_victor` | INTEGER | Winning player number. NULL for draw, ongoing, or aborted. | | `time_ended` | INTEGER | Epoch ms when game concluded. NULL if ongoing. | #### Group 6: Timer State | Column | Type | Notes | | ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `afk_resign_time` | INTEGER | Epoch ms when the AFK auto-resign fires. NULL if no AFK timer active. On restoration, remaining = `stored - Date.now()`; if ≤ 0, immediately resign. | | `delete_time` | INTEGER | Epoch ms when the concluded game is deleted and logged. NULL if ongoing. Set to `timeEnded + timeBeforeGameDeletionMillis`. On restoration, if elapsed, immediately run logging. | #### Group 7: Flags | Column | Type | Notes | | ----------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `position_pasted` | BOOLEAN NOT NULL DEFAULT 0 CHECK (position_pasted IN (0, 1)) | Whether a custom position was pasted. Pasted games are never logged to the permanent `games` table. | | `validate_moves` | BOOLEAN NOT NULL DEFAULT 1 CHECK (validate_moves IN (0, 1)) | Whether server-side move validation is active (`boardsim` is defined). Set to 0 when a position is pasted. | --- ### Table 2: `live_player_games` One row per player per live game. | Column | Type | Notes | | ----------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `game_id` | INTEGER NOT NULL | FK → `live_games.game_id` ON DELETE CASCADE | | `player_number` | INTEGER NOT NULL | 1 = White, 2 = Black, etc. Supports future multi-player games. | | `user_id` | INTEGER | NULL if guest. | | `browser_id` | TEXT NOT NULL | Always present (guests are identified by `browser_id` alone). | | `elo` | TEXT | Snapshot at game start (e.g. `"1500"` or `"1200?"`). NULL if guest. | | `last_draw_offer_ply` | INTEGER | Ply (0-based) of the player's last draw offer. NULL if never offered. | | `time_remaining_ms` | INTEGER | Milliseconds remaining at time of snapshot. NULL if untimed. | | `disconnect_cushion_end_time` | INTEGER | Epoch ms when the 5-second reconnection cushion expires. NULL if no cushion is active. | | `disconnect_resign_time` | INTEGER | Epoch ms when the auto-resign fires. NULL if no active disconnect timer. | | `disconnect_by_choice` | BOOLEAN | 1 = intentional disconnect (20s timer), 0 = network drop (60s timer). NULL if player was connected. CHECK (disconnect_by_choice IN (0, 1)). | **Three-case disconnect restoration:** - `disconnect_resign_time` non-NULL → auto-resign timer was active; restore from that timestamp. - `disconnect_cushion_end_time` non-NULL, `disconnect_resign_time` NULL → still in the 5-second cushion; revive it (or start the auto-resign timer if elapsed). - All disconnect columns NULL → player was connected before the restart; start a fresh 60-second timer (server restart counts as not-by-choice). --- ## Event Matrix: When Each Column Is Written | Event | `live_games` Columns Updated | `live_player_games` Columns Updated | | --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | **Game created** | INSERT full row (all Group 1 columns, defaults for the rest) | INSERT one row per player (identity, elo, defaults) | | **Move submitted** | `moves`, `color_ticking`, `clock_snapshot_time`, `validate_moves` | `time_remaining_ms` (both players) | | **Draw offer extended** | `draw_offer_state` | `last_draw_offer_ply` (offering player) | | **Draw offer declined** | `draw_offer_state` → NULL | — | | **Draw accepted** | `conclusion_condition`, `conclusion_victor`, `time_ended`, `draw_offer_state`, `delete_time` | — | | **Resignation** | `conclusion_condition`, `conclusion_victor`, `time_ended`, `delete_time` | — | | **Abort** | `conclusion_condition`, `time_ended`, `delete_time` | — | | **Time loss** | `conclusion_condition`, `conclusion_victor`, `time_ended`, `color_ticking`, `clock_snapshot_time`, `delete_time` | `time_remaining_ms` | | **Disconnect loss** | `conclusion_condition`, `conclusion_victor`, `time_ended`, `delete_time` | — | | **Player disconnects** | — | `disconnect_cushion_end_time`, `disconnect_resign_time`, `disconnect_by_choice` | | **Player reconnects** | — | `disconnect_cushion_end_time` → NULL, `disconnect_resign_time` → NULL, `disconnect_by_choice` → NULL | | **Player goes AFK** | `afk_resign_time` | — | | **Player returns from AFK** | `afk_resign_time` → NULL | — | | **AFK auto-resign** | `conclusion_condition`, `conclusion_victor`, `time_ended`, `afk_resign_time` → NULL, `delete_time` | — | | **Position pasted** | `position_pasted`, `validate_moves` → 0 | — | | **Game deleted/logged** | DELETE row (cascades to `live_player_games`) | — | ================================================ FILE: dev-utils/pieces/spritesheet 512/How to create spritesheet.md ================================================ # How to generate the game's spritesheet 1. Go to [Stitches](https://draeton.github.io/stitches/). 2. Make sure there are 64 images (not including this guide). If there are not, duplicate the empty placeholder until you do. 3. Drag all files inside [this folder](../spritesheet%20512/) and upload them, except this guide. 4. Make sure they are in the correct order. 5. Decrease the padding to 0px. ================================================ FILE: dev-utils/pieces/svg/Converting PNG to SVG.md ================================================ # Steps to converting a PNG to SVG # This is the best method I've found, to retain high quality, yet remain highly compact! 1. Go to [SVG Trace](https://svgtrace.com/png-to-svg) 2. Drag in your desired PNG, approximately 512x512. Larger will lead to a larger ending file size. 3. Do NOT change any of the settings 4. Convert & Export 5. Open [Compress or Die](https://compress-or-die.com/svg) 6. Upload your new SVG 7. Drag "Decimal precision" to exactly 1. Checkmark "Extreme compression (experimental). 8. Click "Generate Optimized Image". Download optimized image. 9. Open your SVG's code, find all `fill` attributes. Change the ones super close to white to `#ffffff`, and the ones super close to black to `#000000`. Remove unneeded external links. 10. See if it can further be compressed by running it through [SVG Minify](https://www.svgminify.com/). 11. Enjoy your optimized SVG. ================================================ FILE: dev-utils/post_processing_effects/posterize/PosterizePass.ts ================================================ // dev-utils/post_processing_effects/posterize/PosterizePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass that reduces the number of colors in the scene * to create a "posterized" effect. */ export class PosterizePass implements PostProcessPass { readonly program: ProgramMap['posterize']; // --- Public Properties for Control --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** * The number of distinct color levels per channel (red, green, blue). * A value of 4, for example, means each channel can only be one of 4 values. * Set 1 or less to effectively disable the effect. */ public levels: number = 8; constructor(programManager: ProgramManager) { this.program = programManager.get('posterize'); } /** * Renders the posterization effect. * @param gl - The WebGL2 rendering context. * @param inputTexture - The texture to process (usually the output of the previous pass). */ render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); // Bind the input texture to texture unit 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Set the uniforms for the shader gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1f(this.program.getUniformLocation('u_levels'), this.levels); } } ================================================ FILE: dev-utils/post_processing_effects/posterize/fragment.glsl ================================================ #version 300 es precision highp float; // The texture containing the scene to be posterized uniform sampler2D u_sceneTexture; uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform float u_levels; // The number of color levels per channel // The texture coordinates passed from the vertex shader in vec2 v_uv; // The final output color out vec4 out_color; void main() { // Sample the original color from the input texture vec4 originalColor = texture(u_sceneTexture, v_uv); // Calculate the fully posterized color vec3 posterizedColor; // If levels are 1.0 or less, the "posterized" color is just the original color. // This prevents division by zero and provides an easy way to toggle the effect. if (u_levels <= 1.0) { posterizedColor = originalColor.rgb; } else { // Apply the posterization formula float numLevels = u_levels - 1.0; posterizedColor = floor(originalColor.rgb * numLevels) / numLevels; } // Blend between the original and the posterized color using master strength. vec3 finalRgb = mix(originalColor.rgb, posterizedColor, u_masterStrength); // Output the final color, preserving the original alpha out_color = vec4(finalRgb, originalColor.a); } ================================================ FILE: dev-utils/post_processing_effects/radial_distortion/RadialDistortionPass.ts ================================================ import type { ProgramManager, ProgramMap } from "../../ProgramManager"; import type { PostProcessPass } from "../PostProcessingPipeline"; /** * A post-processing pass that applies radial distortion. * Used for both barrel and pincushion effects. */ export class RadialDistortionPass implements PostProcessPass { readonly program: ProgramMap['radial_distortion']; // --- Public Properties to Control the Effect --- /** * The strength of the distortion. * Positive values create a barrel (bulging) effect. * Negative values create a pincushion (pinching) effect. */ public strength: number = 0.0; /** The center of the distortion, in UV coordinates [0, 1]. */ public center: [number, number] = [0.5, 0.5]; constructor(programManager: ProgramManager) { this.program = programManager.get('radial_distortion'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength); gl.uniform2fv(this.program.getUniformLocation('u_center'), this.center); } } ================================================ FILE: dev-utils/post_processing_effects/radial_distortion/fragment.glsl ================================================ #version 300 es precision highp float; uniform sampler2D u_sceneTexture; // --- Distortion Controls --- uniform float u_strength; // Positive for barrel, negative for pincushion uniform vec2 u_center; // The center point of the distortion in vec2 v_uv; out vec4 out_color; void main() { // Vector from the current UV coordinate to the distortion center vec2 to_center = v_uv - u_center; // Calculate the distance squared from the center. // Using dot product is often faster than length() -> sqrt(). float dist_sq = dot(to_center, to_center); // Calculate the displacement factor. // This is the core of the effect. float displacement = 1.0 + u_strength * dist_sq; // Apply the displacement to the vector from the center vec2 displaced_uv = u_center + to_center * displacement; // Look up the color from the original texture at the new, distorted coordinate out_color = texture(u_sceneTexture, displaced_uv); } ================================================ FILE: dev-utils/post_processing_effects/rolling_hills/RollingHillsPass.ts ================================================ import type { ProgramManager, ProgramMap } from "../../ProgramManager"; import type { PostProcessPass } from "../PostProcessingPipeline"; /** * A post-processing pass that applies a single-axis sine wave distortion, * creating a "rolling hills" or flag-waving effect. */ export class RollingHillsPass implements PostProcessPass { readonly program: ProgramMap['rolling_hills']; // --- Public Properties to Control the Effect --- /** The strength of the wave (how far pixels are displaced). */ public amplitude: number = 0.1; /** The number of full waves across the screen. */ public frequency: number = 1.0; /** The angle of the wave crests in degrees. 0 creates vertical waves, 90 creates horizontal waves. */ public angle: number = 0.0; /** The current time, used to animate the waves. */ public time: number = 0.0; constructor(programManager: ProgramManager) { this.program = programManager.get('rolling_hills'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Convert user-friendly degrees to radians for the shader const angleInRadians = this.angle * (Math.PI / 180.0); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_amplitude'), this.amplitude); gl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency); gl.uniform1f(this.program.getUniformLocation('u_angle'), angleInRadians); gl.uniform1f(this.program.getUniformLocation('u_time'), this.time); } } ================================================ FILE: dev-utils/post_processing_effects/rolling_hills/fragment.glsl ================================================ #version 300 es precision highp float; uniform sampler2D u_sceneTexture; // --- Distortion Controls --- uniform float u_amplitude; // The strength of the wave uniform float u_frequency; // The number of waves across the screen uniform float u_angle; // The angle of the waves in radians uniform float u_time; // Animate the waves over time in vec2 v_uv; out vec4 out_color; const float PI = 3.1415926535; void main() { // Center the coordinates before rotation vec2 centeredUV = v_uv - 0.5; // Define the direction the pixels will be displaced. // This is perpendicular to the wave crests. vec2 displaceDir = vec2(cos(u_angle), sin(u_angle)); // Define the axis along which the wave's crests lie. // This is perpendicular to the displacement direction. vec2 waveAxis = vec2(-displaceDir.y, displaceDir.x); // Calculate the input for the sine function. // We project the UV coordinate onto the wave's axis. This tells us "how far along" // the wave we are for any given pixel, creating straight, parallel wave crests. float waveInput = dot(centeredUV, waveAxis); // --- NEW: Get the SIGNED distance from the center. --- // This value will be negative on one side of the center and positive on the other. float signedDist = dot(centeredUV, displaceDir); // --- NEW: Create a linear multiplier from the signed distance. --- // The distance is roughly -0.5 to 0.5, so multiplying by 2.0 scales it to a nice -1.0 to 1.0 range. float amplitudeMultiplier = signedDist * 2.0; // // Calculate the amplitude multiplier based on distance from center. // // Get the distance from the center along the wave's travel direction. // float distFromCenter = abs(dot(centeredUV, displaceDir)); // // Create a smooth multiplier that goes from 0.0 (at center) to 1.0 (at screen edge, ~0.5 distance). // // smoothstep gives a nice ease-in/out effect. // float amplitudeMultiplier = smoothstep(0.0, 0.5, distFromCenter); // Calculate the offset amount using the sine function, and apply the multiplier to it. float offset = sin(waveInput * u_frequency * 2.0 * PI + u_time) * u_amplitude * amplitudeMultiplier; // Apply the offset to the UVs in the displacement direction. vec2 distortedUV = v_uv + displaceDir * offset; out_color = texture(u_sceneTexture, distortedUV); } ================================================ FILE: dev-utils/readme.md ================================================ # Dev Utils This directory contains both depricated scripts that we believe might be useful in the future, as well as assets useful for development but not production. No source code script imports and runs any code from this directory, it is completely isolated from the production codebase. For this reason, code in here does not have to follow linting or formatting rules. ================================================ FILE: dev-utils/scripts/PatreonAPI.ts ================================================ // dev-utils/scripts/PatreonAPI.ts /* * This module, in the future, will be where we connect to Patreon's API * to dynamically refresh the list of Patreon-specific patrons on the website. */ /** A list of patrons on Naviary's [patreon](https://www.patreon.com/Naviary) page. * This should be periodically refreshed. */ const patrons: string[] = []; /** An object, containing patron usernames for the key, and their preferred * name on the website's patron list for the value. */ const replacementNames: Record = {}; /** The interval, in milliseconds, to use Patreon's API to refresh the patron list. */ // const intervalToRefreshPatreonPatronsMillis = 1000 * 60 * 60; // 1 hour // /** // * Uses Patreon's API to fetch all patrons on Naviary's // * [patreon](https://www.patreon.com/Naviary) page, and updates our list! // * // * STILL TO BE WRITTEN // */ // function refreshPatreonPatronList() { // } /** * Returns a list of patrons on Naviary's [patreon](https://www.patreon.com/Naviary) page, * updated every {@link intervalToRefreshPatreonPatronsMillis}. */ export function getPatreonPatrons(): string[] { // Replace their true usernames with replacements const patronsWithReplacedNames = patrons.map((patron) => { return replacementNames[patron] || patron; }); return patronsWithReplacedNames; } ================================================ FILE: dev-utils/scripts/audio/processors/bitcrusher/BitcrusherNode.ts ================================================ // dev-utils/scripts/audio/processors/bitcrusher/BitcrusherNode.ts export class BitcrusherNode extends AudioWorkletNode { constructor(context: AudioContext) { super(context, 'bitcrusher-processor'); } /** * Factory method to asynchronously create and initialize a BitcrusherNode. * @param context The AudioContext to create the node in. * @param workletUrl The URL to the compiled bitcrusher-processor.js file. * @returns A promise that resolves with a fully initialized BitcrusherNode instance. */ public static async create(context: AudioContext): Promise { try { // Load the worklet processor from the specified URL await context.audioWorklet.addModule( 'scripts/esm/audio/processors/bitcrusher/BitcrusherProcessor.js', ); // Once loaded, create an instance of the node return new BitcrusherNode(context); } catch (e) { console.error('Failed to load bitcrusher audio worklet', e); throw e; } } /** * The number of bits to quantize the audio signal to. * Range: 1 to 16. Lower = more distortion. */ get bitDepth(): AudioParam | undefined { return this.parameters.get('bitDepth'); } /** * The factor by which to reduce the sample rate. * A value of 1 means no downsampling. * Range: 1 to 40. */ get downsampling(): AudioParam | undefined { return this.parameters.get('downsampling'); } } ================================================ FILE: dev-utils/scripts/audio/processors/bitcrusher/BitcrusherProcessor.ts ================================================ // dev-utils/scripts/audio/processors/bitcrusher/BitcrusherProcessor.ts import type { AudioParamDescriptor } from '../worklet-types'; /* * These need to be declared in every audio worklet processor file, * because apparently our typescript setup doesn't have the * AudioWorkletGlobalScope, and nothing I do will add it. */ declare abstract class AudioWorkletProcessor { static get parameterDescriptors(): AudioParamDescriptor[]; constructor(options?: any); abstract process( inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record, ): boolean; } declare function registerProcessor(name: string, processorCtor: typeof AudioWorkletProcessor): void; /** Parameters for the BitcrusherProcessor. */ interface BitcrusherParameters extends Record { bitDepth: Float32Array; downsampling: Float32Array; } /** An AudioWorkletProcessor that applies a bitcrusher and/or downsampling effect to audio. */ class BitcrusherProcessor extends AudioWorkletProcessor { static override get parameterDescriptors(): AudioParamDescriptor[] { return [ { name: 'bitDepth', defaultValue: 8, minValue: 1, maxValue: 16, automationRate: 'k-rate', }, { name: 'downsampling', defaultValue: 1, minValue: 1, maxValue: 40, automationRate: 'k-rate', }, ]; } private phase = 0; private lastSampleValue = 0; process( inputs: Float32Array[][], outputs: Float32Array[][], parameters: BitcrusherParameters, ): boolean { const input = inputs[0]; const output = outputs[0]; if (!input || !output) return true; // Nothing to process const bitDepth = parameters['bitDepth']; const downsampling = parameters['downsampling']; for (let channel = 0; channel < input.length; ++channel) { const inputChannel = input[channel]; const outputChannel = output[channel]; if (!inputChannel || !outputChannel) continue; for (let i = 0; i < inputChannel.length; ++i) { const bitDepthValue = bitDepth.length > 1 ? bitDepth[i]! : bitDepth[0]!; const downsamplingValue = downsampling.length > 1 ? downsampling[i]! : downsampling[0]!; // Downsampling if (this.phase % downsamplingValue < 1) this.lastSampleValue = inputChannel[i]!; // Bit-depth reduction const step = Math.pow(0.5, bitDepthValue); outputChannel[i] = step * Math.floor(this.lastSampleValue / step + 0.5); this.phase++; } } return true; } } registerProcessor('bitcrusher-processor', BitcrusherProcessor); ================================================ FILE: dev-utils/scripts/clientEventDispatcher.ts ================================================ /** * This event dispatcher will only dispatch events in the browser environment. * * NOTHING will happen if it is imported, or its methods are called, in the Node.js environment. */ /** Whether the current environment is a browser. */ const isBrowser = typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'; const target: Window = isBrowser ? window : null!; /** * Dispatches an event with the given name. If data is provided, a CustomEvent is dispatched * with the data in the detail property. Otherwise, a standard Event is dispatched. * @param eventName The name of the event to dispatch. * @param [data] Optional data to include in the event's detail property. */ function dispatch(eventName: string, data?: any): void { if (!isBrowser) return; if (data !== undefined) target.dispatchEvent(new CustomEvent(eventName, { detail: data })); else target.dispatchEvent(new Event(eventName)); } /** * Listens for an event with the given name. * @param eventName The name of the event to listen for. * @param callback The callback function to invoke when the event occurs. */ function listen(eventName: string, callback: (event: CustomEvent) => void): void { if (!isBrowser) return; target.addEventListener(eventName, callback as EventListener); } /** * Removes a previously added event listener. * @param eventName The name of the event. * @param callback The callback function to remove. */ function removeListener(eventName: string, callback: (event: CustomEvent) => void): void { if (!isBrowser) return; target.removeEventListener(eventName, callback as EventListener); } export default { dispatch, listen, removeListener }; ================================================ FILE: dev-utils/scripts/events.ts ================================================ // dev-utils/scripts/events.ts /** * A script that was intended for managing gamefile events for games * on both client and server ends. * * @author Idontuse */ // Disabling this cause will be using func types lots /* eslint-disable no-unused-vars */ import type gamefile from "../../src/client/scripts/esm/chess/logic/gamefile"; type ExtractArr = T extends (infer U)[] ? U : never interface Eventlist { [eventName: string]: ((...args: any[]) => boolean)[] } function runEvent>>(eventlist: E, event: N, ...args: A): boolean { const funcs = eventlist[event]; if (funcs === undefined) return false; for (const f of funcs) { // @ts-ignore ts thinks that the paramters of the function "could" not match the parameters of the function if (f(...args)) { return true; } } return false; } function addEventListener>(eventlist: E, event: N, listener: L): void { const listeners = eventlist[event]; if (listeners === undefined) { // @ts-ignore it should work but ts thinks there could be a specific subtype where this errors // IT WILL ONLY BE AN ARRAY OF FUNCTIONS NO SUBTYPES NEEDED eventlist[event] = [listener]; return; } listeners.push(listener); return; } function removeEventListener>(eventlist: E, event: N, listener: L): boolean { const listeners = eventlist[event]; if (listeners === undefined) { return false; } for (let i = 0; i < listeners.length; i++ ) { if (listeners[i] !== listener) continue; listeners.splice(i, 1); return true; } return false; } // import type { RegenerateHook } from "./organizedpieces"; // interface GameEvents extends Eventlist { // // Runs when organizedPieces regenerate, DO NOT INTERRUPT. // regenerateLists: RegenerateHook[] // } export type { Eventlist, // GameEvents }; export default { addEventListener, removeEventListener, runEvent, }; ================================================ FILE: dev-utils/scripts/gl-matrix.js ================================================ /*! @fileoverview gl-matrix - High performance matrix and vector operations @author Brandon Jones @author Colin MacKenzie IV @version 3.4.0 Copyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.glMatrix = {})); })(this, (function (exports) { 'use strict'; /** * Common utilities * @module glMatrix */ // Configuration Constants var EPSILON = 0.000001; var ARRAY_TYPE = typeof Float32Array !== "undefined" ? Float32Array : Array; var RANDOM = Math.random; var ANGLE_ORDER = "zyx"; /** * Sets the type of array used when creating new vectors and matrices * * @param {Float32ArrayConstructor | ArrayConstructor} type Array type, such as Float32Array or Array */ function setMatrixArrayType(type) { ARRAY_TYPE = type; } var degree = Math.PI / 180; /** * Convert Degree To Radian * * @param {Number} a Angle in Degrees */ function toRadian(a) { return a * degree; } /** * Tests whether the arguments have approximately the same value, within an absolute * or relative tolerance of glMatrix.EPSILON (an absolute tolerance is used for values less * than or equal to 1.0, and a relative tolerance is used for larger values) * * @param {Number} a The first number to test. * @param {Number} b The second number to test. * @returns {Boolean} True if the numbers are approximately equal, false otherwise. */ function equals$9(a, b) { return Math.abs(a - b) <= EPSILON * Math.max(1.0, Math.abs(a), Math.abs(b)); } if (!Math.hypot) Math.hypot = function () { var y = 0, i = arguments.length; while (i--) { y += arguments[i] * arguments[i]; } return Math.sqrt(y); }; var common = /*#__PURE__*/Object.freeze({ __proto__: null, EPSILON: EPSILON, get ARRAY_TYPE () { return ARRAY_TYPE; }, RANDOM: RANDOM, ANGLE_ORDER: ANGLE_ORDER, setMatrixArrayType: setMatrixArrayType, toRadian: toRadian, equals: equals$9 }); /** * 2x2 Matrix * @module mat2 */ /** * Creates a new identity mat2 * * @returns {mat2} a new 2x2 matrix */ function create$8() { var out = new ARRAY_TYPE(4); if (ARRAY_TYPE != Float32Array) { out[1] = 0; out[2] = 0; } out[0] = 1; out[3] = 1; return out; } /** * Creates a new mat2 initialized with values from an existing matrix * * @param {ReadonlyMat2} a matrix to clone * @returns {mat2} a new 2x2 matrix */ function clone$8(a) { var out = new ARRAY_TYPE(4); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out; } /** * Copy the values from one mat2 to another * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the source matrix * @returns {mat2} out */ function copy$8(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out; } /** * Set a mat2 to the identity matrix * * @param {mat2} out the receiving matrix * @returns {mat2} out */ function identity$5(out) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 1; return out; } /** * Create a new mat2 with the given values * * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m10 Component in column 1, row 0 position (index 2) * @param {Number} m11 Component in column 1, row 1 position (index 3) * @returns {mat2} out A new 2x2 matrix */ function fromValues$8(m00, m01, m10, m11) { var out = new ARRAY_TYPE(4); out[0] = m00; out[1] = m01; out[2] = m10; out[3] = m11; return out; } /** * Set the components of a mat2 to the given values * * @param {mat2} out the receiving matrix * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m10 Component in column 1, row 0 position (index 2) * @param {Number} m11 Component in column 1, row 1 position (index 3) * @returns {mat2} out */ function set$8(out, m00, m01, m10, m11) { out[0] = m00; out[1] = m01; out[2] = m10; out[3] = m11; return out; } /** * Transpose the values of a mat2 * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the source matrix * @returns {mat2} out */ function transpose$2(out, a) { // If we are transposing ourselves we can skip a few steps but have to cache // some values if (out === a) { var a1 = a[1]; out[1] = a[2]; out[2] = a1; } else { out[0] = a[0]; out[1] = a[2]; out[2] = a[1]; out[3] = a[3]; } return out; } /** * Inverts a mat2 * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the source matrix * @returns {mat2} out */ function invert$5(out, a) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; // Calculate the determinant var det = a0 * a3 - a2 * a1; if (!det) { return null; } det = 1.0 / det; out[0] = a3 * det; out[1] = -a1 * det; out[2] = -a2 * det; out[3] = a0 * det; return out; } /** * Calculates the adjugate of a mat2 * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the source matrix * @returns {mat2} out */ function adjoint$2(out, a) { // Caching this value is necessary if out == a var a0 = a[0]; out[0] = a[3]; out[1] = -a[1]; out[2] = -a[2]; out[3] = a0; return out; } /** * Calculates the determinant of a mat2 * * @param {ReadonlyMat2} a the source matrix * @returns {Number} determinant of a */ function determinant$3(a) { return a[0] * a[3] - a[2] * a[1]; } /** * Multiplies two mat2's * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the first operand * @param {ReadonlyMat2} b the second operand * @returns {mat2} out */ function multiply$8(out, a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; out[0] = a0 * b0 + a2 * b1; out[1] = a1 * b0 + a3 * b1; out[2] = a0 * b2 + a2 * b3; out[3] = a1 * b2 + a3 * b3; return out; } /** * Rotates a mat2 by the given angle * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat2} out */ function rotate$4(out, a, rad) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var s = Math.sin(rad); var c = Math.cos(rad); out[0] = a0 * c + a2 * s; out[1] = a1 * c + a3 * s; out[2] = a0 * -s + a2 * c; out[3] = a1 * -s + a3 * c; return out; } /** * Scales the mat2 by the dimensions in the given vec2 * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the matrix to rotate * @param {ReadonlyVec2} v the vec2 to scale the matrix by * @returns {mat2} out **/ function scale$8(out, a, v) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var v0 = v[0], v1 = v[1]; out[0] = a0 * v0; out[1] = a1 * v0; out[2] = a2 * v1; out[3] = a3 * v1; return out; } /** * Creates a matrix from a given angle * This is equivalent to (but much faster than): * * mat2.identity(dest); * mat2.rotate(dest, dest, rad); * * @param {mat2} out mat2 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat2} out */ function fromRotation$4(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); out[0] = c; out[1] = s; out[2] = -s; out[3] = c; return out; } /** * Creates a matrix from a vector scaling * This is equivalent to (but much faster than): * * mat2.identity(dest); * mat2.scale(dest, dest, vec); * * @param {mat2} out mat2 receiving operation result * @param {ReadonlyVec2} v Scaling vector * @returns {mat2} out */ function fromScaling$3(out, v) { out[0] = v[0]; out[1] = 0; out[2] = 0; out[3] = v[1]; return out; } /** * Returns a string representation of a mat2 * * @param {ReadonlyMat2} a matrix to represent as a string * @returns {String} string representation of the matrix */ function str$8(a) { return "mat2(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ")"; } /** * Returns Frobenius norm of a mat2 * * @param {ReadonlyMat2} a the matrix to calculate Frobenius norm of * @returns {Number} Frobenius norm */ function frob$3(a) { return Math.hypot(a[0], a[1], a[2], a[3]); } /** * Returns L, D and U matrices (Lower triangular, Diagonal and Upper triangular) by factorizing the input matrix * @param {ReadonlyMat2} L the lower triangular matrix * @param {ReadonlyMat2} D the diagonal matrix * @param {ReadonlyMat2} U the upper triangular matrix * @param {ReadonlyMat2} a the input matrix to factorize */ function LDU(L, D, U, a) { L[2] = a[2] / a[0]; U[0] = a[0]; U[1] = a[1]; U[3] = a[3] - L[2] * U[1]; return [L, D, U]; } /** * Adds two mat2's * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the first operand * @param {ReadonlyMat2} b the second operand * @returns {mat2} out */ function add$8(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; return out; } /** * Subtracts matrix b from matrix a * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the first operand * @param {ReadonlyMat2} b the second operand * @returns {mat2} out */ function subtract$6(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; return out; } /** * Returns whether the matrices have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyMat2} a The first matrix. * @param {ReadonlyMat2} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function exactEquals$8(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } /** * Returns whether the matrices have approximately the same elements in the same position. * * @param {ReadonlyMat2} a The first matrix. * @param {ReadonlyMat2} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function equals$8(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)); } /** * Multiply each element of the matrix by a scalar. * * @param {mat2} out the receiving matrix * @param {ReadonlyMat2} a the matrix to scale * @param {Number} b amount to scale the matrix's elements by * @returns {mat2} out */ function multiplyScalar$3(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; return out; } /** * Adds two mat2's after multiplying each element of the second operand by a scalar value. * * @param {mat2} out the receiving vector * @param {ReadonlyMat2} a the first operand * @param {ReadonlyMat2} b the second operand * @param {Number} scale the amount to scale b's elements by before adding * @returns {mat2} out */ function multiplyScalarAndAdd$3(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; return out; } /** * Alias for {@link mat2.multiply} * @function */ var mul$8 = multiply$8; /** * Alias for {@link mat2.subtract} * @function */ var sub$6 = subtract$6; var mat2 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$8, clone: clone$8, copy: copy$8, identity: identity$5, fromValues: fromValues$8, set: set$8, transpose: transpose$2, invert: invert$5, adjoint: adjoint$2, determinant: determinant$3, multiply: multiply$8, rotate: rotate$4, scale: scale$8, fromRotation: fromRotation$4, fromScaling: fromScaling$3, str: str$8, frob: frob$3, LDU: LDU, add: add$8, subtract: subtract$6, exactEquals: exactEquals$8, equals: equals$8, multiplyScalar: multiplyScalar$3, multiplyScalarAndAdd: multiplyScalarAndAdd$3, mul: mul$8, sub: sub$6 }); /** * 2x3 Matrix * @module mat2d * @description * A mat2d contains six elements defined as: *
   * [a, b,
   *  c, d,
   *  tx, ty]
   * 
* This is a short form for the 3x3 matrix: *
   * [a, b, 0,
   *  c, d, 0,
   *  tx, ty, 1]
   * 
* The last column is ignored so the array is shorter and operations are faster. */ /** * Creates a new identity mat2d * * @returns {mat2d} a new 2x3 matrix */ function create$7() { var out = new ARRAY_TYPE(6); if (ARRAY_TYPE != Float32Array) { out[1] = 0; out[2] = 0; out[4] = 0; out[5] = 0; } out[0] = 1; out[3] = 1; return out; } /** * Creates a new mat2d initialized with values from an existing matrix * * @param {ReadonlyMat2d} a matrix to clone * @returns {mat2d} a new 2x3 matrix */ function clone$7(a) { var out = new ARRAY_TYPE(6); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; return out; } /** * Copy the values from one mat2d to another * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the source matrix * @returns {mat2d} out */ function copy$7(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; return out; } /** * Set a mat2d to the identity matrix * * @param {mat2d} out the receiving matrix * @returns {mat2d} out */ function identity$4(out) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 0; return out; } /** * Create a new mat2d with the given values * * @param {Number} a Component A (index 0) * @param {Number} b Component B (index 1) * @param {Number} c Component C (index 2) * @param {Number} d Component D (index 3) * @param {Number} tx Component TX (index 4) * @param {Number} ty Component TY (index 5) * @returns {mat2d} A new mat2d */ function fromValues$7(a, b, c, d, tx, ty) { var out = new ARRAY_TYPE(6); out[0] = a; out[1] = b; out[2] = c; out[3] = d; out[4] = tx; out[5] = ty; return out; } /** * Set the components of a mat2d to the given values * * @param {mat2d} out the receiving matrix * @param {Number} a Component A (index 0) * @param {Number} b Component B (index 1) * @param {Number} c Component C (index 2) * @param {Number} d Component D (index 3) * @param {Number} tx Component TX (index 4) * @param {Number} ty Component TY (index 5) * @returns {mat2d} out */ function set$7(out, a, b, c, d, tx, ty) { out[0] = a; out[1] = b; out[2] = c; out[3] = d; out[4] = tx; out[5] = ty; return out; } /** * Inverts a mat2d * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the source matrix * @returns {mat2d} out */ function invert$4(out, a) { var aa = a[0], ab = a[1], ac = a[2], ad = a[3]; var atx = a[4], aty = a[5]; var det = aa * ad - ab * ac; if (!det) { return null; } det = 1.0 / det; out[0] = ad * det; out[1] = -ab * det; out[2] = -ac * det; out[3] = aa * det; out[4] = (ac * aty - ad * atx) * det; out[5] = (ab * atx - aa * aty) * det; return out; } /** * Calculates the determinant of a mat2d * * @param {ReadonlyMat2d} a the source matrix * @returns {Number} determinant of a */ function determinant$2(a) { return a[0] * a[3] - a[1] * a[2]; } /** * Multiplies two mat2d's * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the first operand * @param {ReadonlyMat2d} b the second operand * @returns {mat2d} out */ function multiply$7(out, a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3], b4 = b[4], b5 = b[5]; out[0] = a0 * b0 + a2 * b1; out[1] = a1 * b0 + a3 * b1; out[2] = a0 * b2 + a2 * b3; out[3] = a1 * b2 + a3 * b3; out[4] = a0 * b4 + a2 * b5 + a4; out[5] = a1 * b4 + a3 * b5 + a5; return out; } /** * Rotates a mat2d by the given angle * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat2d} out */ function rotate$3(out, a, rad) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; var s = Math.sin(rad); var c = Math.cos(rad); out[0] = a0 * c + a2 * s; out[1] = a1 * c + a3 * s; out[2] = a0 * -s + a2 * c; out[3] = a1 * -s + a3 * c; out[4] = a4; out[5] = a5; return out; } /** * Scales the mat2d by the dimensions in the given vec2 * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the matrix to translate * @param {ReadonlyVec2} v the vec2 to scale the matrix by * @returns {mat2d} out **/ function scale$7(out, a, v) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; var v0 = v[0], v1 = v[1]; out[0] = a0 * v0; out[1] = a1 * v0; out[2] = a2 * v1; out[3] = a3 * v1; out[4] = a4; out[5] = a5; return out; } /** * Translates the mat2d by the dimensions in the given vec2 * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the matrix to translate * @param {ReadonlyVec2} v the vec2 to translate the matrix by * @returns {mat2d} out **/ function translate$3(out, a, v) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; var v0 = v[0], v1 = v[1]; out[0] = a0; out[1] = a1; out[2] = a2; out[3] = a3; out[4] = a0 * v0 + a2 * v1 + a4; out[5] = a1 * v0 + a3 * v1 + a5; return out; } /** * Creates a matrix from a given angle * This is equivalent to (but much faster than): * * mat2d.identity(dest); * mat2d.rotate(dest, dest, rad); * * @param {mat2d} out mat2d receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat2d} out */ function fromRotation$3(out, rad) { var s = Math.sin(rad), c = Math.cos(rad); out[0] = c; out[1] = s; out[2] = -s; out[3] = c; out[4] = 0; out[5] = 0; return out; } /** * Creates a matrix from a vector scaling * This is equivalent to (but much faster than): * * mat2d.identity(dest); * mat2d.scale(dest, dest, vec); * * @param {mat2d} out mat2d receiving operation result * @param {ReadonlyVec2} v Scaling vector * @returns {mat2d} out */ function fromScaling$2(out, v) { out[0] = v[0]; out[1] = 0; out[2] = 0; out[3] = v[1]; out[4] = 0; out[5] = 0; return out; } /** * Creates a matrix from a vector translation * This is equivalent to (but much faster than): * * mat2d.identity(dest); * mat2d.translate(dest, dest, vec); * * @param {mat2d} out mat2d receiving operation result * @param {ReadonlyVec2} v Translation vector * @returns {mat2d} out */ function fromTranslation$3(out, v) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 1; out[4] = v[0]; out[5] = v[1]; return out; } /** * Returns a string representation of a mat2d * * @param {ReadonlyMat2d} a matrix to represent as a string * @returns {String} string representation of the matrix */ function str$7(a) { return "mat2d(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ", " + a[4] + ", " + a[5] + ")"; } /** * Returns Frobenius norm of a mat2d * * @param {ReadonlyMat2d} a the matrix to calculate Frobenius norm of * @returns {Number} Frobenius norm */ function frob$2(a) { return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], 1); } /** * Adds two mat2d's * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the first operand * @param {ReadonlyMat2d} b the second operand * @returns {mat2d} out */ function add$7(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; out[4] = a[4] + b[4]; out[5] = a[5] + b[5]; return out; } /** * Subtracts matrix b from matrix a * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the first operand * @param {ReadonlyMat2d} b the second operand * @returns {mat2d} out */ function subtract$5(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; out[4] = a[4] - b[4]; out[5] = a[5] - b[5]; return out; } /** * Multiply each element of the matrix by a scalar. * * @param {mat2d} out the receiving matrix * @param {ReadonlyMat2d} a the matrix to scale * @param {Number} b amount to scale the matrix's elements by * @returns {mat2d} out */ function multiplyScalar$2(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; out[4] = a[4] * b; out[5] = a[5] * b; return out; } /** * Adds two mat2d's after multiplying each element of the second operand by a scalar value. * * @param {mat2d} out the receiving vector * @param {ReadonlyMat2d} a the first operand * @param {ReadonlyMat2d} b the second operand * @param {Number} scale the amount to scale b's elements by before adding * @returns {mat2d} out */ function multiplyScalarAndAdd$2(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; out[4] = a[4] + b[4] * scale; out[5] = a[5] + b[5] * scale; return out; } /** * Returns whether the matrices have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyMat2d} a The first matrix. * @param {ReadonlyMat2d} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function exactEquals$7(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5]; } /** * Returns whether the matrices have approximately the same elements in the same position. * * @param {ReadonlyMat2d} a The first matrix. * @param {ReadonlyMat2d} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function equals$7(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3], b4 = b[4], b5 = b[5]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)); } /** * Alias for {@link mat2d.multiply} * @function */ var mul$7 = multiply$7; /** * Alias for {@link mat2d.subtract} * @function */ var sub$5 = subtract$5; var mat2d = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$7, clone: clone$7, copy: copy$7, identity: identity$4, fromValues: fromValues$7, set: set$7, invert: invert$4, determinant: determinant$2, multiply: multiply$7, rotate: rotate$3, scale: scale$7, translate: translate$3, fromRotation: fromRotation$3, fromScaling: fromScaling$2, fromTranslation: fromTranslation$3, str: str$7, frob: frob$2, add: add$7, subtract: subtract$5, multiplyScalar: multiplyScalar$2, multiplyScalarAndAdd: multiplyScalarAndAdd$2, exactEquals: exactEquals$7, equals: equals$7, mul: mul$7, sub: sub$5 }); /** * 3x3 Matrix * @module mat3 */ /** * Creates a new identity mat3 * * @returns {mat3} a new 3x3 matrix */ function create$6() { var out = new ARRAY_TYPE(9); if (ARRAY_TYPE != Float32Array) { out[1] = 0; out[2] = 0; out[3] = 0; out[5] = 0; out[6] = 0; out[7] = 0; } out[0] = 1; out[4] = 1; out[8] = 1; return out; } /** * Copies the upper-left 3x3 values into the given mat3. * * @param {mat3} out the receiving 3x3 matrix * @param {ReadonlyMat4} a the source 4x4 matrix * @returns {mat3} out */ function fromMat4$1(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[4]; out[4] = a[5]; out[5] = a[6]; out[6] = a[8]; out[7] = a[9]; out[8] = a[10]; return out; } /** * Creates a new mat3 initialized with values from an existing matrix * * @param {ReadonlyMat3} a matrix to clone * @returns {mat3} a new 3x3 matrix */ function clone$6(a) { var out = new ARRAY_TYPE(9); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; return out; } /** * Copy the values from one mat3 to another * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the source matrix * @returns {mat3} out */ function copy$6(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; return out; } /** * Create a new mat3 with the given values * * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m10 Component in column 1, row 0 position (index 3) * @param {Number} m11 Component in column 1, row 1 position (index 4) * @param {Number} m12 Component in column 1, row 2 position (index 5) * @param {Number} m20 Component in column 2, row 0 position (index 6) * @param {Number} m21 Component in column 2, row 1 position (index 7) * @param {Number} m22 Component in column 2, row 2 position (index 8) * @returns {mat3} A new mat3 */ function fromValues$6(m00, m01, m02, m10, m11, m12, m20, m21, m22) { var out = new ARRAY_TYPE(9); out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m10; out[4] = m11; out[5] = m12; out[6] = m20; out[7] = m21; out[8] = m22; return out; } /** * Set the components of a mat3 to the given values * * @param {mat3} out the receiving matrix * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m10 Component in column 1, row 0 position (index 3) * @param {Number} m11 Component in column 1, row 1 position (index 4) * @param {Number} m12 Component in column 1, row 2 position (index 5) * @param {Number} m20 Component in column 2, row 0 position (index 6) * @param {Number} m21 Component in column 2, row 1 position (index 7) * @param {Number} m22 Component in column 2, row 2 position (index 8) * @returns {mat3} out */ function set$6(out, m00, m01, m02, m10, m11, m12, m20, m21, m22) { out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m10; out[4] = m11; out[5] = m12; out[6] = m20; out[7] = m21; out[8] = m22; return out; } /** * Set a mat3 to the identity matrix * * @param {mat3} out the receiving matrix * @returns {mat3} out */ function identity$3(out) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 1; out[5] = 0; out[6] = 0; out[7] = 0; out[8] = 1; return out; } /** * Transpose the values of a mat3 * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the source matrix * @returns {mat3} out */ function transpose$1(out, a) { // If we are transposing ourselves we can skip a few steps but have to cache some values if (out === a) { var a01 = a[1], a02 = a[2], a12 = a[5]; out[1] = a[3]; out[2] = a[6]; out[3] = a01; out[5] = a[7]; out[6] = a02; out[7] = a12; } else { out[0] = a[0]; out[1] = a[3]; out[2] = a[6]; out[3] = a[1]; out[4] = a[4]; out[5] = a[7]; out[6] = a[2]; out[7] = a[5]; out[8] = a[8]; } return out; } /** * Inverts a mat3 * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the source matrix * @returns {mat3} out */ function invert$3(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2]; var a10 = a[3], a11 = a[4], a12 = a[5]; var a20 = a[6], a21 = a[7], a22 = a[8]; var b01 = a22 * a11 - a12 * a21; var b11 = -a22 * a10 + a12 * a20; var b21 = a21 * a10 - a11 * a20; // Calculate the determinant var det = a00 * b01 + a01 * b11 + a02 * b21; if (!det) { return null; } det = 1.0 / det; out[0] = b01 * det; out[1] = (-a22 * a01 + a02 * a21) * det; out[2] = (a12 * a01 - a02 * a11) * det; out[3] = b11 * det; out[4] = (a22 * a00 - a02 * a20) * det; out[5] = (-a12 * a00 + a02 * a10) * det; out[6] = b21 * det; out[7] = (-a21 * a00 + a01 * a20) * det; out[8] = (a11 * a00 - a01 * a10) * det; return out; } /** * Calculates the adjugate of a mat3 * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the source matrix * @returns {mat3} out */ function adjoint$1(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2]; var a10 = a[3], a11 = a[4], a12 = a[5]; var a20 = a[6], a21 = a[7], a22 = a[8]; out[0] = a11 * a22 - a12 * a21; out[1] = a02 * a21 - a01 * a22; out[2] = a01 * a12 - a02 * a11; out[3] = a12 * a20 - a10 * a22; out[4] = a00 * a22 - a02 * a20; out[5] = a02 * a10 - a00 * a12; out[6] = a10 * a21 - a11 * a20; out[7] = a01 * a20 - a00 * a21; out[8] = a00 * a11 - a01 * a10; return out; } /** * Calculates the determinant of a mat3 * * @param {ReadonlyMat3} a the source matrix * @returns {Number} determinant of a */ function determinant$1(a) { var a00 = a[0], a01 = a[1], a02 = a[2]; var a10 = a[3], a11 = a[4], a12 = a[5]; var a20 = a[6], a21 = a[7], a22 = a[8]; return a00 * (a22 * a11 - a12 * a21) + a01 * (-a22 * a10 + a12 * a20) + a02 * (a21 * a10 - a11 * a20); } /** * Multiplies two mat3's * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the first operand * @param {ReadonlyMat3} b the second operand * @returns {mat3} out */ function multiply$6(out, a, b) { var a00 = a[0], a01 = a[1], a02 = a[2]; var a10 = a[3], a11 = a[4], a12 = a[5]; var a20 = a[6], a21 = a[7], a22 = a[8]; var b00 = b[0], b01 = b[1], b02 = b[2]; var b10 = b[3], b11 = b[4], b12 = b[5]; var b20 = b[6], b21 = b[7], b22 = b[8]; out[0] = b00 * a00 + b01 * a10 + b02 * a20; out[1] = b00 * a01 + b01 * a11 + b02 * a21; out[2] = b00 * a02 + b01 * a12 + b02 * a22; out[3] = b10 * a00 + b11 * a10 + b12 * a20; out[4] = b10 * a01 + b11 * a11 + b12 * a21; out[5] = b10 * a02 + b11 * a12 + b12 * a22; out[6] = b20 * a00 + b21 * a10 + b22 * a20; out[7] = b20 * a01 + b21 * a11 + b22 * a21; out[8] = b20 * a02 + b21 * a12 + b22 * a22; return out; } /** * Translate a mat3 by the given vector * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the matrix to translate * @param {ReadonlyVec2} v vector to translate by * @returns {mat3} out */ function translate$2(out, a, v) { var a00 = a[0], a01 = a[1], a02 = a[2], a10 = a[3], a11 = a[4], a12 = a[5], a20 = a[6], a21 = a[7], a22 = a[8], x = v[0], y = v[1]; out[0] = a00; out[1] = a01; out[2] = a02; out[3] = a10; out[4] = a11; out[5] = a12; out[6] = x * a00 + y * a10 + a20; out[7] = x * a01 + y * a11 + a21; out[8] = x * a02 + y * a12 + a22; return out; } /** * Rotates a mat3 by the given angle * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat3} out */ function rotate$2(out, a, rad) { var a00 = a[0], a01 = a[1], a02 = a[2], a10 = a[3], a11 = a[4], a12 = a[5], a20 = a[6], a21 = a[7], a22 = a[8], s = Math.sin(rad), c = Math.cos(rad); out[0] = c * a00 + s * a10; out[1] = c * a01 + s * a11; out[2] = c * a02 + s * a12; out[3] = c * a10 - s * a00; out[4] = c * a11 - s * a01; out[5] = c * a12 - s * a02; out[6] = a20; out[7] = a21; out[8] = a22; return out; } /** * Scales the mat3 by the dimensions in the given vec2 * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the matrix to rotate * @param {ReadonlyVec2} v the vec2 to scale the matrix by * @returns {mat3} out **/ function scale$6(out, a, v) { var x = v[0], y = v[1]; out[0] = x * a[0]; out[1] = x * a[1]; out[2] = x * a[2]; out[3] = y * a[3]; out[4] = y * a[4]; out[5] = y * a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; return out; } /** * Creates a matrix from a vector translation * This is equivalent to (but much faster than): * * mat3.identity(dest); * mat3.translate(dest, dest, vec); * * @param {mat3} out mat3 receiving operation result * @param {ReadonlyVec2} v Translation vector * @returns {mat3} out */ function fromTranslation$2(out, v) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 1; out[5] = 0; out[6] = v[0]; out[7] = v[1]; out[8] = 1; return out; } /** * Creates a matrix from a given angle * This is equivalent to (but much faster than): * * mat3.identity(dest); * mat3.rotate(dest, dest, rad); * * @param {mat3} out mat3 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat3} out */ function fromRotation$2(out, rad) { var s = Math.sin(rad), c = Math.cos(rad); out[0] = c; out[1] = s; out[2] = 0; out[3] = -s; out[4] = c; out[5] = 0; out[6] = 0; out[7] = 0; out[8] = 1; return out; } /** * Creates a matrix from a vector scaling * This is equivalent to (but much faster than): * * mat3.identity(dest); * mat3.scale(dest, dest, vec); * * @param {mat3} out mat3 receiving operation result * @param {ReadonlyVec2} v Scaling vector * @returns {mat3} out */ function fromScaling$1(out, v) { out[0] = v[0]; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = v[1]; out[5] = 0; out[6] = 0; out[7] = 0; out[8] = 1; return out; } /** * Copies the values from a mat2d into a mat3 * * @param {mat3} out the receiving matrix * @param {ReadonlyMat2d} a the matrix to copy * @returns {mat3} out **/ function fromMat2d(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = 0; out[3] = a[2]; out[4] = a[3]; out[5] = 0; out[6] = a[4]; out[7] = a[5]; out[8] = 1; return out; } /** * Calculates a 3x3 matrix from the given quaternion * * @param {mat3} out mat3 receiving operation result * @param {ReadonlyQuat} q Quaternion to create matrix from * * @returns {mat3} out */ function fromQuat$1(out, q) { var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var yx = y * x2; var yy = y * y2; var zx = z * x2; var zy = z * y2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; out[0] = 1 - yy - zz; out[3] = yx - wz; out[6] = zx + wy; out[1] = yx + wz; out[4] = 1 - xx - zz; out[7] = zy - wx; out[2] = zx - wy; out[5] = zy + wx; out[8] = 1 - xx - yy; return out; } /** * Calculates a 3x3 normal matrix (transpose inverse) from the 4x4 matrix * * @param {mat3} out mat3 receiving operation result * @param {ReadonlyMat4} a Mat4 to derive the normal matrix from * * @returns {mat3} out */ function normalFromMat4(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b00 = a00 * a11 - a01 * a10; var b01 = a00 * a12 - a02 * a10; var b02 = a00 * a13 - a03 * a10; var b03 = a01 * a12 - a02 * a11; var b04 = a01 * a13 - a03 * a11; var b05 = a02 * a13 - a03 * a12; var b06 = a20 * a31 - a21 * a30; var b07 = a20 * a32 - a22 * a30; var b08 = a20 * a33 - a23 * a30; var b09 = a21 * a32 - a22 * a31; var b10 = a21 * a33 - a23 * a31; var b11 = a22 * a33 - a23 * a32; // Calculate the determinant var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; if (!det) { return null; } det = 1.0 / det; out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; out[1] = (a12 * b08 - a10 * b11 - a13 * b07) * det; out[2] = (a10 * b10 - a11 * b08 + a13 * b06) * det; out[3] = (a02 * b10 - a01 * b11 - a03 * b09) * det; out[4] = (a00 * b11 - a02 * b08 + a03 * b07) * det; out[5] = (a01 * b08 - a00 * b10 - a03 * b06) * det; out[6] = (a31 * b05 - a32 * b04 + a33 * b03) * det; out[7] = (a32 * b02 - a30 * b05 - a33 * b01) * det; out[8] = (a30 * b04 - a31 * b02 + a33 * b00) * det; return out; } /** * Generates a 2D projection matrix with the given bounds * * @param {mat3} out mat3 frustum matrix will be written into * @param {number} width Width of your gl context * @param {number} height Height of gl context * @returns {mat3} out */ function projection(out, width, height) { out[0] = 2 / width; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = -2 / height; out[5] = 0; out[6] = -1; out[7] = 1; out[8] = 1; return out; } /** * Returns a string representation of a mat3 * * @param {ReadonlyMat3} a matrix to represent as a string * @returns {String} string representation of the matrix */ function str$6(a) { return "mat3(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ", " + a[4] + ", " + a[5] + ", " + a[6] + ", " + a[7] + ", " + a[8] + ")"; } /** * Returns Frobenius norm of a mat3 * * @param {ReadonlyMat3} a the matrix to calculate Frobenius norm of * @returns {Number} Frobenius norm */ function frob$1(a) { return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]); } /** * Adds two mat3's * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the first operand * @param {ReadonlyMat3} b the second operand * @returns {mat3} out */ function add$6(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; out[4] = a[4] + b[4]; out[5] = a[5] + b[5]; out[6] = a[6] + b[6]; out[7] = a[7] + b[7]; out[8] = a[8] + b[8]; return out; } /** * Subtracts matrix b from matrix a * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the first operand * @param {ReadonlyMat3} b the second operand * @returns {mat3} out */ function subtract$4(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; out[4] = a[4] - b[4]; out[5] = a[5] - b[5]; out[6] = a[6] - b[6]; out[7] = a[7] - b[7]; out[8] = a[8] - b[8]; return out; } /** * Multiply each element of the matrix by a scalar. * * @param {mat3} out the receiving matrix * @param {ReadonlyMat3} a the matrix to scale * @param {Number} b amount to scale the matrix's elements by * @returns {mat3} out */ function multiplyScalar$1(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; out[4] = a[4] * b; out[5] = a[5] * b; out[6] = a[6] * b; out[7] = a[7] * b; out[8] = a[8] * b; return out; } /** * Adds two mat3's after multiplying each element of the second operand by a scalar value. * * @param {mat3} out the receiving vector * @param {ReadonlyMat3} a the first operand * @param {ReadonlyMat3} b the second operand * @param {Number} scale the amount to scale b's elements by before adding * @returns {mat3} out */ function multiplyScalarAndAdd$1(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; out[4] = a[4] + b[4] * scale; out[5] = a[5] + b[5] * scale; out[6] = a[6] + b[6] * scale; out[7] = a[7] + b[7] * scale; out[8] = a[8] + b[8] * scale; return out; } /** * Returns whether the matrices have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyMat3} a The first matrix. * @param {ReadonlyMat3} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function exactEquals$6(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8]; } /** * Returns whether the matrices have approximately the same elements in the same position. * * @param {ReadonlyMat3} a The first matrix. * @param {ReadonlyMat3} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function equals$6(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5], a6 = a[6], a7 = a[7], a8 = a[8]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3], b4 = b[4], b5 = b[5], b6 = b[6], b7 = b[7], b8 = b[8]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) && Math.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8)); } /** * Alias for {@link mat3.multiply} * @function */ var mul$6 = multiply$6; /** * Alias for {@link mat3.subtract} * @function */ var sub$4 = subtract$4; var mat3 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$6, fromMat4: fromMat4$1, clone: clone$6, copy: copy$6, fromValues: fromValues$6, set: set$6, identity: identity$3, transpose: transpose$1, invert: invert$3, adjoint: adjoint$1, determinant: determinant$1, multiply: multiply$6, translate: translate$2, rotate: rotate$2, scale: scale$6, fromTranslation: fromTranslation$2, fromRotation: fromRotation$2, fromScaling: fromScaling$1, fromMat2d: fromMat2d, fromQuat: fromQuat$1, normalFromMat4: normalFromMat4, projection: projection, str: str$6, frob: frob$1, add: add$6, subtract: subtract$4, multiplyScalar: multiplyScalar$1, multiplyScalarAndAdd: multiplyScalarAndAdd$1, exactEquals: exactEquals$6, equals: equals$6, mul: mul$6, sub: sub$4 }); /** * 4x4 Matrix
Format: column-major, when typed out it looks like row-major
The matrices are being post multiplied. * @module mat4 */ /** * Creates a new identity mat4 * * @returns {mat4} a new 4x4 matrix */ function create$5() { var out = new ARRAY_TYPE(16); if (ARRAY_TYPE != Float32Array) { out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; } out[0] = 1; out[5] = 1; out[10] = 1; out[15] = 1; return out; } /** * Creates a new mat4 initialized with values from an existing matrix * * @param {ReadonlyMat4} a matrix to clone * @returns {mat4} a new 4x4 matrix */ function clone$5(a) { var out = new ARRAY_TYPE(16); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Copy the values from one mat4 to another * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function copy$5(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Create a new mat4 with the given values * * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m03 Component in column 0, row 3 position (index 3) * @param {Number} m10 Component in column 1, row 0 position (index 4) * @param {Number} m11 Component in column 1, row 1 position (index 5) * @param {Number} m12 Component in column 1, row 2 position (index 6) * @param {Number} m13 Component in column 1, row 3 position (index 7) * @param {Number} m20 Component in column 2, row 0 position (index 8) * @param {Number} m21 Component in column 2, row 1 position (index 9) * @param {Number} m22 Component in column 2, row 2 position (index 10) * @param {Number} m23 Component in column 2, row 3 position (index 11) * @param {Number} m30 Component in column 3, row 0 position (index 12) * @param {Number} m31 Component in column 3, row 1 position (index 13) * @param {Number} m32 Component in column 3, row 2 position (index 14) * @param {Number} m33 Component in column 3, row 3 position (index 15) * @returns {mat4} A new mat4 */ function fromValues$5(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33) { var out = new ARRAY_TYPE(16); out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m03; out[4] = m10; out[5] = m11; out[6] = m12; out[7] = m13; out[8] = m20; out[9] = m21; out[10] = m22; out[11] = m23; out[12] = m30; out[13] = m31; out[14] = m32; out[15] = m33; return out; } /** * Set the components of a mat4 to the given values * * @param {mat4} out the receiving matrix * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m03 Component in column 0, row 3 position (index 3) * @param {Number} m10 Component in column 1, row 0 position (index 4) * @param {Number} m11 Component in column 1, row 1 position (index 5) * @param {Number} m12 Component in column 1, row 2 position (index 6) * @param {Number} m13 Component in column 1, row 3 position (index 7) * @param {Number} m20 Component in column 2, row 0 position (index 8) * @param {Number} m21 Component in column 2, row 1 position (index 9) * @param {Number} m22 Component in column 2, row 2 position (index 10) * @param {Number} m23 Component in column 2, row 3 position (index 11) * @param {Number} m30 Component in column 3, row 0 position (index 12) * @param {Number} m31 Component in column 3, row 1 position (index 13) * @param {Number} m32 Component in column 3, row 2 position (index 14) * @param {Number} m33 Component in column 3, row 3 position (index 15) * @returns {mat4} out */ function set$5(out, m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33) { out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m03; out[4] = m10; out[5] = m11; out[6] = m12; out[7] = m13; out[8] = m20; out[9] = m21; out[10] = m22; out[11] = m23; out[12] = m30; out[13] = m31; out[14] = m32; out[15] = m33; return out; } /** * Set a mat4 to the identity matrix * * @param {mat4} out the receiving matrix * @returns {mat4} out */ function identity$2(out) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Transpose the values of a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function transpose(out, a) { // If we are transposing ourselves we can skip a few steps but have to cache some values if (out === a) { var a01 = a[1], a02 = a[2], a03 = a[3]; var a12 = a[6], a13 = a[7]; var a23 = a[11]; out[1] = a[4]; out[2] = a[8]; out[3] = a[12]; out[4] = a01; out[6] = a[9]; out[7] = a[13]; out[8] = a02; out[9] = a12; out[11] = a[14]; out[12] = a03; out[13] = a13; out[14] = a23; } else { out[0] = a[0]; out[1] = a[4]; out[2] = a[8]; out[3] = a[12]; out[4] = a[1]; out[5] = a[5]; out[6] = a[9]; out[7] = a[13]; out[8] = a[2]; out[9] = a[6]; out[10] = a[10]; out[11] = a[14]; out[12] = a[3]; out[13] = a[7]; out[14] = a[11]; out[15] = a[15]; } return out; } /** * Inverts a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function invert$2(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b00 = a00 * a11 - a01 * a10; var b01 = a00 * a12 - a02 * a10; var b02 = a00 * a13 - a03 * a10; var b03 = a01 * a12 - a02 * a11; var b04 = a01 * a13 - a03 * a11; var b05 = a02 * a13 - a03 * a12; var b06 = a20 * a31 - a21 * a30; var b07 = a20 * a32 - a22 * a30; var b08 = a20 * a33 - a23 * a30; var b09 = a21 * a32 - a22 * a31; var b10 = a21 * a33 - a23 * a31; var b11 = a22 * a33 - a23 * a32; // Calculate the determinant var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; if (!det) { return null; } det = 1.0 / det; out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det; out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det; out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det; out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det; return out; } /** * Calculates the adjugate of a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function adjoint(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b00 = a00 * a11 - a01 * a10; var b01 = a00 * a12 - a02 * a10; var b02 = a00 * a13 - a03 * a10; var b03 = a01 * a12 - a02 * a11; var b04 = a01 * a13 - a03 * a11; var b05 = a02 * a13 - a03 * a12; var b06 = a20 * a31 - a21 * a30; var b07 = a20 * a32 - a22 * a30; var b08 = a20 * a33 - a23 * a30; var b09 = a21 * a32 - a22 * a31; var b10 = a21 * a33 - a23 * a31; var b11 = a22 * a33 - a23 * a32; out[0] = a11 * b11 - a12 * b10 + a13 * b09; out[1] = a02 * b10 - a01 * b11 - a03 * b09; out[2] = a31 * b05 - a32 * b04 + a33 * b03; out[3] = a22 * b04 - a21 * b05 - a23 * b03; out[4] = a12 * b08 - a10 * b11 - a13 * b07; out[5] = a00 * b11 - a02 * b08 + a03 * b07; out[6] = a32 * b02 - a30 * b05 - a33 * b01; out[7] = a20 * b05 - a22 * b02 + a23 * b01; out[8] = a10 * b10 - a11 * b08 + a13 * b06; out[9] = a01 * b08 - a00 * b10 - a03 * b06; out[10] = a30 * b04 - a31 * b02 + a33 * b00; out[11] = a21 * b02 - a20 * b04 - a23 * b00; out[12] = a11 * b07 - a10 * b09 - a12 * b06; out[13] = a00 * b09 - a01 * b07 + a02 * b06; out[14] = a31 * b01 - a30 * b03 - a32 * b00; out[15] = a20 * b03 - a21 * b01 + a22 * b00; return out; } /** * Calculates the determinant of a mat4 * * @param {ReadonlyMat4} a the source matrix * @returns {Number} determinant of a */ function determinant(a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b0 = a00 * a11 - a01 * a10; var b1 = a00 * a12 - a02 * a10; var b2 = a01 * a12 - a02 * a11; var b3 = a20 * a31 - a21 * a30; var b4 = a20 * a32 - a22 * a30; var b5 = a21 * a32 - a22 * a31; var b6 = a00 * b5 - a01 * b4 + a02 * b3; var b7 = a10 * b5 - a11 * b4 + a12 * b3; var b8 = a20 * b2 - a21 * b1 + a22 * b0; var b9 = a30 * b2 - a31 * b1 + a32 * b0; // Calculate the determinant return a13 * b6 - a03 * b7 + a33 * b8 - a23 * b9; } /** * Multiplies two mat4s * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function multiply$5(out, a, b) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; // Cache only the current line of the second matrix var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7]; out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11]; out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15]; out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; return out; } /** * Translate a mat4 by the given vector * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to translate * @param {ReadonlyVec3} v vector to translate by * @returns {mat4} out */ function translate$1(out, a, v) { var x = v[0], y = v[1], z = v[2]; var a00, a01, a02, a03; var a10, a11, a12, a13; var a20, a21, a22, a23; if (a === out) { out[12] = a[0] * x + a[4] * y + a[8] * z + a[12]; out[13] = a[1] * x + a[5] * y + a[9] * z + a[13]; out[14] = a[2] * x + a[6] * y + a[10] * z + a[14]; out[15] = a[3] * x + a[7] * y + a[11] * z + a[15]; } else { a00 = a[0]; a01 = a[1]; a02 = a[2]; a03 = a[3]; a10 = a[4]; a11 = a[5]; a12 = a[6]; a13 = a[7]; a20 = a[8]; a21 = a[9]; a22 = a[10]; a23 = a[11]; out[0] = a00; out[1] = a01; out[2] = a02; out[3] = a03; out[4] = a10; out[5] = a11; out[6] = a12; out[7] = a13; out[8] = a20; out[9] = a21; out[10] = a22; out[11] = a23; out[12] = a00 * x + a10 * y + a20 * z + a[12]; out[13] = a01 * x + a11 * y + a21 * z + a[13]; out[14] = a02 * x + a12 * y + a22 * z + a[14]; out[15] = a03 * x + a13 * y + a23 * z + a[15]; } return out; } /** * Scales the mat4 by the dimensions in the given vec3 not using vectorization * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to scale * @param {ReadonlyVec3} v the vec3 to scale the matrix by * @returns {mat4} out **/ function scale$5(out, a, v) { var x = v[0], y = v[1], z = v[2]; out[0] = a[0] * x; out[1] = a[1] * x; out[2] = a[2] * x; out[3] = a[3] * x; out[4] = a[4] * y; out[5] = a[5] * y; out[6] = a[6] * y; out[7] = a[7] * y; out[8] = a[8] * z; out[9] = a[9] * z; out[10] = a[10] * z; out[11] = a[11] * z; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Rotates a mat4 by the given angle around the given axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @param {ReadonlyVec3} axis the axis to rotate around * @returns {mat4} out */ function rotate$1(out, a, rad, axis) { var x = axis[0], y = axis[1], z = axis[2]; var len = Math.hypot(x, y, z); var s, c, t; var a00, a01, a02, a03; var a10, a11, a12, a13; var a20, a21, a22, a23; var b00, b01, b02; var b10, b11, b12; var b20, b21, b22; if (len < EPSILON) { return null; } len = 1 / len; x *= len; y *= len; z *= len; s = Math.sin(rad); c = Math.cos(rad); t = 1 - c; a00 = a[0]; a01 = a[1]; a02 = a[2]; a03 = a[3]; a10 = a[4]; a11 = a[5]; a12 = a[6]; a13 = a[7]; a20 = a[8]; a21 = a[9]; a22 = a[10]; a23 = a[11]; // Construct the elements of the rotation matrix b00 = x * x * t + c; b01 = y * x * t + z * s; b02 = z * x * t - y * s; b10 = x * y * t - z * s; b11 = y * y * t + c; b12 = z * y * t + x * s; b20 = x * z * t + y * s; b21 = y * z * t - x * s; b22 = z * z * t + c; // Perform rotation-specific matrix multiplication out[0] = a00 * b00 + a10 * b01 + a20 * b02; out[1] = a01 * b00 + a11 * b01 + a21 * b02; out[2] = a02 * b00 + a12 * b01 + a22 * b02; out[3] = a03 * b00 + a13 * b01 + a23 * b02; out[4] = a00 * b10 + a10 * b11 + a20 * b12; out[5] = a01 * b10 + a11 * b11 + a21 * b12; out[6] = a02 * b10 + a12 * b11 + a22 * b12; out[7] = a03 * b10 + a13 * b11 + a23 * b12; out[8] = a00 * b20 + a10 * b21 + a20 * b22; out[9] = a01 * b20 + a11 * b21 + a21 * b22; out[10] = a02 * b20 + a12 * b21 + a22 * b22; out[11] = a03 * b20 + a13 * b21 + a23 * b22; if (a !== out) { // If the source and destination differ, copy the unchanged last row out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } return out; } /** * Rotates a matrix by the given angle around the X axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateX$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a10 = a[4]; var a11 = a[5]; var a12 = a[6]; var a13 = a[7]; var a20 = a[8]; var a21 = a[9]; var a22 = a[10]; var a23 = a[11]; if (a !== out) { // If the source and destination differ, copy the unchanged rows out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[4] = a10 * c + a20 * s; out[5] = a11 * c + a21 * s; out[6] = a12 * c + a22 * s; out[7] = a13 * c + a23 * s; out[8] = a20 * c - a10 * s; out[9] = a21 * c - a11 * s; out[10] = a22 * c - a12 * s; out[11] = a23 * c - a13 * s; return out; } /** * Rotates a matrix by the given angle around the Y axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateY$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a00 = a[0]; var a01 = a[1]; var a02 = a[2]; var a03 = a[3]; var a20 = a[8]; var a21 = a[9]; var a22 = a[10]; var a23 = a[11]; if (a !== out) { // If the source and destination differ, copy the unchanged rows out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[0] = a00 * c - a20 * s; out[1] = a01 * c - a21 * s; out[2] = a02 * c - a22 * s; out[3] = a03 * c - a23 * s; out[8] = a00 * s + a20 * c; out[9] = a01 * s + a21 * c; out[10] = a02 * s + a22 * c; out[11] = a03 * s + a23 * c; return out; } /** * Rotates a matrix by the given angle around the Z axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateZ$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a00 = a[0]; var a01 = a[1]; var a02 = a[2]; var a03 = a[3]; var a10 = a[4]; var a11 = a[5]; var a12 = a[6]; var a13 = a[7]; if (a !== out) { // If the source and destination differ, copy the unchanged last row out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[0] = a00 * c + a10 * s; out[1] = a01 * c + a11 * s; out[2] = a02 * c + a12 * s; out[3] = a03 * c + a13 * s; out[4] = a10 * c - a00 * s; out[5] = a11 * c - a01 * s; out[6] = a12 * c - a02 * s; out[7] = a13 * c - a03 * s; return out; } /** * Creates a matrix from a vector translation * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, dest, vec); * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyVec3} v Translation vector * @returns {mat4} out */ function fromTranslation$1(out, v) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a matrix from a vector scaling * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.scale(dest, dest, vec); * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyVec3} v Scaling vector * @returns {mat4} out */ function fromScaling(out, v) { out[0] = v[0]; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = v[1]; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = v[2]; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from a given angle around a given axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotate(dest, dest, rad, axis); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @param {ReadonlyVec3} axis the axis to rotate around * @returns {mat4} out */ function fromRotation$1(out, rad, axis) { var x = axis[0], y = axis[1], z = axis[2]; var len = Math.hypot(x, y, z); var s, c, t; if (len < EPSILON) { return null; } len = 1 / len; x *= len; y *= len; z *= len; s = Math.sin(rad); c = Math.cos(rad); t = 1 - c; // Perform rotation-specific matrix multiplication out[0] = x * x * t + c; out[1] = y * x * t + z * s; out[2] = z * x * t - y * s; out[3] = 0; out[4] = x * y * t - z * s; out[5] = y * y * t + c; out[6] = z * y * t + x * s; out[7] = 0; out[8] = x * z * t + y * s; out[9] = y * z * t - x * s; out[10] = z * z * t + c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the X axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateX(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromXRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = c; out[6] = s; out[7] = 0; out[8] = 0; out[9] = -s; out[10] = c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the Y axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateY(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromYRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = c; out[1] = 0; out[2] = -s; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = s; out[9] = 0; out[10] = c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the Z axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateZ(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromZRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = c; out[1] = s; out[2] = 0; out[3] = 0; out[4] = -s; out[5] = c; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from a quaternion rotation and vector translation * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @returns {mat4} out */ function fromRotationTranslation$1(out, q, v) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; out[0] = 1 - (yy + zz); out[1] = xy + wz; out[2] = xz - wy; out[3] = 0; out[4] = xy - wz; out[5] = 1 - (xx + zz); out[6] = yz + wx; out[7] = 0; out[8] = xz + wy; out[9] = yz - wx; out[10] = 1 - (xx + yy); out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a new mat4 from a dual quat. * * @param {mat4} out Matrix * @param {ReadonlyQuat2} a Dual Quaternion * @returns {mat4} mat4 receiving operation result */ function fromQuat2(out, a) { var translation = new ARRAY_TYPE(3); var bx = -a[0], by = -a[1], bz = -a[2], bw = a[3], ax = a[4], ay = a[5], az = a[6], aw = a[7]; var magnitude = bx * bx + by * by + bz * bz + bw * bw; //Only scale if it makes sense if (magnitude > 0) { translation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2 / magnitude; translation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2 / magnitude; translation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2 / magnitude; } else { translation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2; translation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2; translation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2; } fromRotationTranslation$1(out, a, translation); return out; } /** * Returns the translation vector component of a transformation * matrix. If a matrix is built with fromRotationTranslation, * the returned vector will be the same as the translation vector * originally supplied. * @param {vec3} out Vector to receive translation component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {vec3} out */ function getTranslation$1(out, mat) { out[0] = mat[12]; out[1] = mat[13]; out[2] = mat[14]; return out; } /** * Returns the scaling factor component of a transformation * matrix. If a matrix is built with fromRotationTranslationScale * with a normalized Quaternion paramter, the returned vector will be * the same as the scaling vector * originally supplied. * @param {vec3} out Vector to receive scaling factor component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {vec3} out */ function getScaling(out, mat) { var m11 = mat[0]; var m12 = mat[1]; var m13 = mat[2]; var m21 = mat[4]; var m22 = mat[5]; var m23 = mat[6]; var m31 = mat[8]; var m32 = mat[9]; var m33 = mat[10]; out[0] = Math.hypot(m11, m12, m13); out[1] = Math.hypot(m21, m22, m23); out[2] = Math.hypot(m31, m32, m33); return out; } /** * Returns a quaternion representing the rotational component * of a transformation matrix. If a matrix is built with * fromRotationTranslation, the returned quaternion will be the * same as the quaternion originally supplied. * @param {quat} out Quaternion to receive the rotation component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {quat} out */ function getRotation(out, mat) { var scaling = new ARRAY_TYPE(3); getScaling(scaling, mat); var is1 = 1 / scaling[0]; var is2 = 1 / scaling[1]; var is3 = 1 / scaling[2]; var sm11 = mat[0] * is1; var sm12 = mat[1] * is2; var sm13 = mat[2] * is3; var sm21 = mat[4] * is1; var sm22 = mat[5] * is2; var sm23 = mat[6] * is3; var sm31 = mat[8] * is1; var sm32 = mat[9] * is2; var sm33 = mat[10] * is3; var trace = sm11 + sm22 + sm33; var S = 0; if (trace > 0) { S = Math.sqrt(trace + 1.0) * 2; out[3] = 0.25 * S; out[0] = (sm23 - sm32) / S; out[1] = (sm31 - sm13) / S; out[2] = (sm12 - sm21) / S; } else if (sm11 > sm22 && sm11 > sm33) { S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2; out[3] = (sm23 - sm32) / S; out[0] = 0.25 * S; out[1] = (sm12 + sm21) / S; out[2] = (sm31 + sm13) / S; } else if (sm22 > sm33) { S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2; out[3] = (sm31 - sm13) / S; out[0] = (sm12 + sm21) / S; out[1] = 0.25 * S; out[2] = (sm23 + sm32) / S; } else { S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2; out[3] = (sm12 - sm21) / S; out[0] = (sm31 + sm13) / S; out[1] = (sm23 + sm32) / S; out[2] = 0.25 * S; } return out; } /** * Decomposes a transformation matrix into its rotation, translation * and scale components. Returns only the rotation component * @param {quat} out_r Quaternion to receive the rotation component * @param {vec3} out_t Vector to receive the translation vector * @param {vec3} out_s Vector to receive the scaling factor * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @returns {quat} out_r */ function decompose(out_r, out_t, out_s, mat) { out_t[0] = mat[12]; out_t[1] = mat[13]; out_t[2] = mat[14]; var m11 = mat[0]; var m12 = mat[1]; var m13 = mat[2]; var m21 = mat[4]; var m22 = mat[5]; var m23 = mat[6]; var m31 = mat[8]; var m32 = mat[9]; var m33 = mat[10]; out_s[0] = Math.hypot(m11, m12, m13); out_s[1] = Math.hypot(m21, m22, m23); out_s[2] = Math.hypot(m31, m32, m33); var is1 = 1 / out_s[0]; var is2 = 1 / out_s[1]; var is3 = 1 / out_s[2]; var sm11 = m11 * is1; var sm12 = m12 * is2; var sm13 = m13 * is3; var sm21 = m21 * is1; var sm22 = m22 * is2; var sm23 = m23 * is3; var sm31 = m31 * is1; var sm32 = m32 * is2; var sm33 = m33 * is3; var trace = sm11 + sm22 + sm33; var S = 0; if (trace > 0) { S = Math.sqrt(trace + 1.0) * 2; out_r[3] = 0.25 * S; out_r[0] = (sm23 - sm32) / S; out_r[1] = (sm31 - sm13) / S; out_r[2] = (sm12 - sm21) / S; } else if (sm11 > sm22 && sm11 > sm33) { S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2; out_r[3] = (sm23 - sm32) / S; out_r[0] = 0.25 * S; out_r[1] = (sm12 + sm21) / S; out_r[2] = (sm31 + sm13) / S; } else if (sm22 > sm33) { S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2; out_r[3] = (sm31 - sm13) / S; out_r[0] = (sm12 + sm21) / S; out_r[1] = 0.25 * S; out_r[2] = (sm23 + sm32) / S; } else { S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2; out_r[3] = (sm12 - sm21) / S; out_r[0] = (sm31 + sm13) / S; out_r[1] = (sm23 + sm32) / S; out_r[2] = 0.25 * S; } return out_r; } /** * Creates a matrix from a quaternion rotation, vector translation and vector scale * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * mat4.scale(dest, scale) * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @param {ReadonlyVec3} s Scaling vector * @returns {mat4} out */ function fromRotationTranslationScale(out, q, v, s) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; var sx = s[0]; var sy = s[1]; var sz = s[2]; out[0] = (1 - (yy + zz)) * sx; out[1] = (xy + wz) * sx; out[2] = (xz - wy) * sx; out[3] = 0; out[4] = (xy - wz) * sy; out[5] = (1 - (xx + zz)) * sy; out[6] = (yz + wx) * sy; out[7] = 0; out[8] = (xz + wy) * sz; out[9] = (yz - wx) * sz; out[10] = (1 - (xx + yy)) * sz; out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a matrix from a quaternion rotation, vector translation and vector scale, rotating and scaling around the given origin * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * mat4.translate(dest, origin); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * mat4.scale(dest, scale) * mat4.translate(dest, negativeOrigin); * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @param {ReadonlyVec3} s Scaling vector * @param {ReadonlyVec3} o The origin vector around which to scale and rotate * @returns {mat4} out */ function fromRotationTranslationScaleOrigin(out, q, v, s, o) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; var sx = s[0]; var sy = s[1]; var sz = s[2]; var ox = o[0]; var oy = o[1]; var oz = o[2]; var out0 = (1 - (yy + zz)) * sx; var out1 = (xy + wz) * sx; var out2 = (xz - wy) * sx; var out4 = (xy - wz) * sy; var out5 = (1 - (xx + zz)) * sy; var out6 = (yz + wx) * sy; var out8 = (xz + wy) * sz; var out9 = (yz - wx) * sz; var out10 = (1 - (xx + yy)) * sz; out[0] = out0; out[1] = out1; out[2] = out2; out[3] = 0; out[4] = out4; out[5] = out5; out[6] = out6; out[7] = 0; out[8] = out8; out[9] = out9; out[10] = out10; out[11] = 0; out[12] = v[0] + ox - (out0 * ox + out4 * oy + out8 * oz); out[13] = v[1] + oy - (out1 * ox + out5 * oy + out9 * oz); out[14] = v[2] + oz - (out2 * ox + out6 * oy + out10 * oz); out[15] = 1; return out; } /** * Calculates a 4x4 matrix from the given quaternion * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyQuat} q Quaternion to create matrix from * * @returns {mat4} out */ function fromQuat(out, q) { var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var yx = y * x2; var yy = y * y2; var zx = z * x2; var zy = z * y2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; out[0] = 1 - yy - zz; out[1] = yx + wz; out[2] = zx - wy; out[3] = 0; out[4] = yx - wz; out[5] = 1 - xx - zz; out[6] = zy + wx; out[7] = 0; out[8] = zx + wy; out[9] = zy - wx; out[10] = 1 - xx - yy; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Generates a frustum matrix with the given bounds * * @param {mat4} out mat4 frustum matrix will be written into * @param {Number} left Left bound of the frustum * @param {Number} right Right bound of the frustum * @param {Number} bottom Bottom bound of the frustum * @param {Number} top Top bound of the frustum * @param {Number} near Near bound of the frustum * @param {Number} far Far bound of the frustum * @returns {mat4} out */ function frustum(out, left, right, bottom, top, near, far) { var rl = 1 / (right - left); var tb = 1 / (top - bottom); var nf = 1 / (near - far); out[0] = near * 2 * rl; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = near * 2 * tb; out[6] = 0; out[7] = 0; out[8] = (right + left) * rl; out[9] = (top + bottom) * tb; out[10] = (far + near) * nf; out[11] = -1; out[12] = 0; out[13] = 0; out[14] = far * near * 2 * nf; out[15] = 0; return out; } /** * Generates a perspective projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1], * which matches WebGL/OpenGL's clip volume. * Passing null/undefined/no value for far will generate infinite projection matrix. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} fovy Vertical field of view in radians * @param {number} aspect Aspect ratio. typically viewport width/height * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum, can be null or Infinity * @returns {mat4} out */ function perspectiveNO(out, fovy, aspect, near, far) { var f = 1.0 / Math.tan(fovy / 2); out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = -1; out[12] = 0; out[13] = 0; out[15] = 0; if (far != null && far !== Infinity) { var nf = 1 / (near - far); out[10] = (far + near) * nf; out[14] = 2 * far * near * nf; } else { out[10] = -1; out[14] = -2 * near; } return out; } /** * Alias for {@link mat4.perspectiveNO} * @function */ var perspective = perspectiveNO; /** * Generates a perspective projection matrix suitable for WebGPU with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1], * which matches WebGPU/Vulkan/DirectX/Metal's clip volume. * Passing null/undefined/no value for far will generate infinite projection matrix. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} fovy Vertical field of view in radians * @param {number} aspect Aspect ratio. typically viewport width/height * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum, can be null or Infinity * @returns {mat4} out */ function perspectiveZO(out, fovy, aspect, near, far) { var f = 1.0 / Math.tan(fovy / 2); out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = -1; out[12] = 0; out[13] = 0; out[15] = 0; if (far != null && far !== Infinity) { var nf = 1 / (near - far); out[10] = far * nf; out[14] = far * near * nf; } else { out[10] = -1; out[14] = -near; } return out; } /** * Generates a perspective projection matrix with the given field of view. * This is primarily useful for generating projection matrices to be used * with the still experiemental WebVR API. * * @param {mat4} out mat4 frustum matrix will be written into * @param {Object} fov Object containing the following values: upDegrees, downDegrees, leftDegrees, rightDegrees * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function perspectiveFromFieldOfView(out, fov, near, far) { var upTan = Math.tan(fov.upDegrees * Math.PI / 180.0); var downTan = Math.tan(fov.downDegrees * Math.PI / 180.0); var leftTan = Math.tan(fov.leftDegrees * Math.PI / 180.0); var rightTan = Math.tan(fov.rightDegrees * Math.PI / 180.0); var xScale = 2.0 / (leftTan + rightTan); var yScale = 2.0 / (upTan + downTan); out[0] = xScale; out[1] = 0.0; out[2] = 0.0; out[3] = 0.0; out[4] = 0.0; out[5] = yScale; out[6] = 0.0; out[7] = 0.0; out[8] = -((leftTan - rightTan) * xScale * 0.5); out[9] = (upTan - downTan) * yScale * 0.5; out[10] = far / (near - far); out[11] = -1.0; out[12] = 0.0; out[13] = 0.0; out[14] = far * near / (near - far); out[15] = 0.0; return out; } /** * Generates a orthogonal projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1], * which matches WebGL/OpenGL's clip volume. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} left Left bound of the frustum * @param {number} right Right bound of the frustum * @param {number} bottom Bottom bound of the frustum * @param {number} top Top bound of the frustum * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function orthoNO(out, left, right, bottom, top, near, far) { var lr = 1 / (left - right); var bt = 1 / (bottom - top); var nf = 1 / (near - far); out[0] = -2 * lr; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = -2 * bt; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 2 * nf; out[11] = 0; out[12] = (left + right) * lr; out[13] = (top + bottom) * bt; out[14] = (far + near) * nf; out[15] = 1; return out; } /** * Alias for {@link mat4.orthoNO} * @function */ var ortho = orthoNO; /** * Generates a orthogonal projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1], * which matches WebGPU/Vulkan/DirectX/Metal's clip volume. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} left Left bound of the frustum * @param {number} right Right bound of the frustum * @param {number} bottom Bottom bound of the frustum * @param {number} top Top bound of the frustum * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function orthoZO(out, left, right, bottom, top, near, far) { var lr = 1 / (left - right); var bt = 1 / (bottom - top); var nf = 1 / (near - far); out[0] = -2 * lr; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = -2 * bt; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = nf; out[11] = 0; out[12] = (left + right) * lr; out[13] = (top + bottom) * bt; out[14] = near * nf; out[15] = 1; return out; } /** * Generates a look-at matrix with the given eye position, focal point, and up axis. * If you want a matrix that actually makes an object look at another object, you should use targetTo instead. * * @param {mat4} out mat4 frustum matrix will be written into * @param {ReadonlyVec3} eye Position of the viewer * @param {ReadonlyVec3} center Point the viewer is looking at * @param {ReadonlyVec3} up vec3 pointing up * @returns {mat4} out */ function lookAt(out, eye, center, up) { var x0, x1, x2, y0, y1, y2, z0, z1, z2, len; var eyex = eye[0]; var eyey = eye[1]; var eyez = eye[2]; var upx = up[0]; var upy = up[1]; var upz = up[2]; var centerx = center[0]; var centery = center[1]; var centerz = center[2]; if (Math.abs(eyex - centerx) < EPSILON && Math.abs(eyey - centery) < EPSILON && Math.abs(eyez - centerz) < EPSILON) { return identity$2(out); } z0 = eyex - centerx; z1 = eyey - centery; z2 = eyez - centerz; len = 1 / Math.hypot(z0, z1, z2); z0 *= len; z1 *= len; z2 *= len; x0 = upy * z2 - upz * z1; x1 = upz * z0 - upx * z2; x2 = upx * z1 - upy * z0; len = Math.hypot(x0, x1, x2); if (!len) { x0 = 0; x1 = 0; x2 = 0; } else { len = 1 / len; x0 *= len; x1 *= len; x2 *= len; } y0 = z1 * x2 - z2 * x1; y1 = z2 * x0 - z0 * x2; y2 = z0 * x1 - z1 * x0; len = Math.hypot(y0, y1, y2); if (!len) { y0 = 0; y1 = 0; y2 = 0; } else { len = 1 / len; y0 *= len; y1 *= len; y2 *= len; } out[0] = x0; out[1] = y0; out[2] = z0; out[3] = 0; out[4] = x1; out[5] = y1; out[6] = z1; out[7] = 0; out[8] = x2; out[9] = y2; out[10] = z2; out[11] = 0; out[12] = -(x0 * eyex + x1 * eyey + x2 * eyez); out[13] = -(y0 * eyex + y1 * eyey + y2 * eyez); out[14] = -(z0 * eyex + z1 * eyey + z2 * eyez); out[15] = 1; return out; } /** * Generates a matrix that makes something look at something else. * * @param {mat4} out mat4 frustum matrix will be written into * @param {ReadonlyVec3} eye Position of the viewer * @param {ReadonlyVec3} center Point the viewer is looking at * @param {ReadonlyVec3} up vec3 pointing up * @returns {mat4} out */ function targetTo(out, eye, target, up) { var eyex = eye[0], eyey = eye[1], eyez = eye[2], upx = up[0], upy = up[1], upz = up[2]; var z0 = eyex - target[0], z1 = eyey - target[1], z2 = eyez - target[2]; var len = z0 * z0 + z1 * z1 + z2 * z2; if (len > 0) { len = 1 / Math.sqrt(len); z0 *= len; z1 *= len; z2 *= len; } var x0 = upy * z2 - upz * z1, x1 = upz * z0 - upx * z2, x2 = upx * z1 - upy * z0; len = x0 * x0 + x1 * x1 + x2 * x2; if (len > 0) { len = 1 / Math.sqrt(len); x0 *= len; x1 *= len; x2 *= len; } out[0] = x0; out[1] = x1; out[2] = x2; out[3] = 0; out[4] = z1 * x2 - z2 * x1; out[5] = z2 * x0 - z0 * x2; out[6] = z0 * x1 - z1 * x0; out[7] = 0; out[8] = z0; out[9] = z1; out[10] = z2; out[11] = 0; out[12] = eyex; out[13] = eyey; out[14] = eyez; out[15] = 1; return out; } /** * Returns a string representation of a mat4 * * @param {ReadonlyMat4} a matrix to represent as a string * @returns {String} string representation of the matrix */ function str$5(a) { return "mat4(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ", " + a[4] + ", " + a[5] + ", " + a[6] + ", " + a[7] + ", " + a[8] + ", " + a[9] + ", " + a[10] + ", " + a[11] + ", " + a[12] + ", " + a[13] + ", " + a[14] + ", " + a[15] + ")"; } /** * Returns Frobenius norm of a mat4 * * @param {ReadonlyMat4} a the matrix to calculate Frobenius norm of * @returns {Number} Frobenius norm */ function frob(a) { return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]); } /** * Adds two mat4's * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function add$5(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; out[4] = a[4] + b[4]; out[5] = a[5] + b[5]; out[6] = a[6] + b[6]; out[7] = a[7] + b[7]; out[8] = a[8] + b[8]; out[9] = a[9] + b[9]; out[10] = a[10] + b[10]; out[11] = a[11] + b[11]; out[12] = a[12] + b[12]; out[13] = a[13] + b[13]; out[14] = a[14] + b[14]; out[15] = a[15] + b[15]; return out; } /** * Subtracts matrix b from matrix a * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function subtract$3(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; out[4] = a[4] - b[4]; out[5] = a[5] - b[5]; out[6] = a[6] - b[6]; out[7] = a[7] - b[7]; out[8] = a[8] - b[8]; out[9] = a[9] - b[9]; out[10] = a[10] - b[10]; out[11] = a[11] - b[11]; out[12] = a[12] - b[12]; out[13] = a[13] - b[13]; out[14] = a[14] - b[14]; out[15] = a[15] - b[15]; return out; } /** * Multiply each element of the matrix by a scalar. * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to scale * @param {Number} b amount to scale the matrix's elements by * @returns {mat4} out */ function multiplyScalar(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; out[4] = a[4] * b; out[5] = a[5] * b; out[6] = a[6] * b; out[7] = a[7] * b; out[8] = a[8] * b; out[9] = a[9] * b; out[10] = a[10] * b; out[11] = a[11] * b; out[12] = a[12] * b; out[13] = a[13] * b; out[14] = a[14] * b; out[15] = a[15] * b; return out; } /** * Adds two mat4's after multiplying each element of the second operand by a scalar value. * * @param {mat4} out the receiving vector * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @param {Number} scale the amount to scale b's elements by before adding * @returns {mat4} out */ function multiplyScalarAndAdd(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; out[4] = a[4] + b[4] * scale; out[5] = a[5] + b[5] * scale; out[6] = a[6] + b[6] * scale; out[7] = a[7] + b[7] * scale; out[8] = a[8] + b[8] * scale; out[9] = a[9] + b[9] * scale; out[10] = a[10] + b[10] * scale; out[11] = a[11] + b[11] * scale; out[12] = a[12] + b[12] * scale; out[13] = a[13] + b[13] * scale; out[14] = a[14] + b[14] * scale; out[15] = a[15] + b[15] * scale; return out; } /** * Returns whether the matrices have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyMat4} a The first matrix. * @param {ReadonlyMat4} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function exactEquals$5(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8] && a[9] === b[9] && a[10] === b[10] && a[11] === b[11] && a[12] === b[12] && a[13] === b[13] && a[14] === b[14] && a[15] === b[15]; } /** * Returns whether the matrices have approximately the same elements in the same position. * * @param {ReadonlyMat4} a The first matrix. * @param {ReadonlyMat4} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function equals$5(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var a4 = a[4], a5 = a[5], a6 = a[6], a7 = a[7]; var a8 = a[8], a9 = a[9], a10 = a[10], a11 = a[11]; var a12 = a[12], a13 = a[13], a14 = a[14], a15 = a[15]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; var b4 = b[4], b5 = b[5], b6 = b[6], b7 = b[7]; var b8 = b[8], b9 = b[9], b10 = b[10], b11 = b[11]; var b12 = b[12], b13 = b[13], b14 = b[14], b15 = b[15]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) && Math.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8)) && Math.abs(a9 - b9) <= EPSILON * Math.max(1.0, Math.abs(a9), Math.abs(b9)) && Math.abs(a10 - b10) <= EPSILON * Math.max(1.0, Math.abs(a10), Math.abs(b10)) && Math.abs(a11 - b11) <= EPSILON * Math.max(1.0, Math.abs(a11), Math.abs(b11)) && Math.abs(a12 - b12) <= EPSILON * Math.max(1.0, Math.abs(a12), Math.abs(b12)) && Math.abs(a13 - b13) <= EPSILON * Math.max(1.0, Math.abs(a13), Math.abs(b13)) && Math.abs(a14 - b14) <= EPSILON * Math.max(1.0, Math.abs(a14), Math.abs(b14)) && Math.abs(a15 - b15) <= EPSILON * Math.max(1.0, Math.abs(a15), Math.abs(b15)); } /** * Alias for {@link mat4.multiply} * @function */ var mul$5 = multiply$5; /** * Alias for {@link mat4.subtract} * @function */ var sub$3 = subtract$3; var mat4 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$5, clone: clone$5, copy: copy$5, fromValues: fromValues$5, set: set$5, identity: identity$2, transpose: transpose, invert: invert$2, adjoint: adjoint, determinant: determinant, multiply: multiply$5, translate: translate$1, scale: scale$5, rotate: rotate$1, rotateX: rotateX$3, rotateY: rotateY$3, rotateZ: rotateZ$3, fromTranslation: fromTranslation$1, fromScaling: fromScaling, fromRotation: fromRotation$1, fromXRotation: fromXRotation, fromYRotation: fromYRotation, fromZRotation: fromZRotation, fromRotationTranslation: fromRotationTranslation$1, fromQuat2: fromQuat2, getTranslation: getTranslation$1, getScaling: getScaling, getRotation: getRotation, decompose: decompose, fromRotationTranslationScale: fromRotationTranslationScale, fromRotationTranslationScaleOrigin: fromRotationTranslationScaleOrigin, fromQuat: fromQuat, frustum: frustum, perspectiveNO: perspectiveNO, perspective: perspective, perspectiveZO: perspectiveZO, perspectiveFromFieldOfView: perspectiveFromFieldOfView, orthoNO: orthoNO, ortho: ortho, orthoZO: orthoZO, lookAt: lookAt, targetTo: targetTo, str: str$5, frob: frob, add: add$5, subtract: subtract$3, multiplyScalar: multiplyScalar, multiplyScalarAndAdd: multiplyScalarAndAdd, exactEquals: exactEquals$5, equals: equals$5, mul: mul$5, sub: sub$3 }); /** * 3 Dimensional Vector * @module vec3 */ /** * Creates a new, empty vec3 * * @returns {vec3} a new 3D vector */ function create$4() { var out = new ARRAY_TYPE(3); if (ARRAY_TYPE != Float32Array) { out[0] = 0; out[1] = 0; out[2] = 0; } return out; } /** * Creates a new vec3 initialized with values from an existing vector * * @param {ReadonlyVec3} a vector to clone * @returns {vec3} a new 3D vector */ function clone$4(a) { var out = new ARRAY_TYPE(3); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; return out; } /** * Calculates the length of a vec3 * * @param {ReadonlyVec3} a vector to calculate length of * @returns {Number} length of a */ function length$4(a) { var x = a[0]; var y = a[1]; var z = a[2]; return Math.hypot(x, y, z); } /** * Creates a new vec3 initialized with the given values * * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @returns {vec3} a new 3D vector */ function fromValues$4(x, y, z) { var out = new ARRAY_TYPE(3); out[0] = x; out[1] = y; out[2] = z; return out; } /** * Copy the values from one vec3 to another * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the source vector * @returns {vec3} out */ function copy$4(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; return out; } /** * Set the components of a vec3 to the given values * * @param {vec3} out the receiving vector * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @returns {vec3} out */ function set$4(out, x, y, z) { out[0] = x; out[1] = y; out[2] = z; return out; } /** * Adds two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function add$4(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; return out; } /** * Subtracts vector b from vector a * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function subtract$2(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; return out; } /** * Multiplies two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function multiply$4(out, a, b) { out[0] = a[0] * b[0]; out[1] = a[1] * b[1]; out[2] = a[2] * b[2]; return out; } /** * Divides two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function divide$2(out, a, b) { out[0] = a[0] / b[0]; out[1] = a[1] / b[1]; out[2] = a[2] / b[2]; return out; } /** * Math.ceil the components of a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to ceil * @returns {vec3} out */ function ceil$2(out, a) { out[0] = Math.ceil(a[0]); out[1] = Math.ceil(a[1]); out[2] = Math.ceil(a[2]); return out; } /** * Math.floor the components of a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to floor * @returns {vec3} out */ function floor$2(out, a) { out[0] = Math.floor(a[0]); out[1] = Math.floor(a[1]); out[2] = Math.floor(a[2]); return out; } /** * Returns the minimum of two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function min$2(out, a, b) { out[0] = Math.min(a[0], b[0]); out[1] = Math.min(a[1], b[1]); out[2] = Math.min(a[2], b[2]); return out; } /** * Returns the maximum of two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function max$2(out, a, b) { out[0] = Math.max(a[0], b[0]); out[1] = Math.max(a[1], b[1]); out[2] = Math.max(a[2], b[2]); return out; } /** * Math.round the components of a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to round * @returns {vec3} out */ function round$2(out, a) { out[0] = Math.round(a[0]); out[1] = Math.round(a[1]); out[2] = Math.round(a[2]); return out; } /** * Scales a vec3 by a scalar number * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the vector to scale * @param {Number} b amount to scale the vector by * @returns {vec3} out */ function scale$4(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; return out; } /** * Adds two vec3's after scaling the second operand by a scalar value * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @param {Number} scale the amount to scale b by before adding * @returns {vec3} out */ function scaleAndAdd$2(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; return out; } /** * Calculates the euclidian distance between two vec3's * * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {Number} distance between a and b */ function distance$2(a, b) { var x = b[0] - a[0]; var y = b[1] - a[1]; var z = b[2] - a[2]; return Math.hypot(x, y, z); } /** * Calculates the squared euclidian distance between two vec3's * * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {Number} squared distance between a and b */ function squaredDistance$2(a, b) { var x = b[0] - a[0]; var y = b[1] - a[1]; var z = b[2] - a[2]; return x * x + y * y + z * z; } /** * Calculates the squared length of a vec3 * * @param {ReadonlyVec3} a vector to calculate squared length of * @returns {Number} squared length of a */ function squaredLength$4(a) { var x = a[0]; var y = a[1]; var z = a[2]; return x * x + y * y + z * z; } /** * Negates the components of a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to negate * @returns {vec3} out */ function negate$2(out, a) { out[0] = -a[0]; out[1] = -a[1]; out[2] = -a[2]; return out; } /** * Returns the inverse of the components of a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to invert * @returns {vec3} out */ function inverse$2(out, a) { out[0] = 1.0 / a[0]; out[1] = 1.0 / a[1]; out[2] = 1.0 / a[2]; return out; } /** * Normalize a vec3 * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a vector to normalize * @returns {vec3} out */ function normalize$4(out, a) { var x = a[0]; var y = a[1]; var z = a[2]; var len = x * x + y * y + z * z; if (len > 0) { //TODO: evaluate use of glm_invsqrt here? len = 1 / Math.sqrt(len); } out[0] = a[0] * len; out[1] = a[1] * len; out[2] = a[2] * len; return out; } /** * Calculates the dot product of two vec3's * * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {Number} dot product of a and b */ function dot$4(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } /** * Computes the cross product of two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @returns {vec3} out */ function cross$2(out, a, b) { var ax = a[0], ay = a[1], az = a[2]; var bx = b[0], by = b[1], bz = b[2]; out[0] = ay * bz - az * by; out[1] = az * bx - ax * bz; out[2] = ax * by - ay * bx; return out; } /** * Performs a linear interpolation between two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec3} out */ function lerp$4(out, a, b, t) { var ax = a[0]; var ay = a[1]; var az = a[2]; out[0] = ax + t * (b[0] - ax); out[1] = ay + t * (b[1] - ay); out[2] = az + t * (b[2] - az); return out; } /** * Performs a spherical linear interpolation between two vec3's * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec3} out */ function slerp$1(out, a, b, t) { var angle = Math.acos(Math.min(Math.max(dot$4(a, b), -1), 1)); var sinTotal = Math.sin(angle); var ratioA = Math.sin((1 - t) * angle) / sinTotal; var ratioB = Math.sin(t * angle) / sinTotal; out[0] = ratioA * a[0] + ratioB * b[0]; out[1] = ratioA * a[1] + ratioB * b[1]; out[2] = ratioA * a[2] + ratioB * b[2]; return out; } /** * Performs a hermite interpolation with two control points * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @param {ReadonlyVec3} c the third operand * @param {ReadonlyVec3} d the fourth operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec3} out */ function hermite(out, a, b, c, d, t) { var factorTimes2 = t * t; var factor1 = factorTimes2 * (2 * t - 3) + 1; var factor2 = factorTimes2 * (t - 2) + t; var factor3 = factorTimes2 * (t - 1); var factor4 = factorTimes2 * (3 - 2 * t); out[0] = a[0] * factor1 + b[0] * factor2 + c[0] * factor3 + d[0] * factor4; out[1] = a[1] * factor1 + b[1] * factor2 + c[1] * factor3 + d[1] * factor4; out[2] = a[2] * factor1 + b[2] * factor2 + c[2] * factor3 + d[2] * factor4; return out; } /** * Performs a bezier interpolation with two control points * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the first operand * @param {ReadonlyVec3} b the second operand * @param {ReadonlyVec3} c the third operand * @param {ReadonlyVec3} d the fourth operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec3} out */ function bezier(out, a, b, c, d, t) { var inverseFactor = 1 - t; var inverseFactorTimesTwo = inverseFactor * inverseFactor; var factorTimes2 = t * t; var factor1 = inverseFactorTimesTwo * inverseFactor; var factor2 = 3 * t * inverseFactorTimesTwo; var factor3 = 3 * factorTimes2 * inverseFactor; var factor4 = factorTimes2 * t; out[0] = a[0] * factor1 + b[0] * factor2 + c[0] * factor3 + d[0] * factor4; out[1] = a[1] * factor1 + b[1] * factor2 + c[1] * factor3 + d[1] * factor4; out[2] = a[2] * factor1 + b[2] * factor2 + c[2] * factor3 + d[2] * factor4; return out; } /** * Generates a random vector with the given scale * * @param {vec3} out the receiving vector * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned * @returns {vec3} out */ function random$3(out, scale) { scale = scale === undefined ? 1.0 : scale; var r = RANDOM() * 2.0 * Math.PI; var z = RANDOM() * 2.0 - 1.0; var zScale = Math.sqrt(1.0 - z * z) * scale; out[0] = Math.cos(r) * zScale; out[1] = Math.sin(r) * zScale; out[2] = z * scale; return out; } /** * Transforms the vec3 with a mat4. * 4th vector component is implicitly '1' * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the vector to transform * @param {ReadonlyMat4} m matrix to transform with * @returns {vec3} out */ function transformMat4$2(out, a, m) { var x = a[0], y = a[1], z = a[2]; var w = m[3] * x + m[7] * y + m[11] * z + m[15]; w = w || 1.0; out[0] = (m[0] * x + m[4] * y + m[8] * z + m[12]) / w; out[1] = (m[1] * x + m[5] * y + m[9] * z + m[13]) / w; out[2] = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w; return out; } /** * Transforms the vec3 with a mat3. * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the vector to transform * @param {ReadonlyMat3} m the 3x3 matrix to transform with * @returns {vec3} out */ function transformMat3$1(out, a, m) { var x = a[0], y = a[1], z = a[2]; out[0] = x * m[0] + y * m[3] + z * m[6]; out[1] = x * m[1] + y * m[4] + z * m[7]; out[2] = x * m[2] + y * m[5] + z * m[8]; return out; } /** * Transforms the vec3 with a quat * Can also be used for dual quaternions. (Multiply it with the real part) * * @param {vec3} out the receiving vector * @param {ReadonlyVec3} a the vector to transform * @param {ReadonlyQuat} q quaternion to transform with * @returns {vec3} out */ function transformQuat$1(out, a, q) { // benchmarks: https://jsperf.com/quaternion-transform-vec3-implementations-fixed var qx = q[0], qy = q[1], qz = q[2], qw = q[3]; var x = a[0], y = a[1], z = a[2]; // var qvec = [qx, qy, qz]; // var uv = vec3.cross([], qvec, a); var uvx = qy * z - qz * y, uvy = qz * x - qx * z, uvz = qx * y - qy * x; // var uuv = vec3.cross([], qvec, uv); var uuvx = qy * uvz - qz * uvy, uuvy = qz * uvx - qx * uvz, uuvz = qx * uvy - qy * uvx; // vec3.scale(uv, uv, 2 * w); var w2 = qw * 2; uvx *= w2; uvy *= w2; uvz *= w2; // vec3.scale(uuv, uuv, 2); uuvx *= 2; uuvy *= 2; uuvz *= 2; // return vec3.add(out, a, vec3.add(out, uv, uuv)); out[0] = x + uvx + uuvx; out[1] = y + uvy + uuvy; out[2] = z + uvz + uuvz; return out; } /** * Rotate a 3D vector around the x-axis * @param {vec3} out The receiving vec3 * @param {ReadonlyVec3} a The vec3 point to rotate * @param {ReadonlyVec3} b The origin of the rotation * @param {Number} rad The angle of rotation in radians * @returns {vec3} out */ function rotateX$2(out, a, b, rad) { var p = [], r = []; //Translate point to the origin p[0] = a[0] - b[0]; p[1] = a[1] - b[1]; p[2] = a[2] - b[2]; //perform rotation r[0] = p[0]; r[1] = p[1] * Math.cos(rad) - p[2] * Math.sin(rad); r[2] = p[1] * Math.sin(rad) + p[2] * Math.cos(rad); //translate to correct position out[0] = r[0] + b[0]; out[1] = r[1] + b[1]; out[2] = r[2] + b[2]; return out; } /** * Rotate a 3D vector around the y-axis * @param {vec3} out The receiving vec3 * @param {ReadonlyVec3} a The vec3 point to rotate * @param {ReadonlyVec3} b The origin of the rotation * @param {Number} rad The angle of rotation in radians * @returns {vec3} out */ function rotateY$2(out, a, b, rad) { var p = [], r = []; //Translate point to the origin p[0] = a[0] - b[0]; p[1] = a[1] - b[1]; p[2] = a[2] - b[2]; //perform rotation r[0] = p[2] * Math.sin(rad) + p[0] * Math.cos(rad); r[1] = p[1]; r[2] = p[2] * Math.cos(rad) - p[0] * Math.sin(rad); //translate to correct position out[0] = r[0] + b[0]; out[1] = r[1] + b[1]; out[2] = r[2] + b[2]; return out; } /** * Rotate a 3D vector around the z-axis * @param {vec3} out The receiving vec3 * @param {ReadonlyVec3} a The vec3 point to rotate * @param {ReadonlyVec3} b The origin of the rotation * @param {Number} rad The angle of rotation in radians * @returns {vec3} out */ function rotateZ$2(out, a, b, rad) { var p = [], r = []; //Translate point to the origin p[0] = a[0] - b[0]; p[1] = a[1] - b[1]; p[2] = a[2] - b[2]; //perform rotation r[0] = p[0] * Math.cos(rad) - p[1] * Math.sin(rad); r[1] = p[0] * Math.sin(rad) + p[1] * Math.cos(rad); r[2] = p[2]; //translate to correct position out[0] = r[0] + b[0]; out[1] = r[1] + b[1]; out[2] = r[2] + b[2]; return out; } /** * Get the angle between two 3D vectors * @param {ReadonlyVec3} a The first operand * @param {ReadonlyVec3} b The second operand * @returns {Number} The angle in radians */ function angle$1(a, b) { var ax = a[0], ay = a[1], az = a[2], bx = b[0], by = b[1], bz = b[2], mag = Math.sqrt((ax * ax + ay * ay + az * az) * (bx * bx + by * by + bz * bz)), cosine = mag && dot$4(a, b) / mag; return Math.acos(Math.min(Math.max(cosine, -1), 1)); } /** * Set the components of a vec3 to zero * * @param {vec3} out the receiving vector * @returns {vec3} out */ function zero$2(out) { out[0] = 0.0; out[1] = 0.0; out[2] = 0.0; return out; } /** * Returns a string representation of a vector * * @param {ReadonlyVec3} a vector to represent as a string * @returns {String} string representation of the vector */ function str$4(a) { return "vec3(" + a[0] + ", " + a[1] + ", " + a[2] + ")"; } /** * Returns whether the vectors have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyVec3} a The first vector. * @param {ReadonlyVec3} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function exactEquals$4(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; } /** * Returns whether the vectors have approximately the same elements in the same position. * * @param {ReadonlyVec3} a The first vector. * @param {ReadonlyVec3} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function equals$4(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2]; var b0 = b[0], b1 = b[1], b2 = b[2]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)); } /** * Alias for {@link vec3.subtract} * @function */ var sub$2 = subtract$2; /** * Alias for {@link vec3.multiply} * @function */ var mul$4 = multiply$4; /** * Alias for {@link vec3.divide} * @function */ var div$2 = divide$2; /** * Alias for {@link vec3.distance} * @function */ var dist$2 = distance$2; /** * Alias for {@link vec3.squaredDistance} * @function */ var sqrDist$2 = squaredDistance$2; /** * Alias for {@link vec3.length} * @function */ var len$4 = length$4; /** * Alias for {@link vec3.squaredLength} * @function */ var sqrLen$4 = squaredLength$4; /** * Perform some operation over an array of vec3s. * * @param {Array} a the array of vectors to iterate over * @param {Number} stride Number of elements between the start of each vec3. If 0 assumes tightly packed * @param {Number} offset Number of elements to skip at the beginning of the array * @param {Number} count Number of vec3s to iterate over. If 0 iterates over entire array * @param {Function} fn Function to call for each vector in the array * @param {Object} [arg] additional argument to pass to fn * @returns {Array} a * @function */ var forEach$2 = function () { var vec = create$4(); return function (a, stride, offset, count, fn, arg) { var i, l; if (!stride) { stride = 3; } if (!offset) { offset = 0; } if (count) { l = Math.min(count * stride + offset, a.length); } else { l = a.length; } for (i = offset; i < l; i += stride) { vec[0] = a[i]; vec[1] = a[i + 1]; vec[2] = a[i + 2]; fn(vec, vec, arg); a[i] = vec[0]; a[i + 1] = vec[1]; a[i + 2] = vec[2]; } return a; }; }(); var vec3 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$4, clone: clone$4, length: length$4, fromValues: fromValues$4, copy: copy$4, set: set$4, add: add$4, subtract: subtract$2, multiply: multiply$4, divide: divide$2, ceil: ceil$2, floor: floor$2, min: min$2, max: max$2, round: round$2, scale: scale$4, scaleAndAdd: scaleAndAdd$2, distance: distance$2, squaredDistance: squaredDistance$2, squaredLength: squaredLength$4, negate: negate$2, inverse: inverse$2, normalize: normalize$4, dot: dot$4, cross: cross$2, lerp: lerp$4, slerp: slerp$1, hermite: hermite, bezier: bezier, random: random$3, transformMat4: transformMat4$2, transformMat3: transformMat3$1, transformQuat: transformQuat$1, rotateX: rotateX$2, rotateY: rotateY$2, rotateZ: rotateZ$2, angle: angle$1, zero: zero$2, str: str$4, exactEquals: exactEquals$4, equals: equals$4, sub: sub$2, mul: mul$4, div: div$2, dist: dist$2, sqrDist: sqrDist$2, len: len$4, sqrLen: sqrLen$4, forEach: forEach$2 }); /** * 4 Dimensional Vector * @module vec4 */ /** * Creates a new, empty vec4 * * @returns {vec4} a new 4D vector */ function create$3() { var out = new ARRAY_TYPE(4); if (ARRAY_TYPE != Float32Array) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 0; } return out; } /** * Creates a new vec4 initialized with values from an existing vector * * @param {ReadonlyVec4} a vector to clone * @returns {vec4} a new 4D vector */ function clone$3(a) { var out = new ARRAY_TYPE(4); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out; } /** * Creates a new vec4 initialized with the given values * * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @param {Number} w W component * @returns {vec4} a new 4D vector */ function fromValues$3(x, y, z, w) { var out = new ARRAY_TYPE(4); out[0] = x; out[1] = y; out[2] = z; out[3] = w; return out; } /** * Copy the values from one vec4 to another * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the source vector * @returns {vec4} out */ function copy$3(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; return out; } /** * Set the components of a vec4 to the given values * * @param {vec4} out the receiving vector * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @param {Number} w W component * @returns {vec4} out */ function set$3(out, x, y, z, w) { out[0] = x; out[1] = y; out[2] = z; out[3] = w; return out; } /** * Adds two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function add$3(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; return out; } /** * Subtracts vector b from vector a * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function subtract$1(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; return out; } /** * Multiplies two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function multiply$3(out, a, b) { out[0] = a[0] * b[0]; out[1] = a[1] * b[1]; out[2] = a[2] * b[2]; out[3] = a[3] * b[3]; return out; } /** * Divides two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function divide$1(out, a, b) { out[0] = a[0] / b[0]; out[1] = a[1] / b[1]; out[2] = a[2] / b[2]; out[3] = a[3] / b[3]; return out; } /** * Math.ceil the components of a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to ceil * @returns {vec4} out */ function ceil$1(out, a) { out[0] = Math.ceil(a[0]); out[1] = Math.ceil(a[1]); out[2] = Math.ceil(a[2]); out[3] = Math.ceil(a[3]); return out; } /** * Math.floor the components of a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to floor * @returns {vec4} out */ function floor$1(out, a) { out[0] = Math.floor(a[0]); out[1] = Math.floor(a[1]); out[2] = Math.floor(a[2]); out[3] = Math.floor(a[3]); return out; } /** * Returns the minimum of two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function min$1(out, a, b) { out[0] = Math.min(a[0], b[0]); out[1] = Math.min(a[1], b[1]); out[2] = Math.min(a[2], b[2]); out[3] = Math.min(a[3], b[3]); return out; } /** * Returns the maximum of two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {vec4} out */ function max$1(out, a, b) { out[0] = Math.max(a[0], b[0]); out[1] = Math.max(a[1], b[1]); out[2] = Math.max(a[2], b[2]); out[3] = Math.max(a[3], b[3]); return out; } /** * Math.round the components of a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to round * @returns {vec4} out */ function round$1(out, a) { out[0] = Math.round(a[0]); out[1] = Math.round(a[1]); out[2] = Math.round(a[2]); out[3] = Math.round(a[3]); return out; } /** * Scales a vec4 by a scalar number * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the vector to scale * @param {Number} b amount to scale the vector by * @returns {vec4} out */ function scale$3(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; return out; } /** * Adds two vec4's after scaling the second operand by a scalar value * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @param {Number} scale the amount to scale b by before adding * @returns {vec4} out */ function scaleAndAdd$1(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; return out; } /** * Calculates the euclidian distance between two vec4's * * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {Number} distance between a and b */ function distance$1(a, b) { var x = b[0] - a[0]; var y = b[1] - a[1]; var z = b[2] - a[2]; var w = b[3] - a[3]; return Math.hypot(x, y, z, w); } /** * Calculates the squared euclidian distance between two vec4's * * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {Number} squared distance between a and b */ function squaredDistance$1(a, b) { var x = b[0] - a[0]; var y = b[1] - a[1]; var z = b[2] - a[2]; var w = b[3] - a[3]; return x * x + y * y + z * z + w * w; } /** * Calculates the length of a vec4 * * @param {ReadonlyVec4} a vector to calculate length of * @returns {Number} length of a */ function length$3(a) { var x = a[0]; var y = a[1]; var z = a[2]; var w = a[3]; return Math.hypot(x, y, z, w); } /** * Calculates the squared length of a vec4 * * @param {ReadonlyVec4} a vector to calculate squared length of * @returns {Number} squared length of a */ function squaredLength$3(a) { var x = a[0]; var y = a[1]; var z = a[2]; var w = a[3]; return x * x + y * y + z * z + w * w; } /** * Negates the components of a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to negate * @returns {vec4} out */ function negate$1(out, a) { out[0] = -a[0]; out[1] = -a[1]; out[2] = -a[2]; out[3] = -a[3]; return out; } /** * Returns the inverse of the components of a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to invert * @returns {vec4} out */ function inverse$1(out, a) { out[0] = 1.0 / a[0]; out[1] = 1.0 / a[1]; out[2] = 1.0 / a[2]; out[3] = 1.0 / a[3]; return out; } /** * Normalize a vec4 * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a vector to normalize * @returns {vec4} out */ function normalize$3(out, a) { var x = a[0]; var y = a[1]; var z = a[2]; var w = a[3]; var len = x * x + y * y + z * z + w * w; if (len > 0) { len = 1 / Math.sqrt(len); } out[0] = x * len; out[1] = y * len; out[2] = z * len; out[3] = w * len; return out; } /** * Calculates the dot product of two vec4's * * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @returns {Number} dot product of a and b */ function dot$3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; } /** * Returns the cross-product of three vectors in a 4-dimensional space * * @param {ReadonlyVec4} result the receiving vector * @param {ReadonlyVec4} U the first vector * @param {ReadonlyVec4} V the second vector * @param {ReadonlyVec4} W the third vector * @returns {vec4} result */ function cross$1(out, u, v, w) { var A = v[0] * w[1] - v[1] * w[0], B = v[0] * w[2] - v[2] * w[0], C = v[0] * w[3] - v[3] * w[0], D = v[1] * w[2] - v[2] * w[1], E = v[1] * w[3] - v[3] * w[1], F = v[2] * w[3] - v[3] * w[2]; var G = u[0]; var H = u[1]; var I = u[2]; var J = u[3]; out[0] = H * F - I * E + J * D; out[1] = -(G * F) + I * C - J * B; out[2] = G * E - H * C + J * A; out[3] = -(G * D) + H * B - I * A; return out; } /** * Performs a linear interpolation between two vec4's * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the first operand * @param {ReadonlyVec4} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec4} out */ function lerp$3(out, a, b, t) { var ax = a[0]; var ay = a[1]; var az = a[2]; var aw = a[3]; out[0] = ax + t * (b[0] - ax); out[1] = ay + t * (b[1] - ay); out[2] = az + t * (b[2] - az); out[3] = aw + t * (b[3] - aw); return out; } /** * Generates a random vector with the given scale * * @param {vec4} out the receiving vector * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned * @returns {vec4} out */ function random$2(out, scale) { scale = scale === undefined ? 1.0 : scale; // Marsaglia, George. Choosing a Point from the Surface of a // Sphere. Ann. Math. Statist. 43 (1972), no. 2, 645--646. // http://projecteuclid.org/euclid.aoms/1177692644; var v1, v2, v3, v4; var s1, s2; do { v1 = RANDOM() * 2 - 1; v2 = RANDOM() * 2 - 1; s1 = v1 * v1 + v2 * v2; } while (s1 >= 1); do { v3 = RANDOM() * 2 - 1; v4 = RANDOM() * 2 - 1; s2 = v3 * v3 + v4 * v4; } while (s2 >= 1); var d = Math.sqrt((1 - s1) / s2); out[0] = scale * v1; out[1] = scale * v2; out[2] = scale * v3 * d; out[3] = scale * v4 * d; return out; } /** * Transforms the vec4 with a mat4. * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the vector to transform * @param {ReadonlyMat4} m matrix to transform with * @returns {vec4} out */ function transformMat4$1(out, a, m) { var x = a[0], y = a[1], z = a[2], w = a[3]; out[0] = m[0] * x + m[4] * y + m[8] * z + m[12] * w; out[1] = m[1] * x + m[5] * y + m[9] * z + m[13] * w; out[2] = m[2] * x + m[6] * y + m[10] * z + m[14] * w; out[3] = m[3] * x + m[7] * y + m[11] * z + m[15] * w; return out; } /** * Transforms the vec4 with a quat * * @param {vec4} out the receiving vector * @param {ReadonlyVec4} a the vector to transform * @param {ReadonlyQuat} q quaternion to transform with * @returns {vec4} out */ function transformQuat(out, a, q) { var x = a[0], y = a[1], z = a[2]; var qx = q[0], qy = q[1], qz = q[2], qw = q[3]; // calculate quat * vec var ix = qw * x + qy * z - qz * y; var iy = qw * y + qz * x - qx * z; var iz = qw * z + qx * y - qy * x; var iw = -qx * x - qy * y - qz * z; // calculate result * inverse quat out[0] = ix * qw + iw * -qx + iy * -qz - iz * -qy; out[1] = iy * qw + iw * -qy + iz * -qx - ix * -qz; out[2] = iz * qw + iw * -qz + ix * -qy - iy * -qx; out[3] = a[3]; return out; } /** * Set the components of a vec4 to zero * * @param {vec4} out the receiving vector * @returns {vec4} out */ function zero$1(out) { out[0] = 0.0; out[1] = 0.0; out[2] = 0.0; out[3] = 0.0; return out; } /** * Returns a string representation of a vector * * @param {ReadonlyVec4} a vector to represent as a string * @returns {String} string representation of the vector */ function str$3(a) { return "vec4(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ")"; } /** * Returns whether the vectors have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyVec4} a The first vector. * @param {ReadonlyVec4} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function exactEquals$3(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } /** * Returns whether the vectors have approximately the same elements in the same position. * * @param {ReadonlyVec4} a The first vector. * @param {ReadonlyVec4} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function equals$3(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)); } /** * Alias for {@link vec4.subtract} * @function */ var sub$1 = subtract$1; /** * Alias for {@link vec4.multiply} * @function */ var mul$3 = multiply$3; /** * Alias for {@link vec4.divide} * @function */ var div$1 = divide$1; /** * Alias for {@link vec4.distance} * @function */ var dist$1 = distance$1; /** * Alias for {@link vec4.squaredDistance} * @function */ var sqrDist$1 = squaredDistance$1; /** * Alias for {@link vec4.length} * @function */ var len$3 = length$3; /** * Alias for {@link vec4.squaredLength} * @function */ var sqrLen$3 = squaredLength$3; /** * Perform some operation over an array of vec4s. * * @param {Array} a the array of vectors to iterate over * @param {Number} stride Number of elements between the start of each vec4. If 0 assumes tightly packed * @param {Number} offset Number of elements to skip at the beginning of the array * @param {Number} count Number of vec4s to iterate over. If 0 iterates over entire array * @param {Function} fn Function to call for each vector in the array * @param {Object} [arg] additional argument to pass to fn * @returns {Array} a * @function */ var forEach$1 = function () { var vec = create$3(); return function (a, stride, offset, count, fn, arg) { var i, l; if (!stride) { stride = 4; } if (!offset) { offset = 0; } if (count) { l = Math.min(count * stride + offset, a.length); } else { l = a.length; } for (i = offset; i < l; i += stride) { vec[0] = a[i]; vec[1] = a[i + 1]; vec[2] = a[i + 2]; vec[3] = a[i + 3]; fn(vec, vec, arg); a[i] = vec[0]; a[i + 1] = vec[1]; a[i + 2] = vec[2]; a[i + 3] = vec[3]; } return a; }; }(); var vec4 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$3, clone: clone$3, fromValues: fromValues$3, copy: copy$3, set: set$3, add: add$3, subtract: subtract$1, multiply: multiply$3, divide: divide$1, ceil: ceil$1, floor: floor$1, min: min$1, max: max$1, round: round$1, scale: scale$3, scaleAndAdd: scaleAndAdd$1, distance: distance$1, squaredDistance: squaredDistance$1, length: length$3, squaredLength: squaredLength$3, negate: negate$1, inverse: inverse$1, normalize: normalize$3, dot: dot$3, cross: cross$1, lerp: lerp$3, random: random$2, transformMat4: transformMat4$1, transformQuat: transformQuat, zero: zero$1, str: str$3, exactEquals: exactEquals$3, equals: equals$3, sub: sub$1, mul: mul$3, div: div$1, dist: dist$1, sqrDist: sqrDist$1, len: len$3, sqrLen: sqrLen$3, forEach: forEach$1 }); /** * Quaternion in the format XYZW * @module quat */ /** * Creates a new identity quat * * @returns {quat} a new quaternion */ function create$2() { var out = new ARRAY_TYPE(4); if (ARRAY_TYPE != Float32Array) { out[0] = 0; out[1] = 0; out[2] = 0; } out[3] = 1; return out; } /** * Set a quat to the identity quaternion * * @param {quat} out the receiving quaternion * @returns {quat} out */ function identity$1(out) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1; return out; } /** * Sets a quat from the given angle and rotation axis, * then returns it. * * @param {quat} out the receiving quaternion * @param {ReadonlyVec3} axis the axis around which to rotate * @param {Number} rad the angle in radians * @returns {quat} out **/ function setAxisAngle(out, axis, rad) { rad = rad * 0.5; var s = Math.sin(rad); out[0] = s * axis[0]; out[1] = s * axis[1]; out[2] = s * axis[2]; out[3] = Math.cos(rad); return out; } /** * Gets the rotation axis and angle for a given * quaternion. If a quaternion is created with * setAxisAngle, this method will return the same * values as providied in the original parameter list * OR functionally equivalent values. * Example: The quaternion formed by axis [0, 0, 1] and * angle -90 is the same as the quaternion formed by * [0, 0, 1] and 270. This method favors the latter. * @param {vec3} out_axis Vector receiving the axis of rotation * @param {ReadonlyQuat} q Quaternion to be decomposed * @return {Number} Angle, in radians, of the rotation */ function getAxisAngle(out_axis, q) { var rad = Math.acos(q[3]) * 2.0; var s = Math.sin(rad / 2.0); if (s > EPSILON) { out_axis[0] = q[0] / s; out_axis[1] = q[1] / s; out_axis[2] = q[2] / s; } else { // If s is zero, return any axis (no rotation - axis does not matter) out_axis[0] = 1; out_axis[1] = 0; out_axis[2] = 0; } return rad; } /** * Gets the angular distance between two unit quaternions * * @param {ReadonlyQuat} a Origin unit quaternion * @param {ReadonlyQuat} b Destination unit quaternion * @return {Number} Angle, in radians, between the two quaternions */ function getAngle(a, b) { var dotproduct = dot$2(a, b); return Math.acos(2 * dotproduct * dotproduct - 1); } /** * Multiplies two quat's * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @returns {quat} out */ function multiply$2(out, a, b) { var ax = a[0], ay = a[1], az = a[2], aw = a[3]; var bx = b[0], by = b[1], bz = b[2], bw = b[3]; out[0] = ax * bw + aw * bx + ay * bz - az * by; out[1] = ay * bw + aw * by + az * bx - ax * bz; out[2] = az * bw + aw * bz + ax * by - ay * bx; out[3] = aw * bw - ax * bx - ay * by - az * bz; return out; } /** * Rotates a quaternion by the given angle about the X axis * * @param {quat} out quat receiving operation result * @param {ReadonlyQuat} a quat to rotate * @param {number} rad angle (in radians) to rotate * @returns {quat} out */ function rotateX$1(out, a, rad) { rad *= 0.5; var ax = a[0], ay = a[1], az = a[2], aw = a[3]; var bx = Math.sin(rad), bw = Math.cos(rad); out[0] = ax * bw + aw * bx; out[1] = ay * bw + az * bx; out[2] = az * bw - ay * bx; out[3] = aw * bw - ax * bx; return out; } /** * Rotates a quaternion by the given angle about the Y axis * * @param {quat} out quat receiving operation result * @param {ReadonlyQuat} a quat to rotate * @param {number} rad angle (in radians) to rotate * @returns {quat} out */ function rotateY$1(out, a, rad) { rad *= 0.5; var ax = a[0], ay = a[1], az = a[2], aw = a[3]; var by = Math.sin(rad), bw = Math.cos(rad); out[0] = ax * bw - az * by; out[1] = ay * bw + aw * by; out[2] = az * bw + ax * by; out[3] = aw * bw - ay * by; return out; } /** * Rotates a quaternion by the given angle about the Z axis * * @param {quat} out quat receiving operation result * @param {ReadonlyQuat} a quat to rotate * @param {number} rad angle (in radians) to rotate * @returns {quat} out */ function rotateZ$1(out, a, rad) { rad *= 0.5; var ax = a[0], ay = a[1], az = a[2], aw = a[3]; var bz = Math.sin(rad), bw = Math.cos(rad); out[0] = ax * bw + ay * bz; out[1] = ay * bw - ax * bz; out[2] = az * bw + aw * bz; out[3] = aw * bw - az * bz; return out; } /** * Calculates the W component of a quat from the X, Y, and Z components. * Assumes that quaternion is 1 unit in length. * Any existing W component will be ignored. * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate W component of * @returns {quat} out */ function calculateW(out, a) { var x = a[0], y = a[1], z = a[2]; out[0] = x; out[1] = y; out[2] = z; out[3] = Math.sqrt(Math.abs(1.0 - x * x - y * y - z * z)); return out; } /** * Calculate the exponential of a unit quaternion. * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate the exponential of * @returns {quat} out */ function exp(out, a) { var x = a[0], y = a[1], z = a[2], w = a[3]; var r = Math.sqrt(x * x + y * y + z * z); var et = Math.exp(w); var s = r > 0 ? et * Math.sin(r) / r : 0; out[0] = x * s; out[1] = y * s; out[2] = z * s; out[3] = et * Math.cos(r); return out; } /** * Calculate the natural logarithm of a unit quaternion. * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate the exponential of * @returns {quat} out */ function ln(out, a) { var x = a[0], y = a[1], z = a[2], w = a[3]; var r = Math.sqrt(x * x + y * y + z * z); var t = r > 0 ? Math.atan2(r, w) / r : 0; out[0] = x * t; out[1] = y * t; out[2] = z * t; out[3] = 0.5 * Math.log(x * x + y * y + z * z + w * w); return out; } /** * Calculate the scalar power of a unit quaternion. * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate the exponential of * @param {Number} b amount to scale the quaternion by * @returns {quat} out */ function pow(out, a, b) { ln(out, a); scale$2(out, out, b); exp(out, out); return out; } /** * Performs a spherical linear interpolation between two quat * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {quat} out */ function slerp(out, a, b, t) { // benchmarks: // http://jsperf.com/quaternion-slerp-implementations var ax = a[0], ay = a[1], az = a[2], aw = a[3]; var bx = b[0], by = b[1], bz = b[2], bw = b[3]; var omega, cosom, sinom, scale0, scale1; // calc cosine cosom = ax * bx + ay * by + az * bz + aw * bw; // adjust signs (if necessary) if (cosom < 0.0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw; } // calculate coefficients if (1.0 - cosom > EPSILON) { // standard case (slerp) omega = Math.acos(cosom); sinom = Math.sin(omega); scale0 = Math.sin((1.0 - t) * omega) / sinom; scale1 = Math.sin(t * omega) / sinom; } else { // "from" and "to" quaternions are very close // ... so we can do a linear interpolation scale0 = 1.0 - t; scale1 = t; } // calculate final values out[0] = scale0 * ax + scale1 * bx; out[1] = scale0 * ay + scale1 * by; out[2] = scale0 * az + scale1 * bz; out[3] = scale0 * aw + scale1 * bw; return out; } /** * Generates a random unit quaternion * * @param {quat} out the receiving quaternion * @returns {quat} out */ function random$1(out) { // Implementation of http://planning.cs.uiuc.edu/node198.html // TODO: Calling random 3 times is probably not the fastest solution var u1 = RANDOM(); var u2 = RANDOM(); var u3 = RANDOM(); var sqrt1MinusU1 = Math.sqrt(1 - u1); var sqrtU1 = Math.sqrt(u1); out[0] = sqrt1MinusU1 * Math.sin(2.0 * Math.PI * u2); out[1] = sqrt1MinusU1 * Math.cos(2.0 * Math.PI * u2); out[2] = sqrtU1 * Math.sin(2.0 * Math.PI * u3); out[3] = sqrtU1 * Math.cos(2.0 * Math.PI * u3); return out; } /** * Calculates the inverse of a quat * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate inverse of * @returns {quat} out */ function invert$1(out, a) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var dot = a0 * a0 + a1 * a1 + a2 * a2 + a3 * a3; var invDot = dot ? 1.0 / dot : 0; // TODO: Would be faster to return [0,0,0,0] immediately if dot == 0 out[0] = -a0 * invDot; out[1] = -a1 * invDot; out[2] = -a2 * invDot; out[3] = a3 * invDot; return out; } /** * Calculates the conjugate of a quat * If the quaternion is normalized, this function is faster than quat.inverse and produces the same result. * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quat to calculate conjugate of * @returns {quat} out */ function conjugate$1(out, a) { out[0] = -a[0]; out[1] = -a[1]; out[2] = -a[2]; out[3] = a[3]; return out; } /** * Creates a quaternion from the given 3x3 rotation matrix. * * NOTE: The resultant quaternion is not normalized, so you should be sure * to renormalize the quaternion yourself where necessary. * * @param {quat} out the receiving quaternion * @param {ReadonlyMat3} m rotation matrix * @returns {quat} out * @function */ function fromMat3(out, m) { // Algorithm in Ken Shoemake's article in 1987 SIGGRAPH course notes // article "Quaternion Calculus and Fast Animation". var fTrace = m[0] + m[4] + m[8]; var fRoot; if (fTrace > 0.0) { // |w| > 1/2, may as well choose w > 1/2 fRoot = Math.sqrt(fTrace + 1.0); // 2w out[3] = 0.5 * fRoot; fRoot = 0.5 / fRoot; // 1/(4w) out[0] = (m[5] - m[7]) * fRoot; out[1] = (m[6] - m[2]) * fRoot; out[2] = (m[1] - m[3]) * fRoot; } else { // |w| <= 1/2 var i = 0; if (m[4] > m[0]) i = 1; if (m[8] > m[i * 3 + i]) i = 2; var j = (i + 1) % 3; var k = (i + 2) % 3; fRoot = Math.sqrt(m[i * 3 + i] - m[j * 3 + j] - m[k * 3 + k] + 1.0); out[i] = 0.5 * fRoot; fRoot = 0.5 / fRoot; out[3] = (m[j * 3 + k] - m[k * 3 + j]) * fRoot; out[j] = (m[j * 3 + i] + m[i * 3 + j]) * fRoot; out[k] = (m[k * 3 + i] + m[i * 3 + k]) * fRoot; } return out; } /** * Creates a quaternion from the given euler angle x, y, z using the provided intrinsic order for the conversion. * * @param {quat} out the receiving quaternion * @param {x} x Angle to rotate around X axis in degrees. * @param {y} y Angle to rotate around Y axis in degrees. * @param {z} z Angle to rotate around Z axis in degrees. * @param {'zyx'|'xyz'|'yxz'|'yzx'|'zxy'|'zyx'} order Intrinsic order for conversion, default is zyx. * @returns {quat} out * @function */ function fromEuler(out, x, y, z) { var order = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : ANGLE_ORDER; var halfToRad = Math.PI / 360; x *= halfToRad; z *= halfToRad; y *= halfToRad; var sx = Math.sin(x); var cx = Math.cos(x); var sy = Math.sin(y); var cy = Math.cos(y); var sz = Math.sin(z); var cz = Math.cos(z); switch (order) { case "xyz": out[0] = sx * cy * cz + cx * sy * sz; out[1] = cx * sy * cz - sx * cy * sz; out[2] = cx * cy * sz + sx * sy * cz; out[3] = cx * cy * cz - sx * sy * sz; break; case "xzy": out[0] = sx * cy * cz - cx * sy * sz; out[1] = cx * sy * cz - sx * cy * sz; out[2] = cx * cy * sz + sx * sy * cz; out[3] = cx * cy * cz + sx * sy * sz; break; case "yxz": out[0] = sx * cy * cz + cx * sy * sz; out[1] = cx * sy * cz - sx * cy * sz; out[2] = cx * cy * sz - sx * sy * cz; out[3] = cx * cy * cz + sx * sy * sz; break; case "yzx": out[0] = sx * cy * cz + cx * sy * sz; out[1] = cx * sy * cz + sx * cy * sz; out[2] = cx * cy * sz - sx * sy * cz; out[3] = cx * cy * cz - sx * sy * sz; break; case "zxy": out[0] = sx * cy * cz - cx * sy * sz; out[1] = cx * sy * cz + sx * cy * sz; out[2] = cx * cy * sz + sx * sy * cz; out[3] = cx * cy * cz - sx * sy * sz; break; case "zyx": out[0] = sx * cy * cz - cx * sy * sz; out[1] = cx * sy * cz + sx * cy * sz; out[2] = cx * cy * sz - sx * sy * cz; out[3] = cx * cy * cz + sx * sy * sz; break; default: throw new Error('Unknown angle order ' + order); } return out; } /** * Returns a string representation of a quaternion * * @param {ReadonlyQuat} a vector to represent as a string * @returns {String} string representation of the vector */ function str$2(a) { return "quat(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ")"; } /** * Creates a new quat initialized with values from an existing quaternion * * @param {ReadonlyQuat} a quaternion to clone * @returns {quat} a new quaternion * @function */ var clone$2 = clone$3; /** * Creates a new quat initialized with the given values * * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @param {Number} w W component * @returns {quat} a new quaternion * @function */ var fromValues$2 = fromValues$3; /** * Copy the values from one quat to another * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the source quaternion * @returns {quat} out * @function */ var copy$2 = copy$3; /** * Set the components of a quat to the given values * * @param {quat} out the receiving quaternion * @param {Number} x X component * @param {Number} y Y component * @param {Number} z Z component * @param {Number} w W component * @returns {quat} out * @function */ var set$2 = set$3; /** * Adds two quat's * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @returns {quat} out * @function */ var add$2 = add$3; /** * Alias for {@link quat.multiply} * @function */ var mul$2 = multiply$2; /** * Scales a quat by a scalar number * * @param {quat} out the receiving vector * @param {ReadonlyQuat} a the vector to scale * @param {Number} b amount to scale the vector by * @returns {quat} out * @function */ var scale$2 = scale$3; /** * Calculates the dot product of two quat's * * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @returns {Number} dot product of a and b * @function */ var dot$2 = dot$3; /** * Performs a linear interpolation between two quat's * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {quat} out * @function */ var lerp$2 = lerp$3; /** * Calculates the length of a quat * * @param {ReadonlyQuat} a vector to calculate length of * @returns {Number} length of a */ var length$2 = length$3; /** * Alias for {@link quat.length} * @function */ var len$2 = length$2; /** * Calculates the squared length of a quat * * @param {ReadonlyQuat} a vector to calculate squared length of * @returns {Number} squared length of a * @function */ var squaredLength$2 = squaredLength$3; /** * Alias for {@link quat.squaredLength} * @function */ var sqrLen$2 = squaredLength$2; /** * Normalize a quat * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a quaternion to normalize * @returns {quat} out * @function */ var normalize$2 = normalize$3; /** * Returns whether the quaternions have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyQuat} a The first quaternion. * @param {ReadonlyQuat} b The second quaternion. * @returns {Boolean} True if the vectors are equal, false otherwise. */ var exactEquals$2 = exactEquals$3; /** * Returns whether the quaternions point approximately to the same direction. * * Both quaternions are assumed to be unit length. * * @param {ReadonlyQuat} a The first unit quaternion. * @param {ReadonlyQuat} b The second unit quaternion. * @returns {Boolean} True if the quaternions are equal, false otherwise. */ function equals$2(a, b) { return Math.abs(dot$3(a, b)) >= 1 - EPSILON; } /** * Sets a quaternion to represent the shortest rotation from one * vector to another. * * Both vectors are assumed to be unit length. * * @param {quat} out the receiving quaternion. * @param {ReadonlyVec3} a the initial vector * @param {ReadonlyVec3} b the destination vector * @returns {quat} out */ var rotationTo = function () { var tmpvec3 = create$4(); var xUnitVec3 = fromValues$4(1, 0, 0); var yUnitVec3 = fromValues$4(0, 1, 0); return function (out, a, b) { var dot = dot$4(a, b); if (dot < -0.999999) { cross$2(tmpvec3, xUnitVec3, a); if (len$4(tmpvec3) < 0.000001) cross$2(tmpvec3, yUnitVec3, a); normalize$4(tmpvec3, tmpvec3); setAxisAngle(out, tmpvec3, Math.PI); return out; } else if (dot > 0.999999) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1; return out; } else { cross$2(tmpvec3, a, b); out[0] = tmpvec3[0]; out[1] = tmpvec3[1]; out[2] = tmpvec3[2]; out[3] = 1 + dot; return normalize$2(out, out); } }; }(); /** * Performs a spherical linear interpolation with two control points * * @param {quat} out the receiving quaternion * @param {ReadonlyQuat} a the first operand * @param {ReadonlyQuat} b the second operand * @param {ReadonlyQuat} c the third operand * @param {ReadonlyQuat} d the fourth operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {quat} out */ var sqlerp = function () { var temp1 = create$2(); var temp2 = create$2(); return function (out, a, b, c, d, t) { slerp(temp1, a, d, t); slerp(temp2, b, c, t); slerp(out, temp1, temp2, 2 * t * (1 - t)); return out; }; }(); /** * Sets the specified quaternion with values corresponding to the given * axes. Each axis is a vec3 and is expected to be unit length and * perpendicular to all other specified axes. * * @param {ReadonlyVec3} view the vector representing the viewing direction * @param {ReadonlyVec3} right the vector representing the local "right" direction * @param {ReadonlyVec3} up the vector representing the local "up" direction * @returns {quat} out */ var setAxes = function () { var matr = create$6(); return function (out, view, right, up) { matr[0] = right[0]; matr[3] = right[1]; matr[6] = right[2]; matr[1] = up[0]; matr[4] = up[1]; matr[7] = up[2]; matr[2] = -view[0]; matr[5] = -view[1]; matr[8] = -view[2]; return normalize$2(out, fromMat3(out, matr)); }; }(); var quat = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$2, identity: identity$1, setAxisAngle: setAxisAngle, getAxisAngle: getAxisAngle, getAngle: getAngle, multiply: multiply$2, rotateX: rotateX$1, rotateY: rotateY$1, rotateZ: rotateZ$1, calculateW: calculateW, exp: exp, ln: ln, pow: pow, slerp: slerp, random: random$1, invert: invert$1, conjugate: conjugate$1, fromMat3: fromMat3, fromEuler: fromEuler, str: str$2, clone: clone$2, fromValues: fromValues$2, copy: copy$2, set: set$2, add: add$2, mul: mul$2, scale: scale$2, dot: dot$2, lerp: lerp$2, length: length$2, len: len$2, squaredLength: squaredLength$2, sqrLen: sqrLen$2, normalize: normalize$2, exactEquals: exactEquals$2, equals: equals$2, rotationTo: rotationTo, sqlerp: sqlerp, setAxes: setAxes }); /** * Dual Quaternion
* Format: [real, dual]
* Quaternion format: XYZW
* Make sure to have normalized dual quaternions, otherwise the functions may not work as intended.
* @module quat2 */ /** * Creates a new identity dual quat * * @returns {quat2} a new dual quaternion [real -> rotation, dual -> translation] */ function create$1() { var dq = new ARRAY_TYPE(8); if (ARRAY_TYPE != Float32Array) { dq[0] = 0; dq[1] = 0; dq[2] = 0; dq[4] = 0; dq[5] = 0; dq[6] = 0; dq[7] = 0; } dq[3] = 1; return dq; } /** * Creates a new quat initialized with values from an existing quaternion * * @param {ReadonlyQuat2} a dual quaternion to clone * @returns {quat2} new dual quaternion * @function */ function clone$1(a) { var dq = new ARRAY_TYPE(8); dq[0] = a[0]; dq[1] = a[1]; dq[2] = a[2]; dq[3] = a[3]; dq[4] = a[4]; dq[5] = a[5]; dq[6] = a[6]; dq[7] = a[7]; return dq; } /** * Creates a new dual quat initialized with the given values * * @param {Number} x1 X component * @param {Number} y1 Y component * @param {Number} z1 Z component * @param {Number} w1 W component * @param {Number} x2 X component * @param {Number} y2 Y component * @param {Number} z2 Z component * @param {Number} w2 W component * @returns {quat2} new dual quaternion * @function */ function fromValues$1(x1, y1, z1, w1, x2, y2, z2, w2) { var dq = new ARRAY_TYPE(8); dq[0] = x1; dq[1] = y1; dq[2] = z1; dq[3] = w1; dq[4] = x2; dq[5] = y2; dq[6] = z2; dq[7] = w2; return dq; } /** * Creates a new dual quat from the given values (quat and translation) * * @param {Number} x1 X component * @param {Number} y1 Y component * @param {Number} z1 Z component * @param {Number} w1 W component * @param {Number} x2 X component (translation) * @param {Number} y2 Y component (translation) * @param {Number} z2 Z component (translation) * @returns {quat2} new dual quaternion * @function */ function fromRotationTranslationValues(x1, y1, z1, w1, x2, y2, z2) { var dq = new ARRAY_TYPE(8); dq[0] = x1; dq[1] = y1; dq[2] = z1; dq[3] = w1; var ax = x2 * 0.5, ay = y2 * 0.5, az = z2 * 0.5; dq[4] = ax * w1 + ay * z1 - az * y1; dq[5] = ay * w1 + az * x1 - ax * z1; dq[6] = az * w1 + ax * y1 - ay * x1; dq[7] = -ax * x1 - ay * y1 - az * z1; return dq; } /** * Creates a dual quat from a quaternion and a translation * * @param {ReadonlyQuat2} dual quaternion receiving operation result * @param {ReadonlyQuat} q a normalized quaternion * @param {ReadonlyVec3} t translation vector * @returns {quat2} dual quaternion receiving operation result * @function */ function fromRotationTranslation(out, q, t) { var ax = t[0] * 0.5, ay = t[1] * 0.5, az = t[2] * 0.5, bx = q[0], by = q[1], bz = q[2], bw = q[3]; out[0] = bx; out[1] = by; out[2] = bz; out[3] = bw; out[4] = ax * bw + ay * bz - az * by; out[5] = ay * bw + az * bx - ax * bz; out[6] = az * bw + ax * by - ay * bx; out[7] = -ax * bx - ay * by - az * bz; return out; } /** * Creates a dual quat from a translation * * @param {ReadonlyQuat2} dual quaternion receiving operation result * @param {ReadonlyVec3} t translation vector * @returns {quat2} dual quaternion receiving operation result * @function */ function fromTranslation(out, t) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1; out[4] = t[0] * 0.5; out[5] = t[1] * 0.5; out[6] = t[2] * 0.5; out[7] = 0; return out; } /** * Creates a dual quat from a quaternion * * @param {ReadonlyQuat2} dual quaternion receiving operation result * @param {ReadonlyQuat} q the quaternion * @returns {quat2} dual quaternion receiving operation result * @function */ function fromRotation(out, q) { out[0] = q[0]; out[1] = q[1]; out[2] = q[2]; out[3] = q[3]; out[4] = 0; out[5] = 0; out[6] = 0; out[7] = 0; return out; } /** * Creates a new dual quat from a matrix (4x4) * * @param {quat2} out the dual quaternion * @param {ReadonlyMat4} a the matrix * @returns {quat2} dual quat receiving operation result * @function */ function fromMat4(out, a) { //TODO Optimize this var outer = create$2(); getRotation(outer, a); var t = new ARRAY_TYPE(3); getTranslation$1(t, a); fromRotationTranslation(out, outer, t); return out; } /** * Copy the values from one dual quat to another * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the source dual quaternion * @returns {quat2} out * @function */ function copy$1(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; return out; } /** * Set a dual quat to the identity dual quaternion * * @param {quat2} out the receiving quaternion * @returns {quat2} out */ function identity(out) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1; out[4] = 0; out[5] = 0; out[6] = 0; out[7] = 0; return out; } /** * Set the components of a dual quat to the given values * * @param {quat2} out the receiving quaternion * @param {Number} x1 X component * @param {Number} y1 Y component * @param {Number} z1 Z component * @param {Number} w1 W component * @param {Number} x2 X component * @param {Number} y2 Y component * @param {Number} z2 Z component * @param {Number} w2 W component * @returns {quat2} out * @function */ function set$1(out, x1, y1, z1, w1, x2, y2, z2, w2) { out[0] = x1; out[1] = y1; out[2] = z1; out[3] = w1; out[4] = x2; out[5] = y2; out[6] = z2; out[7] = w2; return out; } /** * Gets the real part of a dual quat * @param {quat} out real part * @param {ReadonlyQuat2} a Dual Quaternion * @return {quat} real part */ var getReal = copy$2; /** * Gets the dual part of a dual quat * @param {quat} out dual part * @param {ReadonlyQuat2} a Dual Quaternion * @return {quat} dual part */ function getDual(out, a) { out[0] = a[4]; out[1] = a[5]; out[2] = a[6]; out[3] = a[7]; return out; } /** * Set the real component of a dual quat to the given quaternion * * @param {quat2} out the receiving quaternion * @param {ReadonlyQuat} q a quaternion representing the real part * @returns {quat2} out * @function */ var setReal = copy$2; /** * Set the dual component of a dual quat to the given quaternion * * @param {quat2} out the receiving quaternion * @param {ReadonlyQuat} q a quaternion representing the dual part * @returns {quat2} out * @function */ function setDual(out, q) { out[4] = q[0]; out[5] = q[1]; out[6] = q[2]; out[7] = q[3]; return out; } /** * Gets the translation of a normalized dual quat * @param {vec3} out translation * @param {ReadonlyQuat2} a Dual Quaternion to be decomposed * @return {vec3} translation */ function getTranslation(out, a) { var ax = a[4], ay = a[5], az = a[6], aw = a[7], bx = -a[0], by = -a[1], bz = -a[2], bw = a[3]; out[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2; out[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2; out[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2; return out; } /** * Translates a dual quat by the given vector * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to translate * @param {ReadonlyVec3} v vector to translate by * @returns {quat2} out */ function translate(out, a, v) { var ax1 = a[0], ay1 = a[1], az1 = a[2], aw1 = a[3], bx1 = v[0] * 0.5, by1 = v[1] * 0.5, bz1 = v[2] * 0.5, ax2 = a[4], ay2 = a[5], az2 = a[6], aw2 = a[7]; out[0] = ax1; out[1] = ay1; out[2] = az1; out[3] = aw1; out[4] = aw1 * bx1 + ay1 * bz1 - az1 * by1 + ax2; out[5] = aw1 * by1 + az1 * bx1 - ax1 * bz1 + ay2; out[6] = aw1 * bz1 + ax1 * by1 - ay1 * bx1 + az2; out[7] = -ax1 * bx1 - ay1 * by1 - az1 * bz1 + aw2; return out; } /** * Rotates a dual quat around the X axis * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to rotate * @param {number} rad how far should the rotation be * @returns {quat2} out */ function rotateX(out, a, rad) { var bx = -a[0], by = -a[1], bz = -a[2], bw = a[3], ax = a[4], ay = a[5], az = a[6], aw = a[7], ax1 = ax * bw + aw * bx + ay * bz - az * by, ay1 = ay * bw + aw * by + az * bx - ax * bz, az1 = az * bw + aw * bz + ax * by - ay * bx, aw1 = aw * bw - ax * bx - ay * by - az * bz; rotateX$1(out, a, rad); bx = out[0]; by = out[1]; bz = out[2]; bw = out[3]; out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by; out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz; out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx; out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz; return out; } /** * Rotates a dual quat around the Y axis * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to rotate * @param {number} rad how far should the rotation be * @returns {quat2} out */ function rotateY(out, a, rad) { var bx = -a[0], by = -a[1], bz = -a[2], bw = a[3], ax = a[4], ay = a[5], az = a[6], aw = a[7], ax1 = ax * bw + aw * bx + ay * bz - az * by, ay1 = ay * bw + aw * by + az * bx - ax * bz, az1 = az * bw + aw * bz + ax * by - ay * bx, aw1 = aw * bw - ax * bx - ay * by - az * bz; rotateY$1(out, a, rad); bx = out[0]; by = out[1]; bz = out[2]; bw = out[3]; out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by; out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz; out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx; out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz; return out; } /** * Rotates a dual quat around the Z axis * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to rotate * @param {number} rad how far should the rotation be * @returns {quat2} out */ function rotateZ(out, a, rad) { var bx = -a[0], by = -a[1], bz = -a[2], bw = a[3], ax = a[4], ay = a[5], az = a[6], aw = a[7], ax1 = ax * bw + aw * bx + ay * bz - az * by, ay1 = ay * bw + aw * by + az * bx - ax * bz, az1 = az * bw + aw * bz + ax * by - ay * bx, aw1 = aw * bw - ax * bx - ay * by - az * bz; rotateZ$1(out, a, rad); bx = out[0]; by = out[1]; bz = out[2]; bw = out[3]; out[4] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by; out[5] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz; out[6] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx; out[7] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz; return out; } /** * Rotates a dual quat by a given quaternion (a * q) * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to rotate * @param {ReadonlyQuat} q quaternion to rotate by * @returns {quat2} out */ function rotateByQuatAppend(out, a, q) { var qx = q[0], qy = q[1], qz = q[2], qw = q[3], ax = a[0], ay = a[1], az = a[2], aw = a[3]; out[0] = ax * qw + aw * qx + ay * qz - az * qy; out[1] = ay * qw + aw * qy + az * qx - ax * qz; out[2] = az * qw + aw * qz + ax * qy - ay * qx; out[3] = aw * qw - ax * qx - ay * qy - az * qz; ax = a[4]; ay = a[5]; az = a[6]; aw = a[7]; out[4] = ax * qw + aw * qx + ay * qz - az * qy; out[5] = ay * qw + aw * qy + az * qx - ax * qz; out[6] = az * qw + aw * qz + ax * qy - ay * qx; out[7] = aw * qw - ax * qx - ay * qy - az * qz; return out; } /** * Rotates a dual quat by a given quaternion (q * a) * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat} q quaternion to rotate by * @param {ReadonlyQuat2} a the dual quaternion to rotate * @returns {quat2} out */ function rotateByQuatPrepend(out, q, a) { var qx = q[0], qy = q[1], qz = q[2], qw = q[3], bx = a[0], by = a[1], bz = a[2], bw = a[3]; out[0] = qx * bw + qw * bx + qy * bz - qz * by; out[1] = qy * bw + qw * by + qz * bx - qx * bz; out[2] = qz * bw + qw * bz + qx * by - qy * bx; out[3] = qw * bw - qx * bx - qy * by - qz * bz; bx = a[4]; by = a[5]; bz = a[6]; bw = a[7]; out[4] = qx * bw + qw * bx + qy * bz - qz * by; out[5] = qy * bw + qw * by + qz * bx - qx * bz; out[6] = qz * bw + qw * bz + qx * by - qy * bx; out[7] = qw * bw - qx * bx - qy * by - qz * bz; return out; } /** * Rotates a dual quat around a given axis. Does the normalisation automatically * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the dual quaternion to rotate * @param {ReadonlyVec3} axis the axis to rotate around * @param {Number} rad how far the rotation should be * @returns {quat2} out */ function rotateAroundAxis(out, a, axis, rad) { //Special case for rad = 0 if (Math.abs(rad) < EPSILON) { return copy$1(out, a); } var axisLength = Math.hypot(axis[0], axis[1], axis[2]); rad = rad * 0.5; var s = Math.sin(rad); var bx = s * axis[0] / axisLength; var by = s * axis[1] / axisLength; var bz = s * axis[2] / axisLength; var bw = Math.cos(rad); var ax1 = a[0], ay1 = a[1], az1 = a[2], aw1 = a[3]; out[0] = ax1 * bw + aw1 * bx + ay1 * bz - az1 * by; out[1] = ay1 * bw + aw1 * by + az1 * bx - ax1 * bz; out[2] = az1 * bw + aw1 * bz + ax1 * by - ay1 * bx; out[3] = aw1 * bw - ax1 * bx - ay1 * by - az1 * bz; var ax = a[4], ay = a[5], az = a[6], aw = a[7]; out[4] = ax * bw + aw * bx + ay * bz - az * by; out[5] = ay * bw + aw * by + az * bx - ax * bz; out[6] = az * bw + aw * bz + ax * by - ay * bx; out[7] = aw * bw - ax * bx - ay * by - az * bz; return out; } /** * Adds two dual quat's * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the first operand * @param {ReadonlyQuat2} b the second operand * @returns {quat2} out * @function */ function add$1(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; out[4] = a[4] + b[4]; out[5] = a[5] + b[5]; out[6] = a[6] + b[6]; out[7] = a[7] + b[7]; return out; } /** * Multiplies two dual quat's * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a the first operand * @param {ReadonlyQuat2} b the second operand * @returns {quat2} out */ function multiply$1(out, a, b) { var ax0 = a[0], ay0 = a[1], az0 = a[2], aw0 = a[3], bx1 = b[4], by1 = b[5], bz1 = b[6], bw1 = b[7], ax1 = a[4], ay1 = a[5], az1 = a[6], aw1 = a[7], bx0 = b[0], by0 = b[1], bz0 = b[2], bw0 = b[3]; out[0] = ax0 * bw0 + aw0 * bx0 + ay0 * bz0 - az0 * by0; out[1] = ay0 * bw0 + aw0 * by0 + az0 * bx0 - ax0 * bz0; out[2] = az0 * bw0 + aw0 * bz0 + ax0 * by0 - ay0 * bx0; out[3] = aw0 * bw0 - ax0 * bx0 - ay0 * by0 - az0 * bz0; out[4] = ax0 * bw1 + aw0 * bx1 + ay0 * bz1 - az0 * by1 + ax1 * bw0 + aw1 * bx0 + ay1 * bz0 - az1 * by0; out[5] = ay0 * bw1 + aw0 * by1 + az0 * bx1 - ax0 * bz1 + ay1 * bw0 + aw1 * by0 + az1 * bx0 - ax1 * bz0; out[6] = az0 * bw1 + aw0 * bz1 + ax0 * by1 - ay0 * bx1 + az1 * bw0 + aw1 * bz0 + ax1 * by0 - ay1 * bx0; out[7] = aw0 * bw1 - ax0 * bx1 - ay0 * by1 - az0 * bz1 + aw1 * bw0 - ax1 * bx0 - ay1 * by0 - az1 * bz0; return out; } /** * Alias for {@link quat2.multiply} * @function */ var mul$1 = multiply$1; /** * Scales a dual quat by a scalar number * * @param {quat2} out the receiving dual quat * @param {ReadonlyQuat2} a the dual quat to scale * @param {Number} b amount to scale the dual quat by * @returns {quat2} out * @function */ function scale$1(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; out[4] = a[4] * b; out[5] = a[5] * b; out[6] = a[6] * b; out[7] = a[7] * b; return out; } /** * Calculates the dot product of two dual quat's (The dot product of the real parts) * * @param {ReadonlyQuat2} a the first operand * @param {ReadonlyQuat2} b the second operand * @returns {Number} dot product of a and b * @function */ var dot$1 = dot$2; /** * Performs a linear interpolation between two dual quats's * NOTE: The resulting dual quaternions won't always be normalized (The error is most noticeable when t = 0.5) * * @param {quat2} out the receiving dual quat * @param {ReadonlyQuat2} a the first operand * @param {ReadonlyQuat2} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {quat2} out */ function lerp$1(out, a, b, t) { var mt = 1 - t; if (dot$1(a, b) < 0) t = -t; out[0] = a[0] * mt + b[0] * t; out[1] = a[1] * mt + b[1] * t; out[2] = a[2] * mt + b[2] * t; out[3] = a[3] * mt + b[3] * t; out[4] = a[4] * mt + b[4] * t; out[5] = a[5] * mt + b[5] * t; out[6] = a[6] * mt + b[6] * t; out[7] = a[7] * mt + b[7] * t; return out; } /** * Calculates the inverse of a dual quat. If they are normalized, conjugate is cheaper * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a dual quat to calculate inverse of * @returns {quat2} out */ function invert(out, a) { var sqlen = squaredLength$1(a); out[0] = -a[0] / sqlen; out[1] = -a[1] / sqlen; out[2] = -a[2] / sqlen; out[3] = a[3] / sqlen; out[4] = -a[4] / sqlen; out[5] = -a[5] / sqlen; out[6] = -a[6] / sqlen; out[7] = a[7] / sqlen; return out; } /** * Calculates the conjugate of a dual quat * If the dual quaternion is normalized, this function is faster than quat2.inverse and produces the same result. * * @param {quat2} out the receiving quaternion * @param {ReadonlyQuat2} a quat to calculate conjugate of * @returns {quat2} out */ function conjugate(out, a) { out[0] = -a[0]; out[1] = -a[1]; out[2] = -a[2]; out[3] = a[3]; out[4] = -a[4]; out[5] = -a[5]; out[6] = -a[6]; out[7] = a[7]; return out; } /** * Calculates the length of a dual quat * * @param {ReadonlyQuat2} a dual quat to calculate length of * @returns {Number} length of a * @function */ var length$1 = length$2; /** * Alias for {@link quat2.length} * @function */ var len$1 = length$1; /** * Calculates the squared length of a dual quat * * @param {ReadonlyQuat2} a dual quat to calculate squared length of * @returns {Number} squared length of a * @function */ var squaredLength$1 = squaredLength$2; /** * Alias for {@link quat2.squaredLength} * @function */ var sqrLen$1 = squaredLength$1; /** * Normalize a dual quat * * @param {quat2} out the receiving dual quaternion * @param {ReadonlyQuat2} a dual quaternion to normalize * @returns {quat2} out * @function */ function normalize$1(out, a) { var magnitude = squaredLength$1(a); if (magnitude > 0) { magnitude = Math.sqrt(magnitude); var a0 = a[0] / magnitude; var a1 = a[1] / magnitude; var a2 = a[2] / magnitude; var a3 = a[3] / magnitude; var b0 = a[4]; var b1 = a[5]; var b2 = a[6]; var b3 = a[7]; var a_dot_b = a0 * b0 + a1 * b1 + a2 * b2 + a3 * b3; out[0] = a0; out[1] = a1; out[2] = a2; out[3] = a3; out[4] = (b0 - a0 * a_dot_b) / magnitude; out[5] = (b1 - a1 * a_dot_b) / magnitude; out[6] = (b2 - a2 * a_dot_b) / magnitude; out[7] = (b3 - a3 * a_dot_b) / magnitude; } return out; } /** * Returns a string representation of a dual quaternion * * @param {ReadonlyQuat2} a dual quaternion to represent as a string * @returns {String} string representation of the dual quat */ function str$1(a) { return "quat2(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ", " + a[4] + ", " + a[5] + ", " + a[6] + ", " + a[7] + ")"; } /** * Returns whether the dual quaternions have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyQuat2} a the first dual quaternion. * @param {ReadonlyQuat2} b the second dual quaternion. * @returns {Boolean} true if the dual quaternions are equal, false otherwise. */ function exactEquals$1(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7]; } /** * Returns whether the dual quaternions have approximately the same elements in the same position. * * @param {ReadonlyQuat2} a the first dual quat. * @param {ReadonlyQuat2} b the second dual quat. * @returns {Boolean} true if the dual quats are equal, false otherwise. */ function equals$1(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4], a5 = a[5], a6 = a[6], a7 = a[7]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3], b4 = b[4], b5 = b[5], b6 = b[6], b7 = b[7]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)); } var quat2 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create$1, clone: clone$1, fromValues: fromValues$1, fromRotationTranslationValues: fromRotationTranslationValues, fromRotationTranslation: fromRotationTranslation, fromTranslation: fromTranslation, fromRotation: fromRotation, fromMat4: fromMat4, copy: copy$1, identity: identity, set: set$1, getReal: getReal, getDual: getDual, setReal: setReal, setDual: setDual, getTranslation: getTranslation, translate: translate, rotateX: rotateX, rotateY: rotateY, rotateZ: rotateZ, rotateByQuatAppend: rotateByQuatAppend, rotateByQuatPrepend: rotateByQuatPrepend, rotateAroundAxis: rotateAroundAxis, add: add$1, multiply: multiply$1, mul: mul$1, scale: scale$1, dot: dot$1, lerp: lerp$1, invert: invert, conjugate: conjugate, length: length$1, len: len$1, squaredLength: squaredLength$1, sqrLen: sqrLen$1, normalize: normalize$1, str: str$1, exactEquals: exactEquals$1, equals: equals$1 }); /** * 2 Dimensional Vector * @module vec2 */ /** * Creates a new, empty vec2 * * @returns {vec2} a new 2D vector */ function create() { var out = new ARRAY_TYPE(2); if (ARRAY_TYPE != Float32Array) { out[0] = 0; out[1] = 0; } return out; } /** * Creates a new vec2 initialized with values from an existing vector * * @param {ReadonlyVec2} a vector to clone * @returns {vec2} a new 2D vector */ function clone(a) { var out = new ARRAY_TYPE(2); out[0] = a[0]; out[1] = a[1]; return out; } /** * Creates a new vec2 initialized with the given values * * @param {Number} x X component * @param {Number} y Y component * @returns {vec2} a new 2D vector */ function fromValues(x, y) { var out = new ARRAY_TYPE(2); out[0] = x; out[1] = y; return out; } /** * Copy the values from one vec2 to another * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the source vector * @returns {vec2} out */ function copy(out, a) { out[0] = a[0]; out[1] = a[1]; return out; } /** * Set the components of a vec2 to the given values * * @param {vec2} out the receiving vector * @param {Number} x X component * @param {Number} y Y component * @returns {vec2} out */ function set(out, x, y) { out[0] = x; out[1] = y; return out; } /** * Adds two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function add(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; return out; } /** * Subtracts vector b from vector a * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function subtract(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; return out; } /** * Multiplies two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function multiply(out, a, b) { out[0] = a[0] * b[0]; out[1] = a[1] * b[1]; return out; } /** * Divides two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function divide(out, a, b) { out[0] = a[0] / b[0]; out[1] = a[1] / b[1]; return out; } /** * Math.ceil the components of a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to ceil * @returns {vec2} out */ function ceil(out, a) { out[0] = Math.ceil(a[0]); out[1] = Math.ceil(a[1]); return out; } /** * Math.floor the components of a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to floor * @returns {vec2} out */ function floor(out, a) { out[0] = Math.floor(a[0]); out[1] = Math.floor(a[1]); return out; } /** * Returns the minimum of two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function min(out, a, b) { out[0] = Math.min(a[0], b[0]); out[1] = Math.min(a[1], b[1]); return out; } /** * Returns the maximum of two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec2} out */ function max(out, a, b) { out[0] = Math.max(a[0], b[0]); out[1] = Math.max(a[1], b[1]); return out; } /** * Math.round the components of a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to round * @returns {vec2} out */ function round(out, a) { out[0] = Math.round(a[0]); out[1] = Math.round(a[1]); return out; } /** * Scales a vec2 by a scalar number * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the vector to scale * @param {Number} b amount to scale the vector by * @returns {vec2} out */ function scale(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; return out; } /** * Adds two vec2's after scaling the second operand by a scalar value * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @param {Number} scale the amount to scale b by before adding * @returns {vec2} out */ function scaleAndAdd(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; return out; } /** * Calculates the euclidian distance between two vec2's * * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {Number} distance between a and b */ function distance(a, b) { var x = b[0] - a[0], y = b[1] - a[1]; return Math.hypot(x, y); } /** * Calculates the squared euclidian distance between two vec2's * * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {Number} squared distance between a and b */ function squaredDistance(a, b) { var x = b[0] - a[0], y = b[1] - a[1]; return x * x + y * y; } /** * Calculates the length of a vec2 * * @param {ReadonlyVec2} a vector to calculate length of * @returns {Number} length of a */ function length(a) { var x = a[0], y = a[1]; return Math.hypot(x, y); } /** * Calculates the squared length of a vec2 * * @param {ReadonlyVec2} a vector to calculate squared length of * @returns {Number} squared length of a */ function squaredLength(a) { var x = a[0], y = a[1]; return x * x + y * y; } /** * Negates the components of a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to negate * @returns {vec2} out */ function negate(out, a) { out[0] = -a[0]; out[1] = -a[1]; return out; } /** * Returns the inverse of the components of a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to invert * @returns {vec2} out */ function inverse(out, a) { out[0] = 1.0 / a[0]; out[1] = 1.0 / a[1]; return out; } /** * Normalize a vec2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a vector to normalize * @returns {vec2} out */ function normalize(out, a) { var x = a[0], y = a[1]; var len = x * x + y * y; if (len > 0) { //TODO: evaluate use of glm_invsqrt here? len = 1 / Math.sqrt(len); } out[0] = a[0] * len; out[1] = a[1] * len; return out; } /** * Calculates the dot product of two vec2's * * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {Number} dot product of a and b */ function dot(a, b) { return a[0] * b[0] + a[1] * b[1]; } /** * Computes the cross product of two vec2's * Note that the cross product must by definition produce a 3D vector * * @param {vec3} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @returns {vec3} out */ function cross(out, a, b) { var z = a[0] * b[1] - a[1] * b[0]; out[0] = out[1] = 0; out[2] = z; return out; } /** * Performs a linear interpolation between two vec2's * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the first operand * @param {ReadonlyVec2} b the second operand * @param {Number} t interpolation amount, in the range [0-1], between the two inputs * @returns {vec2} out */ function lerp(out, a, b, t) { var ax = a[0], ay = a[1]; out[0] = ax + t * (b[0] - ax); out[1] = ay + t * (b[1] - ay); return out; } /** * Generates a random vector with the given scale * * @param {vec2} out the receiving vector * @param {Number} [scale] Length of the resulting vector. If omitted, a unit vector will be returned * @returns {vec2} out */ function random(out, scale) { scale = scale === undefined ? 1.0 : scale; var r = RANDOM() * 2.0 * Math.PI; out[0] = Math.cos(r) * scale; out[1] = Math.sin(r) * scale; return out; } /** * Transforms the vec2 with a mat2 * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the vector to transform * @param {ReadonlyMat2} m matrix to transform with * @returns {vec2} out */ function transformMat2(out, a, m) { var x = a[0], y = a[1]; out[0] = m[0] * x + m[2] * y; out[1] = m[1] * x + m[3] * y; return out; } /** * Transforms the vec2 with a mat2d * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the vector to transform * @param {ReadonlyMat2d} m matrix to transform with * @returns {vec2} out */ function transformMat2d(out, a, m) { var x = a[0], y = a[1]; out[0] = m[0] * x + m[2] * y + m[4]; out[1] = m[1] * x + m[3] * y + m[5]; return out; } /** * Transforms the vec2 with a mat3 * 3rd vector component is implicitly '1' * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the vector to transform * @param {ReadonlyMat3} m matrix to transform with * @returns {vec2} out */ function transformMat3(out, a, m) { var x = a[0], y = a[1]; out[0] = m[0] * x + m[3] * y + m[6]; out[1] = m[1] * x + m[4] * y + m[7]; return out; } /** * Transforms the vec2 with a mat4 * 3rd vector component is implicitly '0' * 4th vector component is implicitly '1' * * @param {vec2} out the receiving vector * @param {ReadonlyVec2} a the vector to transform * @param {ReadonlyMat4} m matrix to transform with * @returns {vec2} out */ function transformMat4(out, a, m) { var x = a[0]; var y = a[1]; out[0] = m[0] * x + m[4] * y + m[12]; out[1] = m[1] * x + m[5] * y + m[13]; return out; } /** * Rotate a 2D vector * @param {vec2} out The receiving vec2 * @param {ReadonlyVec2} a The vec2 point to rotate * @param {ReadonlyVec2} b The origin of the rotation * @param {Number} rad The angle of rotation in radians * @returns {vec2} out */ function rotate(out, a, b, rad) { //Translate point to the origin var p0 = a[0] - b[0], p1 = a[1] - b[1], sinC = Math.sin(rad), cosC = Math.cos(rad); //perform rotation and translate to correct position out[0] = p0 * cosC - p1 * sinC + b[0]; out[1] = p0 * sinC + p1 * cosC + b[1]; return out; } /** * Get the angle between two 2D vectors * @param {ReadonlyVec2} a The first operand * @param {ReadonlyVec2} b The second operand * @returns {Number} The angle in radians */ function angle(a, b) { var x1 = a[0], y1 = a[1], x2 = b[0], y2 = b[1], // mag is the product of the magnitudes of a and b mag = Math.sqrt((x1 * x1 + y1 * y1) * (x2 * x2 + y2 * y2)), // mag &&.. short circuits if mag == 0 cosine = mag && (x1 * x2 + y1 * y2) / mag; // Math.min(Math.max(cosine, -1), 1) clamps the cosine between -1 and 1 return Math.acos(Math.min(Math.max(cosine, -1), 1)); } /** * Set the components of a vec2 to zero * * @param {vec2} out the receiving vector * @returns {vec2} out */ function zero(out) { out[0] = 0.0; out[1] = 0.0; return out; } /** * Returns a string representation of a vector * * @param {ReadonlyVec2} a vector to represent as a string * @returns {String} string representation of the vector */ function str(a) { return "vec2(" + a[0] + ", " + a[1] + ")"; } /** * Returns whether the vectors exactly have the same elements in the same position (when compared with ===) * * @param {ReadonlyVec2} a The first vector. * @param {ReadonlyVec2} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function exactEquals(a, b) { return a[0] === b[0] && a[1] === b[1]; } /** * Returns whether the vectors have approximately the same elements in the same position. * * @param {ReadonlyVec2} a The first vector. * @param {ReadonlyVec2} b The second vector. * @returns {Boolean} True if the vectors are equal, false otherwise. */ function equals(a, b) { var a0 = a[0], a1 = a[1]; var b0 = b[0], b1 = b[1]; return Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)); } /** * Alias for {@link vec2.length} * @function */ var len = length; /** * Alias for {@link vec2.subtract} * @function */ var sub = subtract; /** * Alias for {@link vec2.multiply} * @function */ var mul = multiply; /** * Alias for {@link vec2.divide} * @function */ var div = divide; /** * Alias for {@link vec2.distance} * @function */ var dist = distance; /** * Alias for {@link vec2.squaredDistance} * @function */ var sqrDist = squaredDistance; /** * Alias for {@link vec2.squaredLength} * @function */ var sqrLen = squaredLength; /** * Perform some operation over an array of vec2s. * * @param {Array} a the array of vectors to iterate over * @param {Number} stride Number of elements between the start of each vec2. If 0 assumes tightly packed * @param {Number} offset Number of elements to skip at the beginning of the array * @param {Number} count Number of vec2s to iterate over. If 0 iterates over entire array * @param {Function} fn Function to call for each vector in the array * @param {Object} [arg] additional argument to pass to fn * @returns {Array} a * @function */ var forEach = function () { var vec = create(); return function (a, stride, offset, count, fn, arg) { var i, l; if (!stride) { stride = 2; } if (!offset) { offset = 0; } if (count) { l = Math.min(count * stride + offset, a.length); } else { l = a.length; } for (i = offset; i < l; i += stride) { vec[0] = a[i]; vec[1] = a[i + 1]; fn(vec, vec, arg); a[i] = vec[0]; a[i + 1] = vec[1]; } return a; }; }(); var vec2 = /*#__PURE__*/Object.freeze({ __proto__: null, create: create, clone: clone, fromValues: fromValues, copy: copy, set: set, add: add, subtract: subtract, multiply: multiply, divide: divide, ceil: ceil, floor: floor, min: min, max: max, round: round, scale: scale, scaleAndAdd: scaleAndAdd, distance: distance, squaredDistance: squaredDistance, length: length, squaredLength: squaredLength, negate: negate, inverse: inverse, normalize: normalize, dot: dot, cross: cross, lerp: lerp, random: random, transformMat2: transformMat2, transformMat2d: transformMat2d, transformMat3: transformMat3, transformMat4: transformMat4, rotate: rotate, angle: angle, zero: zero, str: str, exactEquals: exactEquals, equals: equals, len: len, sub: sub, mul: mul, div: div, dist: dist, sqrDist: sqrDist, sqrLen: sqrLen, forEach: forEach }); exports.glMatrix = common; exports.mat2 = mat2; exports.mat2d = mat2d; exports.mat3 = mat3; exports.mat4 = mat4; exports.quat = quat; exports.quat2 = quat2; exports.vec2 = vec2; exports.vec3 = vec3; exports.vec4 = vec4; Object.defineProperty(exports, '__esModule', { value: true }); })); ================================================ FILE: dev-utils/scripts/icn-regex-matching.ts ================================================ // dev-utils/scripts/icn-regex-matching.ts /** * This stores a monster regex I made for matching ICN. * * It has a big issue. When matching ICNs with over ~2M pieces, * we'll get a stack overflow error. Fundamental Regex issue, * regex isn't built for handling such large strings. */ // Construct the MONSTER ICN regex! /** * Delimiter between all ICN parts. * Matches whitespace OR the end of the ICN. */ const delimiter = String.raw`(?:\s+|(?=$))`; // const delimiter = String.raw`\s+`; // Matches only whitespace /** Matches an entire ICN, capturing with named groups. */ const ICNRegex = new RegExp( // If any ICN section match is found, whitespace is required immediately after them. String.raw`^\s*` + // Start of the string possessive(String.raw`(?:(?${getSingleMetadataSource(false)}(?:\s+${getSingleMetadataSource(false)})*)${delimiter})?`) + // Captures all metadata into one string possessive(String.raw`(?:(?${raw_piece_code_regex_source}(?::${raw_piece_code_regex_source})*)${delimiter})?`) + possessive(String.raw`(?:(?${coordsKeyRegexSource})${delimiter})?`) + possessive(String.raw`(?:(?${wholeNumberSource}\/${countingNumberSource})${delimiter})?`) + possessive(String.raw`(?:(?${countingNumberSource})${delimiter})?`) + possessive(String.raw`(?:${promotionsRegexSource}${delimiter})?`) + possessive(String.raw`(?:${winConditionRegexSource}${delimiter})?`) + possessive(String.raw`(?:(?${positionRegexSource})${delimiter})?`) + // Captures the whole position in one string possessive(String.raw`(?${movesRegexSource})?`) + // Captures all moves in one string String.raw`\s*$` // End of the string ); console.log("ICNRegex:", ICNRegex); /** * Converts a string in Infinite Chess Notation to game in JSON format. * * Throws an error if it's in an invalid format, or if required sections are missing. */ function ShortToLong_Format(icn: string): LongFormatOut { console.log("Start match..."); const matches = icn.match(ICNRegex); console.log("Done matching!"); if (matches === null) throw new Error("ICN is in an invalid format! " + icn); const groups = matches.groups!; const metadata: Record = {}; if (groups['metadata']) { const metadataMatches = groups['metadata'].matchAll(new RegExp(getSingleMetadataSource(true), 'g')); for (const match of metadataMatches) { const key = match[1]!; const value = match[2]!; metadata[key] = value; } } let turnOrder: Player[] = defaults.turnOrder; if (groups['turnOrder']) { // console.log(`Turn Order: (${groups['turnOrder']})}`); // Substitues if (groups['turnOrder'] === 'w') groups['turnOrder'] = 'w:b'; // 'w' is short for 'w:b' else if (groups['turnOrder'] === 'b') groups['turnOrder'] = 'b:w'; // 'b' is short for 'b:w' const turnOrderArray = groups['turnOrder'].split(':'); // ['w','b'] turnOrder = [...turnOrderArray.map(p_code => { if (!(p_code in player_codes_inverted)) throw Error(`Unknown player code (${p_code}) when parsing turn order of ICN! Turn order (${groups['turnOrder']})`); return Number(player_codes_inverted[p_code]); })] as Player[]; // [1,2] } let enpassant: EnPassant | undefined; if (groups['enpassant']) { const coords = coordutil.getCoordsFromKey(groups['enpassant'] as CoordsKey); const lastTurn = turnOrder[turnOrder.length - 1]; const yParity = lastTurn === p.WHITE ? 1 : lastTurn === p.BLACK ? -1 : (() => { throw new Error(`Invalid last turn (${lastTurn}) when parsing enpassant in ICN!`); })(); enpassant = { square: coords, pawn: [coords[0], coords[1] + yParity] }; } let moveRule: number | undefined; let moveRuleState: number | undefined; if (groups['moveRule']) { const [_moveRuleState, _moveRule] = groups['moveRule'].split('/').map(Number); if (_moveRuleState! > _moveRule!) throw Error(`Invalid move rule (${groups['moveRule']}) when parsing ICN!`); moveRule = _moveRule; moveRuleState = _moveRuleState; } let fullMove: number = defaults.fullMove; if (groups['fullMove']) fullMove = Number(groups['fullMove']); let promotionRanks: PlayerGroup | undefined; let promotionsAllowed: PlayerGroup | undefined; if (groups['promotions']) { // '8,16,24,32;q,r,b,n|1,9,17,25;q,r,b,n' const _promotionRanks: PlayerGroup = {}; const _promotionsAllowed: PlayerGroup = {}; const promotions = groups['promotions'].split('|'); // ['8,16,24,32;q,r,b,n','1,9,17,25;q,r,b,n'] // Make sure the number of promotions matches the number of players if (promotions.length !== turnOrder.length) throw new Error(`Number of promotions (${promotions.length}) does not match number of players (${turnOrder.length})!`); for (const player of turnOrder) { const playerPromotions = promotions.shift()!; // '8,16,24,32;q,r,b,n' if (playerPromotions === '') continue; // Player has no promotions. Maybe promotions were "(8|)" const [ranks, allowed] = playerPromotions.split(';'); // The allowed section is optional _promotionRanks[player] = ranks!.split(',').map(Number); _promotionsAllowed[player] = allowed ? allowed.split(',').map(raw => Number(piece_codes_raw_inverted[raw]) as RawType) : default_promotions; } promotionRanks = _promotionRanks; promotionsAllowed = _promotionsAllowed; } let winConditions: PlayerGroup = defaults.winConditions; if (groups['winConditions']) { // 'checkmate,checkmate|allpiecescaptured' const winConStrings = groups['winConditions'].split('|'); // ['checkmate','checkmate|allpiecescaptured'] const _winConditions: PlayerGroup = {}; // If winConStrings.length is 1, all players have the same win conditions if (winConStrings.length === 1) { const winConArray = winConStrings[0]!.split(','); // ['checkmate','allpiecescaptured'] for (const player of turnOrder) { _winConditions[player] = [...winConArray]; } } else { // Each player has their own win conditions // Make sure the number of win conditions matches the number of players if (winConStrings.length !== turnOrder.length) throw new Error(`Number of win conditions (${winConStrings.length}) does not match number of players (${turnOrder.length})!`); for (const player of turnOrder) { const winConString = winConStrings.shift()!; _winConditions[player] = winConString.split(','); // ['checkmate','allpiecescaptured'] } } winConditions = _winConditions; } let position: Map | undefined; let specialRights: Set | undefined; if (groups['position']) { ({ position, specialRights } = generatePositionFromShortForm(groups['position'])); } else { // Position not specified. We then require the metadata: Variant, UTCDate, and UTCTime if (!metadata['Variant'] || !metadata['UTCDate'] || !metadata['UTCTime']) throw Error("ICN's Variant, UTCDate, and UTCTime must be specified when no position specified."); // Could optionally get the position from variant.ts, but probably not a responsibility of icnconverter // ({ position, specialRights } = variant.getStartingPositionOfVariant({ Variant: metadata['Variant'], UTCDate: metadata['UTCDate'], UTCTime: metadata['UTCTime'] })); } let moves: MoveParsed[] | undefined; if (groups['moves']) moves = parseShortFormMoves(groups['moves']); // =================================== Return the game object =================================== const gameRules: GameRules = { turnOrder, winConditions, }; if (moveRule) gameRules.moveRule = moveRule; if (promotionRanks) gameRules.promotionRanks = promotionRanks; if (promotionsAllowed) gameRules.promotionsAllowed = promotionsAllowed; const state_global: Partial = {}; if (specialRights) state_global.specialRights = specialRights; if (enpassant) state_global.enpassant = enpassant; if (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState; const game: LongFormatOut = { metadata: metadata as unknown as MetaData, gameRules, fullMove, state_global }; if (position) game.position = position; if (moves) game.moves = moves; console.log("Parced ICN: ", jsutil.deepCopyObject(game)); return game; } ================================================ FILE: dev-utils/scripts/meshSimplification.ts ================================================ /** * This stores a mesh simplification algorithm Naviary designed to simplify the void mesh. * * It can't be used anymore since a board editor may dynamically add and remove voids all the time. * We would have to regenerate the mesh every time. */ /** * Simplifies a list of void squares and merges them into larger rectangles. * @param voidList - The list of coordinates where all the voids are * @returns An array of rectangles that look like: `{ left, right, bottom, top }`. */ function simplifyMesh(voidList: PooledArray): BoundingBox[] { // array of coordinates // console.log("Simplifying void mesh..") const voidHash: { [coordsKey: CoordsKey]: true } = {}; for (const thisVoid of voidList) { if (!thisVoid) continue; const key = coordutil.getKeyFromCoords(thisVoid); voidHash[key] = true; } const rectangles: BoundingBox[] = []; // rectangle: { left, right, bottom, top } const alreadyMerged: { [coordsKey: CoordsKey]: true } = { }; // Set the coordinate key `x,y` to true when a void has been merged for (const thisVoid of voidList) { // [x,y] if (!thisVoid) continue; // Has this void already been merged with another previous? const key = coordutil.getKeyFromCoords(thisVoid); if (alreadyMerged[key]) continue; // Next void alreadyMerged[key] = true; // Set this void to true for next iteration let left = thisVoid[0]; let right = thisVoid[0]; let bottom = thisVoid[1]; let top = thisVoid[1]; let width = 1; let height = 1; let foundNeighbor = true; while (foundNeighbor) { // Keep expanding while successful // First test left neighbors let potentialMergers: CoordsKey[] = []; let allNeighborsAreVoid = true; let testX = left - 1; for (let a = 0; a < height; a++) { // Start from bottom and go up const thisTestY = bottom + a; const thisCoord: Coords = [testX, thisTestY]; const thisKey = coordutil.getKeyFromCoords(thisCoord); const isVoid = voidHash[thisKey]; if (!isVoid || alreadyMerged[thisKey]) { allNeighborsAreVoid = false; break; // Can't merge } potentialMergers.push(thisKey); // Can merge } if (allNeighborsAreVoid) { left = testX; // Merge! width++; // Add all the merged squares to the already-merged list potentialMergers.forEach(key => { alreadyMerged[key] = true; }); continue; } // Next test right neighbors potentialMergers = []; allNeighborsAreVoid = true; testX = right + 1; for (let a = 0; a < height; a++) { // Start from bottom and go up const thisTestY = bottom + a; const thisCoord: Coords = [testX, thisTestY]; const thisKey = coordutil.getKeyFromCoords(thisCoord); const isVoid = voidHash[thisKey]; if (!isVoid || alreadyMerged[thisKey]) { allNeighborsAreVoid = false; break; // Can't merge } potentialMergers.push(thisKey); // Can merge } if (allNeighborsAreVoid) { right = testX; // Merge! width++; // Add all the merged squares to the already-merged list potentialMergers.forEach(key => { alreadyMerged[key] = true; }); continue; } // Next test bottom neighbors potentialMergers = []; allNeighborsAreVoid = true; let testY = bottom - 1; for (let a = 0; a < width; a++) { // Start from bottom and go up const thisTestX = left + a; const thisCoord: Coords = [thisTestX, testY]; const thisKey = coordutil.getKeyFromCoords(thisCoord); const isVoid = voidHash[thisKey]; if (!isVoid || alreadyMerged[thisKey]) { allNeighborsAreVoid = false; break; // Can't merge } potentialMergers.push(thisKey); // Can merge } if (allNeighborsAreVoid) { bottom = testY; // Merge! height++; // Add all the merged squares to the already-merged list potentialMergers.forEach(key => { alreadyMerged[key] = true; }); continue; } // Next test top neighbors potentialMergers = []; allNeighborsAreVoid = true; testY = top + 1; for (let a = 0; a < width; a++) { // Start from bottom and go up const thisTestX = left + a; const thisCoord: Coords = [thisTestX, testY]; const thisKey = coordutil.getKeyFromCoords(thisCoord); const isVoid = voidHash[thisKey]; if (!isVoid || alreadyMerged[thisKey]) { allNeighborsAreVoid = false; break; // Can't merge } potentialMergers.push(thisKey); // Can merge } if (allNeighborsAreVoid) { top = testY; // Merge! height++; // Add all the merged squares to the already-merged list potentialMergers.forEach(key => { alreadyMerged[key] = true; }); continue; } foundNeighbor = false; // Cannot expand this rectangle! Stop searching } const rectangle: BoundingBox = { left, right, bottom, top }; rectangles.push(rectangle); } // We now have a filled rectangles variable return rectangles; } ================================================ FILE: dev-utils/scripts/positionnormalizer/moveexpander.ts ================================================ // src/client/scripts/esm/chess/logic/positionnormalizer/moveexpander.ts /** * This script takes a chosen move from analyzing a COMPRESSED/NORMALIZED position * by positioncompressor.ts, and the transformation information of the position, * and expands the move out so it can be applied to the original UNCOMPRESSED position. */ import type { Coords } from "../movesets.js"; import type { MoveCoords } from "../icn/icnconverter.js"; import bd from "../../../util/bigdecimal/bigdecimal.js"; import geometry from "../../../util/math/geometry.js"; import coordutil, { BDCoords } from "../../util/coordutil.js"; import vectors, { LineCoefficients, Vec2, Vec2Key } from "../../../util/math/vectors.js"; import positioncompressor, { AxisOrders, PieceTransform } from "./positioncompressor.js"; // ================================== MOVE EXPANDER ================================== /** * Takes a move that should have been calculated from the compressed position, * and modifies its start and end coords so that it moves the original * uncompressed position's piece, and so its destination coordinates still * threaten all the same original pieces. * @param compressedPosition - The original uncompressed position * @param move - The decided upon move based on the compressed position */ function expandMove(AllAxisOrders: AxisOrders, pieceTransformations: PieceTransform[], move: MoveCoords): MoveCoords { const startCoordsBigInt: Coords = [BigInt(move.startCoords[0]), BigInt(move.startCoords[1])]; const endCoordsBigInt: Coords = [BigInt(move.endCoords[0]), BigInt(move.endCoords[1])]; // Determine the piece's original position const originalPiece = pieceTransformations.find((pt) => coordutil.areCoordsEqual(startCoordsBigInt, pt.transformedCoords as Coords)); if (originalPiece === undefined) throw Error(`Compressed position's pieces doesn't include the moved piece on coords ${String(move.startCoords)}! Were we sure to choose a move based on the compressed position and not the original?`); /** The true start coordinates of the piece they moved. */ const originalStartCoords: Coords = originalPiece.coords; // EASY! This is already given /** * Determine the piece's intended destination square. * * How do we do that? * * Determine if the piece is targetting any specific axis group. * We can then calculate the intersection of its movement vector * and the direction towards that group to determine its intended destination. * * For now there aren't any gaps between groups, so it can't target an arbitrary * opening between gaps, its always got to be a little to the left or right of a group. * However, they can move arbitrarily far fast the farthest group, * so we will just move it the same distance it wanted to. */ // Did it capture a piece? const capturedTransformedPiece = pieceTransformations.find((pt) => coordutil.areCoordsEqual(pt.transformedCoords as Coords, endCoordsBigInt)); if (capturedTransformedPiece) { // EASY! Return the captured piece's original coords return { startCoords: originalStartCoords, endCoords: capturedTransformedPiece.coords }; } // It didn't capture any piece // This is a little more complicated. But we will attach it to the nearest axis group. /** The direction the piece moved in. We KNOW this is preserved when expanding back out! */ const vector: Vec2 = vectors.absVector(vectors.normalizeVector(coordutil.subtractCoords(endCoordsBigInt, startCoordsBigInt))); const vec2Key: Vec2Key = vectors.getKeyFromVec2(vector); // console.log("Original start coords:", originalStartCoords); // console.log("Movement vector:", vector); const movementLine: LineCoefficients = vectors.getLineGeneralFormFromCoordsAndVec(originalStartCoords, vector); // Half the distance between groups so that we can pick the nearest one threatened. const HALF_ARBITRARY_DISTANCE = positioncompressor.MIN_ARBITRARY_DISTANCE / 2n; /** The true end coordinates they want to move to. */ let originalEndCoords: Coords | undefined; // Search each axis group besides the direction it moved in // Skip if our movement is perpendicular to that axis, // its impossible for us to increase our axis value along it // => not interested in threatening any of those groups. if (vec2Key !== '0,1') determineIfMovedPieceInterestedInAxis('1,0'); if (vec2Key !== '1,0') determineIfMovedPieceInterestedInAxis('0,1'); /** * Determines if the moved piece is interested in any group in the given axis. * If so, its final destination will still be relative to that group. */ function determineIfMovedPieceInterestedInAxis(axis: '1,0' | '0,1') { if (originalEndCoords) { console.log(`Moved piece already has end coords determined. Skipping axis ${axis}.`); return; // We already found the original end coords, no need to continue } const axisOrder = AllAxisOrders[axis]; const axisValueDeterminer = positioncompressor.AXIS_DETERMINERS[axis]; const compressedEndCoordsAxisValue = axisValueDeterminer(endCoordsBigInt); // console.log("compressedEndCoordsAxisValue:", compressedEndCoordsAxisValue); // console.log('endCoords bigint:', endCoordsBigInt); for (const axisGroup of axisOrder) { if (compressedEndCoordsAxisValue + HALF_ARBITRARY_DISTANCE >= axisGroup.transformedRange![0] && compressedEndCoordsAxisValue - HALF_ARBITRARY_DISTANCE <= axisGroup.transformedRange![1]) { // We found the group of interest this piece is targetting! console.log(`Moved piece is interested in group on the ${axis} axis with range ${axisGroup.transformedRange}. Original range ${axisGroup.range}`); // The piece is on the same file as this axis group, so connect it to this axis group // so its position remains relative to them when the position is expanded back out. const offsetFromGroupStart = compressedEndCoordsAxisValue - axisGroup.transformedRange![0]; const actualEndCoordsAxisValue = axisGroup.range[0] + offsetFromGroupStart; // console.log('offsetFromGroupStart:', offsetFromGroupStart); // console.log('actualEndCoordsAxisValue:', actualEndCoordsAxisValue); // The ACTUAL coordinates they moved to! originalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue); break; } } if (!originalEndCoords) { // They didn't specifically target any group. // They must have moved further left or right than any group. if (compressedEndCoordsAxisValue + HALF_ARBITRARY_DISTANCE < axisOrder[0].transformedRange![0]) { // They moved left of the leftmost group console.log(`Moved piece wants to move left of the leftmost group on the ${axis} axis.`); const distToLeftMostGroup = compressedEndCoordsAxisValue - axisOrder[0]!.transformedRange![0]; const actualEndCoordsAxisValue = axisOrder[0]!.range[0] + distToLeftMostGroup; // The ACTUAL coordinates they moved to! originalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue); } else if (compressedEndCoordsAxisValue - HALF_ARBITRARY_DISTANCE > axisOrder[axisOrder.length - 1]!.transformedRange![1]) { // They moved right of the rightmost group console.log(`Moved piece wants to move right of the rightmost group on the ${axis} axis.`); const distToRightMostGroup = compressedEndCoordsAxisValue - axisOrder[axisOrder.length - 1]!.transformedRange![1]; const actualEndCoordsAxisValue = axisOrder[axisOrder.length - 1]!.range[1] + distToRightMostGroup; // The ACTUAL coordinates they moved to! originalEndCoords = trueEndCoordsDeterminer(movementLine, axis, actualEndCoordsAxisValue); } else { console.log(`Moved piece is not interested in any groups on the ${axis} axis.`); console.log('compressedEndCoordsAxisValue:', compressedEndCoordsAxisValue); } } } if (!originalEndCoords) throw Error("Unable to determine the original end coordinates of the moved piece! "); return { startCoords: originalStartCoords, endCoords: originalEndCoords }; } /** * Takes the movement line of the moved piece, the axis it is interested in, * the axis value of the axis group it is interested in, * and determines the true end coordinates it wants to land on * in the original uncompressed position. */ function trueEndCoordsDeterminer(movementLine: LineCoefficients, axisOfInterest: '1,0' | '0,1', targetAxisValue: bigint): Coords { // console.log("Determining true end coords for axis:", axisOfGroupOfInterest, " with target axis value:", targetAxisValue); const axisPerpendicularVec: Vec2 = vectors.getPerpendicularVector(vectors.getVec2FromKey(axisOfInterest)); // I need to find the intersection point between the movement line, // and the line of vector axisPerpendicularVec with the targetAxisValue. // First determine the axisPerpendicularVec line with the targetAxisValue. let intersectionLine: LineCoefficients; if (axisOfInterest === '1,0') { // The line is vertical, so the x coordinate is targetAxisValue intersectionLine = vectors.getLineGeneralFormFromCoordsAndVec([targetAxisValue, 0n], axisPerpendicularVec); } else if (axisOfInterest === '0,1') { // The line is horizontal, so the y coordinate is targetAxisValue intersectionLine = vectors.getLineGeneralFormFromCoordsAndVec([0n, targetAxisValue], axisPerpendicularVec); } else throw Error(`Unknown axis of group of interest: ${axisOfInterest}`); // console.log("movementLine:", movementLine); // console.log("intersectionLine:", intersectionLine); // Now find the intersection point between the movement line and the intersection line. const intersectionPoint: BDCoords | undefined = geometry.calcIntersectionPointOfLines(...movementLine, ...intersectionLine); if (!intersectionPoint) throw Error(`Unable to find intersection point between movement line and group of interest!`); if (!bd.areCoordsIntegers(intersectionPoint)) throw Error(`Intersection point between movement line and group of interest is not an integer coordinate!`); return bd.coordsToBigInt(intersectionPoint); } // ================================== EXPORTS ================================== export default { expandMove, }; ================================================ FILE: dev-utils/scripts/positionnormalizer/normalizertester.ts ================================================ // dev-utils/scripts/positionnormalizer/normalizertester.ts /** * ONLY FOR TESTING COMPRESSING POSITIONS */ // ================================ Testing Usage ================================ import moveexpander from "./moveexpander"; import positioncompressor from "./positioncompressor"; import icnconverter, { MoveCoords } from "../icn/icnconverter"; const example_position = '[Event "Casual local Classical infinite chess game"] [Site "https://www.infinitechess.org/"] [Variant "Classical"] [Round "-"] [UTCDate "2023.11.01"] [UTCTime "12:02:58"] [TimeControl "-"] [Result "0-1"] [Termination "Checkmate"] b 1/100 21 (8|1) P1,2+|P8,2+|p1,7+|p2,7+|p7,7+|p8,7+|R8,1+|r1,8+|N7,1|P5,4|p6,6|k6,7|K6,2|r6,8|n7,8|p5,6|P7,4|b5,10|n5,5|q-35694371,-35694371|B-114930749,114930754 '; // const example_position = 'K0,0|q-800,1200|N300,-1800|B-1800,100|r600,200|R-520,-340|P900,-50|b-1100,700|n220,330|Q-1500,-1200|k-7000,5000|R9400,300|r-2700,-8800|B-500,9800|b1500,-9600|Q-9300,1500|q8200,-3600|N-9800,-600|n9900,-400|P-9200,3800'; // const example_position = 'Q-1214,8032|n-594,9261|R4939,1877|B-2227,-3463|b-6210,553|q-8440,1848|N6323,-2171|r8431,671|n-3601,-7208|B4522,209|R-8722,-9556|Q-4978,-100|b1854,-9810|N5564,4021|q2312,-1722|r-6410,9360|n2938,-831|B-7724,-2190|Q9019,3540|R-1125,-6378'; // const example_position = 'Q-120,850|n-125,858|B4200,-7320|b4207,-7313|R-7821,5110|r-7815,5118|q9012,-442|N-311,-9980|n-318,-9989|B7345,1442|b7336,1436|R-2599,-6288'; // Heart // const example_position = 'R-42,118|b133,-55|N-210,305|q87,192|n-166,-211|B249,-315|Q-321,88|r-140,-388|B422,-76|n355,301|b-291,-422|Q315,94|R-388,255|q298,-154|N4200,-3900|r-5600,3188|B7120,-2981|n-8441,1210|b9822,-4033|Q-9331,6120'; // Julia set was always working. // const example_position = 'Q-1214,8032|R4939,1877|N6323,-2171|n-3601,-7208|B4522,209|q2312,-1722|r-6410,9360'; // const example_position = 'Q-9032,1442|B3841,-6672|R-7210,5142|q912,8475|B-6112,2033|R-1278,-9880|Q-4468,755'; // Infinity repetition triangle FORCED TO calculate group's error against all other pieces! // const example_position = 'k0,0|R1200,800|R-1500,-600|R900,-1300|R-700,1100|R300,-1300|R2000,0|R900,2100|R0,2300|R-2200,-2200|R2000,-600|R8000,12000|R-15000,4000|R18000,-6000|R-13000,-16000|R10000,9000|R-9500,14500|R12000,-18000|R-8000,-12000|R19000,2100|R-20000,-600|R905,-1295|R1204,804|R-1504,-596|R295,-1304|R-705,1097'; // Orthogonal test // const example_position = 'k0,0|R900,-1300|R-700,1100|R300,-1300|R2000,0|R2000,-600|R1204,804'; // Orthogonal test // const example_position = 'k0,0|R1200,800|R-1500,-600|R-700,1100|R2000,0|R900,2100|R0,2300|R2000,-600|R-1504,-596'; // House // const example_position = 'k0,0|R900,2100|R0,2300|R18000,-6000|R12000,-18000|R1204,804|R295,-1304|R-705,1097'; // const example_position = 'k0,0|Q-10000,5000|R-20000,1000|R-20000,2000|R-20000,3000|R-20000,4000'; // Diagonal test // const example_position = 'K0,0|q834,1191|R-2240,6303|n4201,-889|b-1719,-8260|Q9329,-214'; // 5 random pieces // const example_position = 'K0,0|q834,1191|R-2240,6303'; // 3 pieces of above // const example_position = 'K0,0|q-150,150|R-30,60|r-30,64|R-30,120'; // const example_position = 'K0,0|R-30,60|r-30,65|R-30,120'; // const example_position = 'K0,0|q-150,150|R-30,60|r-30,90|R-30,120'; // 3 rooks in between // const example_position = 'K0,0|q-110,125|R-30,60|r-30,90'; // 2 rooks in between, queen WAY up // const example_position = 'b-140,-30|K0,0|q-120,125|R-30,60|r-30,90'; // Same as below but with an extra bishop // const example_position = 'K0,0|q-120,125|R-30,60|r-30,90'; // 2 rooks in between, queen 5 up // const example_position = 'K0,0|q-125,120|R-30,60|r-30,90'; // 2 rooks in between, queen 5 left // const example_position = 'K0,0|q-120,120|R-30,60|r-30,90'; // 2 rooks in between // const example_position = 'q-125,120|R-30,115|k0,0'; // rook same y group as queen // const example_position = 'q-125,120|R-30,60|k0,0'; // 1 rook in between, queen 5 left // const example_position = 'n5,60|q-20,60|r33,40|K40,0'; // random connections // const example_position = 'n5,60|q0,60|r40,40|K60,0'; // 1 rook in between, knight 5 right // const example_position = 'n-5,60|q0,60|r40,40|K60,0'; // 1 rook in between, knight 5 left // const example_position = 'q0,60|r40,40|K60,0'; // 1 rook in between // const example_position = 'K0,33|q30,0'; // const example_position = 'K0,30|q33,0'; // const example_position = 'K0,30|q30,0'; // const example_position = "q0,50|k80,0"; const parsedPosition = icnconverter.ShortToLong_Format(example_position); // console.log("parsedPosition:", JSON.stringify(parsedPosition.position, jsutil.stringifyReplacer)); // const compressedPosition = positioncompressor.compressPosition(parsedPosition.position!, 'orthogonals'); const compressedPosition = positioncompressor.compressPosition(parsedPosition.position!, 'diagonals'); console.log("\nBefore:"); console.log(example_position); const newICN = icnconverter.getShortFormPosition(compressedPosition.position, parsedPosition.state_global.specialRights!); console.log("\nAfter:"); console.log(newICN); console.log("\n"); // const chosenMove: MoveCoords = { // startCoords: [20n, 5n], // endCoords: [0n, 1n], // }; // const expandedMove = moveexpander.expandMove(compressedPosition.axisOrders, compressedPosition.pieceTransformations, chosenMove); // console.log(`\nChosen move: Start: (${String(chosenMove.startCoords)}) End: (${String(chosenMove.endCoords)})`); // console.log(`Expanded move: Start: (${String(expandedMove.startCoords)}) End: (${String(expandedMove.endCoords)})\n`); ================================================ FILE: dev-utils/scripts/positionnormalizer/positioncompressor.ts ================================================ // dev-utils/scripts/positionnormalizer/positioncompressor.ts /** * This script contains an algorithm that can take an infinite chess position, * which may have pieces at arbitrarily large coordinates, and compress it * so that all pieces are within the bounds of standard javascript doubles, * while retaining all piece relationships to each other. */ import type { Vec2Key } from "../../../util/math/vectors.js"; import { solve, Model } from "yalps"; // Linear Programming Solver! import bimath from "../../../util/bigdecimal/bimath.js"; import coordutil, { Coords, CoordsKey } from "../../util/coordutil.js"; import typeutil, { players as p, rawTypes as r } from "../../util/typeutil.js"; // ============================== Type Definitions ============================== /** * A compressed position, along with the transformation info to be able to * expand the chosen move back to the original position. */ interface CompressionInfo { position: Map; axisOrders: AxisOrders; /** * Contains information on each group, the group's * original position, and each piece in the group. */ pieceTransformations: PieceTransform[] } /** * Contains the information of where a piece started * before compressing the position, and where they ended up. */ type PieceTransform = { type: number; /** The original coordinates of the piece in the uncompressed position. */ coords: Coords; /** * The pieces new coordinates in the transformed/compressed position. * Both coords will be fully defined after the orthogonal solution is finished. */ transformedCoords: [bigint | undefined, bigint | undefined]; }; /** * Contains information of what pieces are connected/linked/merged on what axis, * and how they have been transformed into the compressed position. */ type AxisOrders = Record; /** * An ordering of the pieces on one axis (X/Y/pos-diag/neg-diag), * also storing what pieces are linked together (their axis values are close together). */ type AxisOrder = AxisGroup[]; /** * A group of pieces all linked on one axis (X/Y/pos-diag/neg-diag) * due to being close together. */ type AxisGroup = { range: [bigint, bigint]; transformedRange?: [bigint, bigint]; pieces: PieceTransform[]; } /** * Takes a pair of coordinates and returns a single * value that is unique to the axis line that piece is on. */ type AxisDeterminer = (_coords: Coords) => bigint; /** All orthogonal axes. */ type OrthoAxis = '1,0' | '0,1'; /** All diagonal axes. */ type DiagAxis = '1,1' | '1,-1'; /** Any axis. */ type Axis = OrthoAxis | DiagAxis; /** * A variable name in the Linear Programming Model. * * The first letter is what axis the piece coord is for. (u/v is only used in constraint names) * After the `-` is the index of the piece in its sorted list. */ type VariableName = `x-${number}` | `y-${number}` | `u-${number}` | `v-${number}`; /** * One column in a constraint of the Linear Programming Model. */ type Column = { /** The name of the variable */ variable: string; /** The coefficient of the variable in the constraint equation. Usually 1 or -1. */ coefficient: number; // } // ================================== Constants ================================== /** * Piece groups further than this many squares away from the origin * will be compressed closer to the origin. * * IN THE FUTURE: Determine whether a position needs to be compressed or not * BASED ON WHETHER intersections of groups, or intersections of intersections * lie beyond Number.MAX_SAFE_INTEGER! * * Actually it actually might be smarter to always normalize positions so engines * have more floating point precision to work with. */ const UNSAFE_BOUND_BIGINT = BigInt(Math.trunc(Number.MAX_SAFE_INTEGER * 0.1)); // const UNSAFE_BOUND_BIGINT = 1000n; /** * How close pieces or groups have to be on on axis or diagonal to * link them together, so that that axis or diagonal will not be * broken when compressing the position. * * They will receive equality constrains instead of inequality constraints. * * This is also considered the minimum distance for a distance * to be considered arbitrary. After all, almost never do we move a * short range piece over 20 squares in a game, so the difference * between 20 and 1 million squares is very little. * * Of course if we are taking into account connections between sub groups * and sub sub groups, the distance naturally becomes larger in order to * retain forks and forks of forks. * * REQUIREMENTS: * * * Must be OVER 2x larger than than the longest jumping jumper piece. * This is so that they will remain connected to the same group when expanding/lifting the move back out. * Jumping moves don't need extra attention other than making sure this is big enough. * Code works automatically, even for hippogonal jumps! * * * Must be divisible by 2, as this is divided by two in moveexpander.ts */ // const MIN_ARBITRARY_DISTANCE = 100n; const MIN_ARBITRARY_DISTANCE = 10n; /** * Each axis determiner, given a coordinate, will return the bigint value * that represents the axis value on the given axis for that piece. * * The axis value is an integer unique to all pieces that lie on the same axis line as it. */ const AXIS_DETERMINERS = { /** X Axis */ '1,0': (compressedEndCoords: Coords): bigint => compressedEndCoords[0], /** Y Axis */ '0,1': (compressedEndCoords: Coords): bigint => compressedEndCoords[1], /** Positive Diagonal Axis */ '1,1': (coords: Coords): bigint => coords[1] - coords[0], /** Negative Diagonal Axis */ '1,-1': (coords: Coords): bigint => coords[1] + coords[0], }; // ==================================== Main Function ==================================== /** * Compresses/normalizes a position. Reduces all arbitrary large distances * to some small distance constant. * Returns transformation info so that the chosen move from the compressed position * can be expanded/lifted back to the original position. * @param position - The position to compress, as a Map of coords to piece types. * @param mode - The compression mode, either 'orthogonals' or 'diagonals'. * - 'orthogonals' require all pieces to remain in the same quadrant relative to other pieces. * - 'diagonals' require all pieces to remain in the same octant relative to other pieces. * - FUTURE: 'hipppogonal' require all pieces to remain in the same hexadecant relative to other pieces. */ function compressPosition(position: Map, mode: 'orthogonals' | 'diagonals'): CompressionInfo { // List all pieces with their bigint arbitrary coordinates. const pieces: PieceTransform[] = []; position.forEach((type, coordsKey) => { const coords = coordutil.getCoordsFromKey(coordsKey); pieces.push({ type, coords, transformedCoords: [undefined, undefined], // Initially undefined }); }); // Determine if the position even needs compression by // seeing whether any piece lies beyond UNSAFE_BOUND_BIGINT. // const needsCompression = pieces.some(piece => // bimath.abs(piece.coords[0]) > UNSAFE_BOUND_BIGINT || bimath.abs(piece.coords[1]) > UNSAFE_BOUND_BIGINT // ); // if (!needsCompression) { // console.log("No compression needed."); // for (const piece of pieces) piece.transformedCoords = piece.coords; // return { position, pieceTransformations: pieces }; // } // ==================================== Construct Axis Orders, Order Pieces ==================================== /** * Orderings of the pieces on every axis of movement, * and how they are all grouped/connected together. */ const AllAxisOrders: AxisOrders = {}; /** All pieces, organized in ascending order on every axis. */ const OrderedPieces: Record = {}; // Init the Axis Orders processAxis('1,0'); processAxis('0,1'); if (mode === 'diagonals') { processAxis('1,1'); processAxis('1,-1'); } /** Helper for constructing the axisOrder and ordered pieces of one axis. */ function processAxis(axis: Axis): void { const axisDeterminer = AXIS_DETERMINERS[axis]; // First sort the pieces by ascending axis value const sortedPieces: PieceTransform[] = pieces.slice(); // Shallow copy sortedPieces.sort((a, b) => bimath.compare(axisDeterminer(a.coords), axisDeterminer(b.coords))); OrderedPieces[axis] = sortedPieces; const axisOrder: AxisOrder = []; AllAxisOrders[axis] = axisOrder; // Go through the sorted pieces one by one, creating the groups on this axis. let currentGroup: AxisGroup | null = null; for (const piece of sortedPieces) { const currentAxisValue = axisDeterminer(piece.coords); // If the axis value is less than or equal to MIN_ARBITRARY_DISTANCE from the current // group being pushed to range's END, add it to that group and extend its range. // Else, start a new group. if (currentGroup === null || currentAxisValue - currentGroup.range[1] > MIN_ARBITRARY_DISTANCE) { // Start a new group currentGroup = { pieces: [], range: [currentAxisValue, currentAxisValue] }; axisOrder.push(currentGroup); } // Add the piece to the current running group currentGroup.pieces.push(piece); // Update its range currentGroup.range[1] = currentAxisValue; } } // All pieces are now in order! // ONLY FOR LOGGING --------------------------------------------- // console.log("\nAll axis orders after registering pieces:"); // for (const vec2Key in AllAxisOrders) { // const axisOrder = AllAxisOrders[vec2Key] as AxisOrder; // console.log(`Axis order ${vec2Key}:`); // for (const axisGroup of axisOrder) { // console.log(` Range: ${axisGroup.range}, Pieces: ${axisGroup.pieces.length}`); // } // } // -------------------------------------------------------------- // ================================ MODEL CONSTRAINTS ================================ // Initiate the linear programming model for solving. const model: Model = { direction: 'minimize', objective: 'manhatten_norm', // The objective function to minimize constraints: { // An equation // piece1_X_constraint: { min: 10 }, // The right hand side of the equation: >= 10 // piece1_Y_constraint: { min: 10 }, }, variables: { // piece1_X: { manhatten_norm: 1, piece1_X_constraint: 1 }, // A list of what equations (constraints) this variable is a part of (a column in), and the coefficient it gets (1 for addition, -1 for subtraction). // piece1_Y: { manhatten_norm: 1, piece1_Y_constraint: 1 }, }, // Enforces all variables to be integers. // Without this, sometimes the solution's piece coordinates will be at half squares. integers: true, }; /** * A map containing a reference to each piece's Model X & Y coord variable names. * Only used if we are in diagonals mode. */ const pieceToVarNames = new Map>(); // ANCHOR: Add constraints to anchor the first X and Y pieces at 0. ------------- const firstXVarName = getVariableName('1,0', 0); addConstraintToModel(model, `${firstXVarName}_anchor`, [ { variable: firstXVarName, coefficient: 1 }, ], 'equal', 0); const firstYVarName = getVariableName('0,1', 0); addConstraintToModel(model, `${firstYVarName}_anchor`, [ { variable: firstYVarName, coefficient: 1 }, ], 'equal', 0); // ------------------------------------------------------------------------------- // Add all the constraints between our piece coordinates to the model. // For each sorted piece on a specific axis, add a constraint to that piece and the previous piece createConstraintsForAxis('1,0'); createConstraintsForAxis('0,1'); if (mode === 'diagonals') { // When using diagonals, first populate the piece to varName map first. // We need this because a piece's index in the organized diagonal list // is not the same as its index in the orthogonal lists. populatePieceVarNames('1,0'); populatePieceVarNames('0,1'); createConstraintsForAxis('1,1'); createConstraintsForAxis('1,-1'); } /** Helper for constructing {@link pieceToVarNames}. */ function populatePieceVarNames(axis: '0,1' | '1,0') { OrderedPieces[axis].forEach((piece, index) => { const varName = getVariableName(axis, index); if (!pieceToVarNames.has(piece)) pieceToVarNames.set(piece, {}); pieceToVarNames.get(piece)![axis] = varName; }); } /** * Helper for creating and adding the constraints between each * adjacent piece on one specific axis to the linear programming model. */ function createConstraintsForAxis(axis: Axis) { const axisDeterminer = AXIS_DETERMINERS[axis]; const sortedPieces = OrderedPieces[axis]; const firstPiece = sortedPieces[0]; let firstPieceAxisValue = axisDeterminer(firstPiece.coords); for (let i = 1; i < sortedPieces.length; i++) { const secondPiece = sortedPieces[i]; const secondPieceAxisValue = axisDeterminer(secondPiece.coords); // Determine if the constraint is exact, or min let type: 'equal' | 'min'; let constraint: number; const difference = secondPieceAxisValue - firstPieceAxisValue; if (difference <= MIN_ARBITRARY_DISTANCE) { // EXACT constraint (same group) type = 'equal'; constraint = Number(difference); } else { // MINIMUM constraint (different groups, over MIN_ARBITRARY_DISTANCE apart) type = 'min'; constraint = Number(MIN_ARBITRARY_DISTANCE); } if (axis === '1,0' || axis === '0,1') { const firstPieceVarName = getVariableName(axis, i - 1); const secondPieceVarName = getVariableName(axis, i); const constraintName = getConstraintName(secondPieceVarName); // What does the constraint look like on the X/Y axis? // Desired: thisPieceXY >= prevPieceXY + 10 // To get that we do: thisPieceXY - prevPieceXY >= 10 addConstraintToModel(model, constraintName, [ { variable: secondPieceVarName, coefficient: 1 }, { variable: firstPieceVarName, coefficient: -1 }, ], type, constraint); // If this is the last piece on the X/Y axis, then we // need to include it in our optimization function! // The optimization function tries to minimize the furthest piece // on the X/Y axes. This naturally tries to shrink the position. const lastPiece = i === sortedPieces.length - 1; if (lastPiece) model.variables[secondPieceVarName][model.objective!] = 1; } else if (axis === '1,1' || axis === '1,-1') { const firstPiece = sortedPieces[i - 1]; const secondPiece = sortedPieces[i]; // Get the variable names for the piece's X and Y coordinates from the X & Y ordered lists. const firstPieceVars = pieceToVarNames.get(firstPiece)!; const secondPieceVars = pieceToVarNames.get(secondPiece)!; const firstPieceVarNameX = firstPieceVars['1,0']!; const firstPieceVarNameY = firstPieceVars['0,1']!; const secondPieceVarNameX = secondPieceVars['1,0']!; const secondPieceVarNameY = secondPieceVars['0,1']!; const constraintName = getConstraintName(getVariableName(axis, i)); if (axis === '1,1') { // What does the constraint look like if this is the U axis? // U axis value (positive diagonal) is determined by: Y - X // Desired: thisPieceY - thisPieceX >= prevPieceY - prevPieceX + 10 // To get that we do: thisPieceY - thisPieceX - prevPieceY + prevPieceX >= 10 addConstraintToModel(model, constraintName, [ // Second piece diagonal { variable: secondPieceVarNameY, coefficient: 1 }, { variable: secondPieceVarNameX, coefficient: -1 }, // First piece diagonal { variable: firstPieceVarNameY, coefficient: -1 }, { variable: firstPieceVarNameX, coefficient: 1 }, ], type, constraint); } else if (axis === '1,-1') { // What does the constraint look like if this is the V axis? // V axis value (negative diagonal) is determined by: X + Y // Desired: thisPieceX + thisPieceY >= prevPieceX + prevPieceY + 10 // To get that we do: thisPieceX + thisPieceY - prevPieceX - prevPieceY >= 10 addConstraintToModel(model, constraintName, [ // Second piece diagonal { variable: secondPieceVarNameX, coefficient: 1 }, { variable: secondPieceVarNameY, coefficient: 1 }, // First piece diagonal { variable: firstPieceVarNameX, coefficient: -1 }, { variable: firstPieceVarNameY, coefficient: -1 }, ], type, constraint); } else throw Error("Unexpected!"); } else throw Error(`Unsupported axis ${axis}.`); // Prepare for next iteration firstPieceAxisValue = secondPieceAxisValue; } } // Solve the Model console.time("Solved"); const solution = solve(model, { // Include variables that are zero in the solution. // We need piece coords even if they are at 0! includeZeroVariables: true, }); console.timeEnd("Solved"); console.log("Solution status:", solution.status); // The score of the solution. This is the sum of the furthest piece's X and Y coordinates. console.log("Result:", solution.result); if (solution.status !== 'optimal') { console.error("The unified solver could not find a feasible solution."); throw new Error("Unified LP solver failed. Constraints may be contradictory."); } // ==================================== Transformed Coordinate Assembly ==================================== // The solution object contains the solved X & Y positions for every single piece. // Extract all the variables. for (const [variableName, value] of solution.variables) { const [axis, pieceIndex] = (variableName as VariableName).split('-'); if (axis === 'x') { const sortedPieces = OrderedPieces['1,0']; const piece = sortedPieces[pieceIndex]!; // Set its transformed X coord. piece.transformedCoords[0] = BigInt(value); } else if (axis === 'y') { const sortedPieces = OrderedPieces['0,1']; const piece = sortedPieces[pieceIndex]!; // Set its transformed Y coord. piece.transformedCoords[1] = BigInt(value); } else throw Error("Unknown axis."); } // Calculate the new, transformed range, for each group on each axis. // Needed for the moveexpander knows what group your move is targeting. for (const axisKey in AllAxisOrders) { const axisOrder = AllAxisOrders[axisKey as Vec2Key]; const axisDeterminer = AXIS_DETERMINERS[axisKey as Axis]; for (const group of axisOrder) { let start: bigint | null = null; let end: bigint | null = null; // Iterate through the pieces in the group to find the min and max axis values. for (let i = 0; i < group.pieces.length; i++) { const piece = group.pieces[i]!; const axisValue = axisDeterminer(piece.transformedCoords as Coords); if (start === null || axisValue < start) start = axisValue; if (end === null || axisValue > end) end = axisValue; } // Set the calculated transformed range for the group. group.transformedRange = [start!, end!]; } } // [Optional] Shift the entire solution so that the White King is in its original spot! (Doesn't break the solution/topology) // ISN'T required for engines, but may be nice for visuals. // Commented-out for decreasing the script size. // RecenterTransformedPosition(pieces, AllAxisOrders); // Assemble the final compressed position from the solved piece's transformed coordinates. const compressedPosition: Map = new Map(); for (const piece of pieces) { // Add the final coordinate and piece type to our output map. const transformedCoordsKey = coordutil.getKeyFromCoords(piece.transformedCoords as Coords); compressedPosition.set(transformedCoordsKey, piece.type); } // Return the complete compression information, which is used to expand the chosen move, later. return { position: compressedPosition, axisOrders: AllAxisOrders, pieceTransformations: pieces, }; } // ========================================== MODEL HELPERS ========================================== /** * Returns a string we'll use for the variable name in the linear programming model. * @param axis - What axis this variable is for * @param index - The index of the piece in its sorted list. */ function getVariableName(axis: Axis, index: number): VariableName { const axisLetter = axis === '1,0' ? 'x' : axis === '0,1' ? 'y' : axis === '1,1' ? 'u' : axis === '1,-1' ? 'v' : (() => { throw Error("Unsupported axis."); })(); return `${axisLetter}-${index}`; } function getConstraintName(varName: VariableName) { return `${varName}_constraint`; } /** * Helper for adding a constraint to the running linear programming model. * * Creates the variable in the model if it doesn't exist yet, adds the constraint, * and updates the variable's columns its included in. */ function addConstraintToModel(model: Model, constraint_name: string, columns: Column[], type: 'equal' | 'min' | 'max', value: number): void { // Add the equation model.constraints[constraint_name] = { [type]: value }; // Add the variables as columns to it for (const column of columns) { // Initialize first if not already if (!model.variables[column.variable]) model.variables[column.variable] = {}; // Include the variable in the column of the constraint function model.variables[column.variable][constraint_name] = column.coefficient; } } // ======================================== RECENTERING TRANFORMED POSITION ======================================== // ISN'T required for engines, but may be nice for visuals. // Commented-out for decreasing the script size. /** * Translates the entire transformed position so tht the White King * ends up on the same square it occupied in the original, uncompressed position. * This doesn't affect the solution or topology at all. * @param allPieces The list of all transformed pieces. * @param allAxisOrders The AxisOrders object containing all axis groups of the transformed position. */ function RecenterTransformedPosition(allPieces: PieceTransform[], allAxisOrders: AxisOrders) { // Define the type for a White King (you may need to import typeutil and players) const whiteKingType = typeutil.buildType(r.KING, p.WHITE); // 1. Find the White King in the list of pieces. const whiteKing: PieceTransform | undefined = allPieces.find(p => p.type === whiteKingType); if (!whiteKing) { console.warn("Could not find White King to normalize position. Skipping translation."); return; } // 2. Calculate the required translation vector (dx, dy). const transformedKingCoords = whiteKing.transformedCoords as Coords; const translationVector: Coords = [ whiteKing.coords[0] - transformedKingCoords[0], whiteKing.coords[1] - transformedKingCoords[1] ]; console.log(`Normalizing position by translating all pieces by [${translationVector[0]}, ${translationVector[1]}] to match White King's original position.`); // 3. Apply the translation to every piece's transformed coordinates. for (const piece of allPieces) { piece.transformedCoords[0]! += translationVector[0]; piece.transformedCoords[1]! += translationVector[1]; } // 4. Apply the same translation to all axes' groups' transformedRange. for (const axisKey in allAxisOrders) { const axisOrder = allAxisOrders[axisKey as Vec2Key]; const axisDeterminer = AXIS_DETERMINERS[axisKey]; // Calculate how the translationVector translates on this specific axis. // This is equivalent to axisDeterminer([dx, dy]) - axisDeterminer([0, 0]). const pushAmount = axisDeterminer(translationVector); for (const group of axisOrder) { if (group.transformedRange) { group.transformedRange[0] += pushAmount; group.transformedRange[1] += pushAmount; } } } } // ========================================= EXPORTS ========================================= export type { AxisOrders, PieceTransform, }; export default { // Constants MIN_ARBITRARY_DISTANCE, AXIS_DETERMINERS, // Main Function compressPosition, }; ================================================ FILE: dev-utils/scripts/positionnormalizer/positioncompressorplusintersections.ts ================================================ // src/client/scripts/esm/chess/logic/positionnormalizer/positioncompressor.ts /** * This script contains an algorithm that can take an infinite chess position, * which may have pieces at arbitrarily large coordinates, and compress it * so that all pieces are within the bounds of standard javascript doubles. */ import type { LineCoefficientsBD, Vec2, Vec2Key } from '../../../util/math/vectors.js'; import { solve, Model } from 'yalps'; // Linear Programming Solver! import bimath from '../../../util/bigdecimal/bimath.js'; import coordutil, { BDCoords, Coords, CoordsKey } from '../../util/coordutil.js'; import typeutil, { players as p, rawTypes as r } from '../../util/typeutil.js'; import vectors from '../../../util/math/vectors.js'; import geometry from '../../../util/math/geometry.js'; import bd, { BigDecimal } from '../../../util/bigdecimal/bigdecimal.js'; // ============================== Type Definitions ============================== /** * A compressed position, along with the transformation info to be able to * expand the chosen move back to the original position. */ interface CompressionInfo { position: Map; axisOrders: AxisOrders; /** * Contains information on each group, the group's * original position, and each piece in the group. */ pieceTransformations: PieceTransform[]; } /** * Contains the information of where a piece started * before compressing the position, and where they ended up. */ type PieceTransform = { type: number; /** The original coordinates of the piece in the uncompressed position. */ coords: BDCoords; /** * The pieces new coordinates in the transformed/compressed position. * Both coords will be fully defined after the orthogonal solution is finished. */ transformedCoords: [BigDecimal | undefined, BigDecimal | undefined]; }; /** * Contains information of what pieces are connected/linked/merged on what axis, * and how they have been transformed into the compressed position. */ type AxisOrders = Record; /** * An ordering of the pieces on one axis (X/Y/pos-diag/neg-diag), * also storing what pieces are linked together (their axis values are close together). */ type AxisOrder = AxisGroup[]; /** * A group of pieces all linked on one axis (X/Y/pos-diag/neg-diag) * due to being close together. */ type AxisGroup = { range: [BigDecimal, BigDecimal]; transformedRange?: [BigDecimal, BigDecimal]; pieces: PieceTransform[]; }; /** * Takes a pair of coordinates and returns a single * value that is unique to the axis line that piece is on. */ type AxisDeterminer = (_coords: Coords) => bigint; /** All orthogonal axes. */ type OrthoAxis = '1,0' | '0,1'; /** All diagonal axes. */ type DiagAxis = '1,1' | '1,-1'; /** Any axis. */ type Axis = OrthoAxis | DiagAxis; /** * A variable name in the Linear Programming Model. * * The first letter is what axis the piece coord is for. (u/v is only used in constraint names) * After the `-` is the index of the piece in its sorted list. */ type VariableName = `x-${number}` | `y-${number}` | `u-${number}` | `v-${number}`; /** * One column in a constraint of the Linear Programming Model. */ type Column = { /** The name of the variable */ variable: string; /** The coefficient of the variable in the constraint equation. Usually 1 or -1. */ coefficient: number; // }; // ================================== Constants ================================== /** * Piece groups further than this many squares away from the origin * will be compressed closer to the origin. * * IN THE FUTURE: Determine whether a position needs to be compressed or not * BASED ON WHETHER intersections of groups, or intersections of intersections * lie beyond Number.MAX_SAFE_INTEGER! * * Actually it actually might be smarter to always normalize positions so engines * have more floating point precision to work with. */ const UNSAFE_BOUND_BIGINT = BigInt(Math.trunc(Number.MAX_SAFE_INTEGER * 0.1)); // const UNSAFE_BOUND_BIGINT = 1000n; /** * How close pieces or groups have to be on on axis or diagonal to * link them together, so that that axis or diagonal will not be * broken when compressing the position. * * They will receive equality constrains instead of inequality constraints. * * This is also considered the minimum distance for a distance * to be considered arbitrary. After all, almost never do we move a * short range piece over 20 squares in a game, so the difference * between 20 and 1 million squares is very little. * * Of course if we are taking into account connections between sub groups * and sub sub groups, the distance naturally becomes larger in order to * retain forks and forks of forks. * * REQUIREMENTS: * * * Must be OVER 2x larger than than the longest jumping jumper piece. * This is so that they will remain connected to the same group when expanding/lifting the move back out. * Jumping moves don't need extra attention other than making sure this is big enough. * Code works automatically, even for hippogonal jumps! * * * Must be divisible by 2, as this is divided by two in moveexpander.ts */ // const MIN_ARBITRARY_DISTANCE = 100n; const MIN_ARBITRARY_DISTANCE = 10n; const MIN_ARBITRARY_DISTANCE_BD = bd.fromBigInt(MIN_ARBITRARY_DISTANCE); /** * Each axis determiner, given a coordinate, will return the bigint value * that represents the axis value on the given axis for that piece. * * The axis value is an integer unique to all pieces that lie on the same axis line as it. */ const AXIS_DETERMINERS = { /** X Axis */ '1,0': (compressedEndCoords: Coords): bigint => compressedEndCoords[0], /** Y Axis */ '0,1': (compressedEndCoords: Coords): bigint => compressedEndCoords[1], /** Positive Diagonal Axis */ '1,1': (coords: Coords): bigint => coords[1] - coords[0], /** Negative Diagonal Axis */ '1,-1': (coords: Coords): bigint => coords[1] + coords[0], }; const AXIS_DETERMINERS_BD = { /** X Axis */ '1,0': (compressedEndCoords: BDCoords): BigDecimal => compressedEndCoords[0], /** Y Axis */ '0,1': (compressedEndCoords: BDCoords): BigDecimal => compressedEndCoords[1], /** Positive Diagonal Axis */ '1,1': (coords: BDCoords): BigDecimal => bd.subtract(coords[1], coords[0]), /** Negative Diagonal Axis */ '1,-1': (coords: BDCoords): BigDecimal => bd.add(coords[1], coords[0]), }; /** * The piece type number reserved for intersection placeholder pieces. * * MUST NOT BE ANY NUMBER ALREADY ASIGNED TO A PIECE TYPE IN typeutil.ts! */ const INTERSECTION_TYPE = -1; // ==================================== Main Function ==================================== /** * Compresses/normalizes a position. Reduces all arbitrary large distances * to some small distance constant. * Returns transformation info so that the chosen move from the compressed position * can be expanded/lifted back to the original position. * @param position - The position to compress, as a Map of coords to piece types. * @param mode - The compression mode, either 'orthogonals' or 'diagonals'. * - 'orthogonals' require all pieces to remain in the same quadrant relative to other pieces. * - 'diagonals' require all pieces to remain in the same octant relative to other pieces. * - FUTURE: 'hipppogonal' require all pieces to remain in the same hexadecant relative to other pieces. */ function compressPosition( position: Map, orthogonals: boolean, diagonals: boolean, hippogonals: boolean, numIntersections: number, ): CompressionInfo { if (!orthogonals && !diagonals && !hippogonals) throw Error('Position to compress must have at least one axis mode enabled.'); if (numIntersections > 0 && !hippogonals && orthogonals !== diagonals) throw Error('numIntersections has no effect when only one axis is enabled.'); // List all pieces with their bigint arbitrary coordinates. const pieces: PieceTransform[] = []; position.forEach((type, coordsKey) => { const coords = coordutil.getCoordsFromKey(coordsKey); pieces.push({ type, coords: bd.FromCoords(coords), transformedCoords: [undefined, undefined], // Initially undefined }); }); // Append the intersections of each pair of pieces. addIntersectionsToPieces(pieces, orthogonals, diagonals, hippogonals, numIntersections); // Determine if the position even needs compression by // seeing whether any piece lies beyond UNSAFE_BOUND_BIGINT. // const needsCompression = pieces.some(piece => // bimath.abs(piece.coords[0]) > UNSAFE_BOUND_BIGINT || bimath.abs(piece.coords[1]) > UNSAFE_BOUND_BIGINT // ); // if (!needsCompression) { // console.log("No compression needed."); // for (const piece of pieces) piece.transformedCoords = piece.coords; // return { position, pieceTransformations: pieces }; // } // ==================================== Construct Axis Orders, Order Pieces ==================================== /** * Orderings of the pieces on every axis of movement, * and how they are all grouped/connected together. */ const AllAxisOrders: AxisOrders = {}; /** All pieces, organized in ascending order on every axis. */ const OrderedPieces: Record = {}; // Init the Axis Orders if (orthogonals) { processAxis('1,0'); processAxis('0,1'); } if (diagonals) { processAxis('1,1'); processAxis('1,-1'); } /** Helper for constructing the axisOrder and ordered pieces of one axis. */ function processAxis(axis: Axis): void { const axisDeterminer = AXIS_DETERMINERS_BD[axis]; // First sort the pieces by ascending axis value const sortedPieces: PieceTransform[] = pieces.slice(); // Shallow copy sortedPieces.sort((a, b) => bd.compare(axisDeterminer(a.coords), axisDeterminer(b.coords))); OrderedPieces[axis] = sortedPieces; const axisOrder: AxisOrder = []; AllAxisOrders[axis] = axisOrder; // Go through the sorted pieces one by one, creating the groups on this axis. let currentGroup: AxisGroup | null = null; for (const piece of sortedPieces) { const currentAxisValue: BigDecimal = axisDeterminer(piece.coords); // If the axis value is less than or equal to MIN_ARBITRARY_DISTANCE from the current // group being pushed to range's END, add it to that group and extend its range. // Else, start a new group. if ( currentGroup === null || bd.compare( bd.subtract(currentAxisValue, currentGroup.range[1]), MIN_ARBITRARY_DISTANCE_BD, ) > 0 ) { // currentAxisValue - currentGroup.range[1] > MIN_ARBITRARY_DISTANCE // Start a new group currentGroup = { pieces: [], range: [currentAxisValue, currentAxisValue] }; axisOrder.push(currentGroup); } // Add the piece to the current running group currentGroup.pieces.push(piece); // Update its range currentGroup.range[1] = currentAxisValue; } } // All pieces are now in order! // ONLY FOR LOGGING --------------------------------------------- // console.log("\nAll axis orders after registering pieces:"); // for (const vec2Key in AllAxisOrders) { // const axisOrder = AllAxisOrders[vec2Key] as AxisOrder; // console.log(`Axis order ${vec2Key}:`); // for (const axisGroup of axisOrder) { // console.log(` Range: ${stringifyBDCoords(axisGroup.range)}, Pieces: ${axisGroup.pieces.length}`); // } // } // -------------------------------------------------------------- // ================================ MODEL CONSTRAINTS ================================ // Initiate the linear programming model for solving. const model: Model = { direction: 'minimize', objective: 'manhatten_norm', // The objective function to minimize constraints: { // An equation // piece1_X_constraint: { min: 10 }, // The right hand side of the equation: >= 10 // piece1_Y_constraint: { min: 10 }, }, variables: { // piece1_X: { manhatten_norm: 1, piece1_X_constraint: 1 }, // A list of what equations (constraints) this variable is a part of (a column in), and the coefficient it gets (1 for addition, -1 for subtraction). // piece1_Y: { manhatten_norm: 1, piece1_Y_constraint: 1 }, }, // Enforces all variables to be integers. // Without this, sometimes the solution's piece coordinates will be at half squares. // integers: true, }; /** * A map containing a reference to each piece's Model X & Y coord variable names. * Only used if we are in diagonals mode. */ const pieceToVarNames = new Map>(); // ANCHOR: Add constraints to anchor the first X and Y pieces at 0. ------------- const firstXVarName = getVariableName('1,0', 0); addConstraintToModel( model, `${firstXVarName}_anchor`, [{ variable: firstXVarName, coefficient: 1 }], 'equal', 0, ); const firstYVarName = getVariableName('0,1', 0); addConstraintToModel( model, `${firstYVarName}_anchor`, [{ variable: firstYVarName, coefficient: 1 }], 'equal', 0, ); // ------------------------------------------------------------------------------- // Add all the constraints between our piece coordinates to the model. // For each sorted piece on a specific axis, add a constraint to that piece and the previous piece if (orthogonals) { createConstraintsForAxis('1,0'); createConstraintsForAxis('0,1'); } if (diagonals) { // When using diagonals, first populate the piece to varName map first. // We need this because a piece's index in the organized diagonal list // is not the same as its index in the orthogonal lists. populatePieceVarNames('1,0'); populatePieceVarNames('0,1'); createConstraintsForAxis('1,1'); createConstraintsForAxis('1,-1'); } /** Helper for constructing {@link pieceToVarNames}. */ function populatePieceVarNames(axis: '0,1' | '1,0') { OrderedPieces[axis].forEach((piece, index) => { const varName = getVariableName(axis, index); if (!pieceToVarNames.has(piece)) pieceToVarNames.set(piece, {}); pieceToVarNames.get(piece)![axis] = varName; }); } /** * Helper for creating and adding the constraints between each * adjacent piece on one specific axis to the linear programming model. */ function createConstraintsForAxis(axis: Axis) { const axisDeterminer = AXIS_DETERMINERS_BD[axis]; const sortedPieces = OrderedPieces[axis]; const firstPiece = sortedPieces[0]; let firstPieceAxisValue = axisDeterminer(firstPiece.coords); for (let i = 1; i < sortedPieces.length; i++) { const secondPiece = sortedPieces[i]; const secondPieceAxisValue = axisDeterminer(secondPiece.coords); // Determine if the constraint is exact, or min let type: 'equal' | 'min'; let constraint: number; const difference = bd.subtract(secondPieceAxisValue, firstPieceAxisValue); if (bd.compare(difference, MIN_ARBITRARY_DISTANCE_BD) <= 0) { // EXACT constraint (same group) type = 'equal'; // constraint = Number(difference); constraint = bd.toNumber(difference); } else { // MINIMUM constraint (different groups, over MIN_ARBITRARY_DISTANCE apart) type = 'min'; constraint = Number(MIN_ARBITRARY_DISTANCE); } if (axis === '1,0' || axis === '0,1') { const firstPieceVarName = getVariableName(axis, i - 1); const secondPieceVarName = getVariableName(axis, i); const constraintName = getConstraintName(secondPieceVarName); // What does the constraint look like on the X/Y axis? // Desired: thisPieceXY >= prevPieceXY + 10 // To get that we do: thisPieceXY - prevPieceXY >= 10 addConstraintToModel( model, constraintName, [ { variable: secondPieceVarName, coefficient: 1 }, { variable: firstPieceVarName, coefficient: -1 }, ], type, constraint, ); // If this is the last piece on the X/Y axis, then we // need to include it in our optimization function! // The optimization function tries to minimize the furthest piece // on the X/Y axes. This naturally tries to shrink the position. const lastPiece = i === sortedPieces.length - 1; if (lastPiece) model.variables[secondPieceVarName][model.objective!] = 1; } else if (axis === '1,1' || axis === '1,-1') { const firstPiece = sortedPieces[i - 1]; const secondPiece = sortedPieces[i]; // Get the variable names for the piece's X and Y coordinates from the X & Y ordered lists. const firstPieceVars = pieceToVarNames.get(firstPiece)!; const secondPieceVars = pieceToVarNames.get(secondPiece)!; const firstPieceVarNameX = firstPieceVars['1,0']!; const firstPieceVarNameY = firstPieceVars['0,1']!; const secondPieceVarNameX = secondPieceVars['1,0']!; const secondPieceVarNameY = secondPieceVars['0,1']!; const constraintName = getConstraintName(getVariableName(axis, i)); if (axis === '1,1') { // What does the constraint look like if this is the U axis? // U axis value (positive diagonal) is determined by: Y - X // Desired: thisPieceY - thisPieceX >= prevPieceY - prevPieceX + 10 // To get that we do: thisPieceY - thisPieceX - prevPieceY + prevPieceX >= 10 addConstraintToModel( model, constraintName, [ // Second piece diagonal { variable: secondPieceVarNameY, coefficient: 1 }, { variable: secondPieceVarNameX, coefficient: -1 }, // First piece diagonal { variable: firstPieceVarNameY, coefficient: -1 }, { variable: firstPieceVarNameX, coefficient: 1 }, ], type, constraint, ); } else if (axis === '1,-1') { // What does the constraint look like if this is the V axis? // V axis value (negative diagonal) is determined by: X + Y // Desired: thisPieceX + thisPieceY >= prevPieceX + prevPieceY + 10 // To get that we do: thisPieceX + thisPieceY - prevPieceX - prevPieceY >= 10 addConstraintToModel( model, constraintName, [ // Second piece diagonal { variable: secondPieceVarNameX, coefficient: 1 }, { variable: secondPieceVarNameY, coefficient: 1 }, // First piece diagonal { variable: firstPieceVarNameX, coefficient: -1 }, { variable: firstPieceVarNameY, coefficient: -1 }, ], type, constraint, ); } else throw Error('Unexpected!'); } else throw Error(`Unsupported axis ${axis}.`); // Prepare for next iteration firstPieceAxisValue = secondPieceAxisValue; } } // console.log("Model:", model); // Solve the Model console.time('Solved'); const solution = solve(model, { // Include variables that are zero in the solution. // We need piece coords even if they are at 0! includeZeroVariables: true, }); console.timeEnd('Solved'); console.log('Solution status:', solution.status); // The score of the solution. This is the sum of the furthest piece's X and Y coordinates. console.log('Result:', solution.result); if (solution.status !== 'optimal') { console.error('The unified solver could not find a feasible solution.'); throw new Error('Unified LP solver failed. Constraints may be contradictory.'); } // ==================================== Transformed Coordinate Assembly ==================================== // The solution object contains the solved X & Y positions for every single piece. // Extract all the variables. for (const [variableName, value] of solution.variables) { const [axis, pieceIndexStr] = (variableName as VariableName).split('-'); const pieceIndex = Number(pieceIndexStr); if (axis === 'x') { const sortedPieces = OrderedPieces['1,0']; const piece = sortedPieces[pieceIndex]!; // Set its transformed X coord. // piece.transformedCoords[0] = BigInt(value); piece.transformedCoords[0] = bd.fromNumber(value); } else if (axis === 'y') { const sortedPieces = OrderedPieces['0,1']; const piece = sortedPieces[pieceIndex]!; // Set its transformed Y coord. // piece.transformedCoords[1] = BigInt(value); piece.transformedCoords[1] = bd.fromNumber(value); } else throw Error('Unknown axis.'); } // Calculate the new, transformed range, for each group on each axis. // Needed for the moveexpander knows what group your move is targeting. for (const axisKey in AllAxisOrders) { const axisOrder = AllAxisOrders[axisKey as Vec2Key]; const axisDeterminer = AXIS_DETERMINERS_BD[axisKey as Axis]; for (const group of axisOrder) { let start: BigDecimal | null = null; let end: BigDecimal | null = null; // Iterate through the pieces in the group to find the min and max axis values. for (let i = 0; i < group.pieces.length; i++) { const piece = group.pieces[i]!; const axisValue = axisDeterminer(piece.transformedCoords as BDCoords); if (start === null || bd.compare(axisValue, start) < 0) start = axisValue; if (end === null || bd.compare(axisValue, end) > 0) end = axisValue; } // Set the calculated transformed range for the group. group.transformedRange = [start!, end!]; } } // [Optional] Shift the entire solution so that the White King is in its original spot! (Doesn't break the solution/topology) // ISN'T required for engines, but may be nice for visuals. // Commented-out for decreasing the script size. // RecenterTransformedPosition(pieces, AllAxisOrders); // Assemble the final compressed position from the solved piece's transformed coordinates. const compressedPosition: Map = new Map(); for (const piece of pieces) { // Add the final coordinate and piece type to our output map. // console.log("Piece type:", stringifyBDCoords(piece.transformedCoords as BDCoords), typeutil.debugType(piece.type)); // If the piece is an intersection, substitue a void for it, and round the coords to the nearest integer. if (piece.type === INTERSECTION_TYPE) { if (!bd.areCoordsIntegers(piece.transformedCoords as BDCoords)) continue; // Skip intersections that don't end up on integer coordinates. piece.type = typeutil.buildType(r.VOID, p.NEUTRAL); } else if (!bd.areCoordsIntegers(piece.transformedCoords as BDCoords)) throw Error('Piece did not end up on integer coordinates after compression.'); // Will round to the nearest integer, if it's an intersection. const intCoords: Coords = bd.coordsToBigInt(piece.transformedCoords as BDCoords); const transformedCoordsKey = coordutil.getKeyFromCoords(intCoords); compressedPosition.set(transformedCoordsKey, piece.type); } // Return the complete compression information, which is used to expand the chosen move, later. return { position: compressedPosition, axisOrders: AllAxisOrders, pieceTransformations: pieces, }; } function addIntersectionsToPieces( pieces: PieceTransform[], orthogonals: boolean, diagonals: boolean, hippogonals: boolean, numIntersections: number, ) { if (numIntersections <= 0) return; // No intersections to add console.log('Piece count before adding intersections:', pieces.length); const lineVectors: Vec2[] = []; if (orthogonals) lineVectors.push(...vectors.VECTORS_ORTHOGONAL); if (diagonals) lineVectors.push(...vectors.VECTORS_DIAGONAL); if (hippogonals) lineVectors.push(...vectors.VECTORS_HIPPOGONAL); const intersections: BDCoords[] = []; for (let a = 0; a < pieces.length; a++) { const pieceA = pieces[a]; // Eminate lines in all directions from the piece coords const pieceALines: LineCoefficientsBD[] = lineVectors.map((l) => vectors.getLineGeneralFormFromCoordsAndVecBD(pieceA.coords, l), ); for (let b = a + 1; b < pieces.length; b++) { const pieceB = pieces[b]; // Eminate lines in all directions from the piece coords const pieceBLines: LineCoefficientsBD[] = lineVectors.map((l) => vectors.getLineGeneralFormFromCoordsAndVecBD(pieceB.coords, l), ); // For each pair of lines, check if they intersect. for (const lineA of pieceALines) { for (const lineB of pieceBLines) { // Do they intersect? const intersection = geometry.calcIntersectionPointOfLinesBD( ...lineA, ...lineB, ); if (intersection === undefined) continue; // No intersections (parallel, or same line) // They DO intersect. // Don't push if the same intersection hasn't already been added. if (intersections.some((i) => coordutil.areBDCoordsEqual(i, intersection))) continue; // Also don't push if the intersection lies on the same square as any other piece. if (pieces.some((p) => coordutil.areBDCoordsEqual(p.coords, intersection))) continue; // Push! intersections.push(intersection); } } } } const intStrs: string[] = []; for (const intersection of intersections) { intStrs.push(stringifyBDCoords(intersection)); } console.log(`Found ${intersections.length} intersections: ` + intStrs.join(', ')); // Add the intersections as pieces to the pieces list. for (const intersection of intersections) { pieces.push({ type: INTERSECTION_TYPE, coords: intersection, transformedCoords: [undefined, undefined], // Initially undefined }); } console.log('Piece count after adding intersections:', pieces.length); } function stringifyBDCoords(coords: BDCoords): string { return `[${bd.toApproximateString(coords[0])}, ${bd.toApproximateString(coords[1])}]`; } // ========================================== MODEL HELPERS ========================================== /** * Returns a string we'll use for the variable name in the linear programming model. * @param axis - What axis this variable is for * @param index - The index of the piece in its sorted list. */ function getVariableName(axis: Axis, index: number): VariableName { const axisLetter = axis === '1,0' ? 'x' : axis === '0,1' ? 'y' : axis === '1,1' ? 'u' : axis === '1,-1' ? 'v' : (() => { throw Error('Unsupported axis.'); })(); return `${axisLetter}-${index}`; } function getConstraintName(varName: VariableName) { return `${varName}_constraint`; } /** * Helper for adding a constraint to the running linear programming model. * * Creates the variable in the model if it doesn't exist yet, adds the constraint, * and updates the variable's columns its included in. */ function addConstraintToModel( model: Model, constraint_name: string, columns: Column[], type: 'equal' | 'min' | 'max', value: number, ): void { // Add the equation model.constraints[constraint_name] = { [type]: value }; // Add the variables as columns to it for (const column of columns) { // Initialize first if not already if (!model.variables[column.variable]) model.variables[column.variable] = {}; // Include the variable in the column of the constraint function model.variables[column.variable][constraint_name] = column.coefficient; } } // ======================================== RECENTERING TRANFORMED POSITION ======================================== // ISN'T required for engines, but may be nice for visuals. // Commented-out for decreasing the script size. /** * Translates the entire transformed position so tht the White King * ends up on the same square it occupied in the original, uncompressed position. * This doesn't affect the solution or topology at all. * @param allPieces The list of all transformed pieces. * @param allAxisOrders The AxisOrders object containing all axis groups of the transformed position. */ function RecenterTransformedPosition(allPieces: PieceTransform[], allAxisOrders: AxisOrders) { // Define the type for a White King (you may need to import typeutil and players) const whiteKingType = typeutil.buildType(r.KING, p.WHITE); // 1. Find the White King in the list of pieces. const whiteKing: PieceTransform | undefined = allPieces.find((p) => p.type === whiteKingType); if (!whiteKing) { console.warn('Could not find White King to normalize position. Skipping translation.'); return; } // 2. Calculate the required translation vector (dx, dy). const transformedKingCoords = whiteKing.transformedCoords as Coords; const translationVector: Coords = [ whiteKing.coords[0] - transformedKingCoords[0], whiteKing.coords[1] - transformedKingCoords[1], ]; console.log( `Normalizing position by translating all pieces by [${translationVector[0]}, ${translationVector[1]}] to match White King's original position.`, ); // 3. Apply the translation to every piece's transformed coordinates. for (const piece of allPieces) { piece.transformedCoords[0]! += translationVector[0]; piece.transformedCoords[1]! += translationVector[1]; } // 4. Apply the same translation to all axes' groups' transformedRange. for (const axisKey in allAxisOrders) { const axisOrder = allAxisOrders[axisKey as Vec2Key]; const axisDeterminer = AXIS_DETERMINERS[axisKey]; // Calculate how the translationVector translates on this specific axis. // This is equivalent to axisDeterminer([dx, dy]) - axisDeterminer([0, 0]). const pushAmount = axisDeterminer(translationVector); for (const group of axisOrder) { if (group.transformedRange) { group.transformedRange[0] += pushAmount; group.transformedRange[1] += pushAmount; } } } } // ========================================= EXPORTS ========================================= export type { AxisOrders, PieceTransform }; export default { // Constants MIN_ARBITRARY_DISTANCE, AXIS_DETERMINERS, // Main Function compressPosition, }; ================================================ FILE: dev-utils/scripts/positionnormalizer/unusedpositionnormalizermethods.ts ================================================ // ======================================== ORTHOGONAL SOLVER ======================================== // /** // * On either the X or Y axis groups, initially sets each's transformedRange, // * and their pieces' transformed coordinates according to the position's // * orthogonal compressed solution. // */ // function TransformToOrthogonalSolution(axisOrder: AxisOrder, coordIndex: 0 | 1) { // let current: bigint = 0n; // for (const group of axisOrder) { // // Update the group's transformed range // const groupSize = group.range[1] - group.range[0]; // // Set the group's first draft transformed range. // group.transformedRange = [current, current + groupSize]; // // Update each piece's transformed coordinates // for (const piece of group.pieces) { // // Add the piece's offset from the start of the group // const offset = piece.coords[coordIndex] - group.range[0]; // piece.transformedCoords[coordIndex] = group.transformedRange![0] + offset; // } // // Increment so that the next group has what's considered an arbitrary spacing between them // current += MIN_ARBITRARY_DISTANCE + groupSize; // } // } // ======================================== HELPERS ======================================== // /** // * Calculates the amount a piece should be pushed to align with another piece. // * It returns zero if the minimum space requirement is met already. // */ // function getShortFall(v_requirement: SeparationRequirement, current_dv: bigint): bigint { // // --- 3. CALCULATE V-AXIS SHORTFALL --- // let v_shortfall = 0n; // if (v_requirement.type === 'exact') { // // If the requirement is exact, any deviation is a shortfall. // v_shortfall = v_requirement.separation - current_dv; // } else if (v_requirement.type === 'min') { // // If the requirement is a minimum, we only have a shortfall if we're below it. // if (current_dv < v_requirement.separation) { // v_shortfall = v_requirement.separation - current_dv; // } // } else if (v_requirement.type === 'max') { // // If the requirement is a maximum, we only have a shortfall if we're above it. // if (current_dv > v_requirement.separation) { // v_shortfall = v_requirement.separation - current_dv; // } // } // return v_shortfall; // } // /** // * Calculates the collapsible gap between a group and the group immediately following it on a given axis. // * This gives the amount the group can be pushed WITHOUT AFFECTING FOLLOWING GROUPS! // * @param axis - The orthogonal axis ('1,0' or '0,1') to measure the gap on. // * @param groupIndex - The index of the group to check how much it can be pushed. // * @returns The collapsible gap size as a non-negative bigint. Returns 0n if there is no collapsible space. // */ // function calculateCollapsableGap(axis: '1,0' | '0,1', AllAxisOrders: AxisOrders, groupIndex: number): bigint { // const axisOrder = AllAxisOrders[axis]; // const currentGroup = axisOrder[groupIndex]; // const nextGroup = axisOrder[groupIndex + 1]!; // // The gap is the space between the end of the current group and the start of the next, // // minus the required padding. This is the amount of space that a push can "collapse". // const gap = nextGroup.transformedRange![0] - currentGroup.transformedRange![1] - MIN_ARBITRARY_DISTANCE; // // The gap should never be negative in a valid state, but if it is, there's no collapsible space. // if (gap < 0n) throw Error("Overlapping groups!"); // Safety check // return gap; // } // /** // * Calculates the total empty space (the sum of all gaps) between two groups on a given orthogonal axis. // * The order of the group indices does not matter. // * @param axis - The orthogonal axis ('1,0' or '0,1') to measure the gap on. // * @param groupIndexA - The index of the first group. // * @param groupIndexB - The index of the second group. // * @returns The total gap size as a non-negative bigint. Returns 0n if the groups are adjacent or overlapping. // */ // function calculateGapBetweenGroups(axis: '1,0' | '0,1', AllAxisOrders: AxisOrders, groupIndexA: number, groupIndexB: number): bigint { // const axisOrder = AllAxisOrders[axis]; // // Ensure startIndex is the smaller of the two indices. // const startIndex = Math.min(groupIndexA, groupIndexB); // const endIndex = Math.max(groupIndexA, groupIndexB); // // If the groups are the same, there is no gap between them. // if (endIndex === startIndex) return 0n; // let totalGap: bigint = 0n; // // Iterate through the groups *between* startIndex and endIndex. // for (let i = startIndex; i < endIndex; i++) { // const currentGroup = axisOrder[i]; // const nextGroup = axisOrder[i + 1]; // // The gap is the space between the end of the current group and the start of the next, subtract the padding. // const gap = nextGroup.transformedRange![0] - MIN_ARBITRARY_DISTANCE - currentGroup.transformedRange![1]; // if (gap < 0n) throw Error("Gap is < 0!"); // Protection in case this bug ever happens. // totalGap += gap; // } // return totalGap; // } // VERSION THAT PUSHES ALL GROUPS AFTERWARD EQUALLY, WITHOUT ABSORBING GAPS // /** // * Pushes all groups on a given orthogonal axis from a starting index onwards by a specific amount. // * @param axisToPush // * @param axisOrder // * @param startingGroupIndex - This group and all following groups will be pushed by the same amount. // * @param pushAmount // * @param coordIndex // */ // function ripplePush(axisToPush: '1,0' | '0,1', AllAxisOrders: AxisOrders, startingGroupIndex: number, pushAmount: bigint) { // if (pushAmount <= 0n) throw Error(`Ripple push amount must be positive, got ${pushAmount}.`); // const coordIndex = axisToPush === '1,0' ? 0 : 1; // const axisOrder = AllAxisOrders[axisToPush]; // const word = axisToPush === '1,0' ? 'RIGHT' : 'UP'; // console.log(`Ripple pushing group of index ${startingGroupIndex} ${word} by ${pushAmount}...`); // for (let i = startingGroupIndex; i < axisOrder.length; i++) { // const groupToPush = axisOrder[i]; // pushGroup(groupToPush, pushAmount, coordIndex); // } // } // /** // * Pushes a given piece's group in the specified X/Y direction by a specific amount. // * If there are any gaps in the X/Y axis groups to be filled behind it, it will do so, // * otherwise, it will ripple push all groups in front of it, too. // * In other words, subsequent groups will only be pushed by enough to ensure there // * is no overlap between the last pushed group and them. // * @param axis - What X/Y axis to ripple push the groups on. // * @param firstPiece - This piece isn't pushed by the ripple, nor is its group. // * @param piece - The piece of which group we are GUARANTEED to push. We will see if its optimal to push groups immediately before it, but not firstPiece's group or prior. // * @param pushAmount - The amount to push the piece's group by. Subsequent groups will only be pushed enough to ensure there aren't any overlaps in groups. // * @param axisDeterminer - What AxisDeterminer to use to calculate the error with the push. NOT the same as the direction of the push!! // */ // function ripplePush( // axis: '1,0' | '0,1', // AllAxisOrders: AxisOrders, // piece: PieceTransform, // pushAmount: bigint, // ) { // if (pushAmount <= 0n) throw Error(`Ripple push amount must be positive, got ${pushAmount}.`); // const word = axis === '1,0' ? 'RIGHT' : 'UP'; // const coordIndex = axis === '1,0' ? 0 : 1; // const axisOrder = AllAxisOrders[axis]; // console.log(`Ripple pushing group of piece ${String(piece.transformedCoords)} ${word} by ${pushAmount}...`); // // Perform the mandatory push on the piece's group and contionally, subsequent groups. // // If subsequent groups can fill a gap in this axis, they will. They just don't like to overlap. // // We know this push is REQUIRED because it is the ONLY action that will satisfy // // the constraint between piece A and piece B! // // First, push the group of the piece that is mandatory to be pushed. // const mandatoryGroup = axisOrder[piece.axisGroups[axis]]; // pushGroup(mandatoryGroup, pushAmount, coordIndex); // // Next, we're going to iterate through all subsequent groups, // // IF THEY NOW OVERLAP with the last pushed group, we push // // them right too, by the minimum amount to make their range start // // line up with the range end of the last pushed group. // let lastPushedGroup = mandatoryGroup; // for (let i = piece.axisGroups[axis] + 1; i < axisOrder.length; i++) { // const groupToUpdate = axisOrder[i]; // // If the last pushed group and this group now overlap, we need to push this group too, // // enough so that it starts at the end of the last pushed group's range end. // if (groupToUpdate.transformedRange![0] < lastPushedGroup.transformedRange![1] + MIN_ARBITRARY_DISTANCE) { // // Calculate how much to push this group by so that it starts at the end of the last pushed group's range. // const pushAmount = lastPushedGroup.transformedRange![1] + MIN_ARBITRARY_DISTANCE - groupToUpdate.transformedRange![0]; // console.log(`Pushing next group by ${pushAmount} to avoid overlap.`); // pushGroup(groupToUpdate, pushAmount, coordIndex); // lastPushedGroup = groupToUpdate; // Update the last pushed group // } else { // // No more groups to push, as they are not overlapping anymore. // break; // } // } // } // /** // * Pushes a group by a specific amount in the X or Y direction, // * updating its transformed range and the transformed coordinates of all pieces in the group. // */ // function pushGroup(group: AxisGroup, pushAmount: bigint, coordIndex: 0 | 1) { // // Update the transformed range of this group // group.transformedRange![0] += pushAmount; // group.transformedRange![1] += pushAmount; // // Update the transformed coords of all pieces in this group // for (const pieceToPush of group.pieces) { // pieceToPush.transformedCoords[coordIndex]! += pushAmount; // } // } // /** // * Takes a push amount and returns the level of error it has (absolute value). // */ // function calculateError(pushAmount: bigint) { // return bimath.abs(pushAmount); // } // /** // * Calculates the sum of all errors on the board on a specific axis between every single pair of pieces. // * This gives one GRAND score where the higher the score, the more incorrect the pieces are relative // * to each other (on that axis), and a score of 0n means the pieces are positioned PERFECT // * relative to each other and no pushes are necessary anymore to satisfy all constraints between them. // */ // function calculateTotalAxisError(pieces: PieceTransform[], axisDeterminer: AxisDeterminer): bigint { // let totalError = 0n; // for (let i = 0; i < pieces.length; i++) { // const pieceA = pieces[i]; // for (let j = i + 1; j < pieces.length; j++) { // const pieceB = pieces[j]; // const axisDiff_Original = axisDeterminer(pieceA.coords) - axisDeterminer(pieceB.coords); // const axisDiff_Transformed = axisDeterminer(pieceA.transformedCoords as Coords) - axisDeterminer(pieceB.transformedCoords as Coords); // const pushAmount = calculatePushAmount(axisDiff_Original, axisDiff_Transformed); // totalError += calculateError(pushAmount); // } // } // return totalError; // } // /** // * Calculates the topology of the board on a specific diagonal axis. // * This is used for comparing against after doing some pushes // * to detect if we've starting infinite repeating. // * @param axis // * @param AllAxisOrders // */ // function calculateBoardTopology(pieces: PieceTransform[], axisDeterminer: AxisDeterminer): bigint[] { // const topology: bigint[] = []; // // Calculate the spacing between each pair of pieces on the board. // for (let i = 0; i < pieces.length; i++) { // const pieceA = pieces[i]; // const pieceA_AxisValue = axisDeterminer(pieceA.transformedCoords as Coords); // for (let j = i + 1; j < pieces.length; j++) { // const pieceB = pieces[j]; // const pieceB_AxisValue = axisDeterminer(pieceB.transformedCoords as Coords); // let axisDiff = pieceB_AxisValue - pieceA_AxisValue; // // Cap the axisDiff to the +-MIN_ARBITRARY_DISTANCE // axisDiff = bimath.clamp(axisDiff, -MIN_ARBITRARY_DISTANCE, MIN_ARBITRARY_DISTANCE); // topology.push(axisDiff); // } // } // return topology; // } ================================================ FILE: dev-utils/scripts/vertexdatatotexture.ts ================================================ /** * This script converts an array of vertex data into a renderable WebGL texture. * * TODO: * * POLISH AND CLEAN THIS UP. It's actually untested to I have no idea how this works. * * Add options for controlling whether mipmaps are enabled, and smoothing. * */ import { createBufferFromData } from "../../src/client/scripts/esm/game/rendering/buffers"; /** * Converts a shape, from its vertex data, to a renderable webgl texture. * @param gl - The webgl rendering context * @param vertexData - The vertex data of the shape to create a texture from. Stride length 6 (2 position, 4 color). * The positional data should be between 0-1 * @returns The renderable webgl texture */ function convertVertexDataToTexture(gl: WebGL2RenderingContext, vertexData: number[]): WebGLTexture { const stride = 6; // Each vertex has 2 values for the x & y position, and 4 for the color const resolution = 500; // 500px by 500px if (vertexData.length % stride !== 0) throw new Error('Vertex data not divisible by stride when converting to texture.'); // Create and bind a framebuffer const framebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); // Create a texture to render to const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.generateMipmap(gl.TEXTURE_2D); // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR); // DEFAULT if not set. Jagged edges, mipmap interpollation (never blurry, though always jaggy) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // Smooth edges, mipmap interpollation (half-blurry all the time, EXCEPT with LOD bias) // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); // Smooth edges, mipmap snapping (clear on some zoom levels, full blurry at others) // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_NEAREST); // Jagged edges, mipmap snapping (jagged all the time) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // Magnification, smooth edges (noticeable when zooming in) // Set texture parameters gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // Attach the texture to the framebuffer gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); // Check framebuffer completeness if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { throw new Error("Framebuffer is not complete"); } const vbo = createBufferFromData(new Float32Array(vertexData)); // Assume shaders and program are already set up // Attributes: aPosition (vec2) at location 0, aColor (vec4) at location 1 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, 0); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride * Float32Array.BYTES_PER_ELEMENT, 2 * Float32Array.BYTES_PER_ELEMENT); gl.enableVertexAttribArray(1); // Set viewport to match the texture resolution gl.viewport(0, 0, resolution, resolution); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); // Clear and render the shape to the texture gl.clearColor(0.0, 0.0, 0.0, 0.0); // Transparent background gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, vertexData.length / 6); // Generate mipmaps gl.bindTexture(gl.TEXTURE_2D, texture); gl.generateMipmap(gl.TEXTURE_2D); gl.bindTexture(gl.TEXTURE_2D, null); // Unbind framebuffer to return to default rendering gl.bindFramebuffer(gl.FRAMEBUFFER, null); // Return the generated texture return texture!; } export { convertVertexDataToTexture }; ================================================ FILE: dev-utils/shaders/texture/instanced/tint/fragment.glsl ================================================ #version 300 es precision highp float; in vec2 vTextureCoord; // From vertex shader uniform sampler2D u_sampler; // Texture sampler uniform vec4 uTintColor; // Universal tint color out vec4 fragColor; // Output color void main() { // Sample texture with LOD bias and apply universal tint vec4 texColor = texture(u_sampler, vTextureCoord, -0.5); fragColor = texColor * uTintColor; } ================================================ FILE: dev-utils/shaders/texture/instanced/tint/vertex.glsl ================================================ #version 300 es // This shader is capable of tinting all textures // a specific color via a uniform in vec4 a_position; // Per-vertex position in vec2 a_texturecoord; // Per-vertex texture coordinates in vec3 a_instanceposition; // Per-instance position offset uniform mat4 u_transformmatrix; // Transformation matrix out vec2 vTextureCoord; // To fragment shader void main() { // Apply instance position offset vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0); // Transform position and pass through texture coords gl_Position = u_transformmatrix * offsetPosition; // Pass texture coordinates to fragment shader vTextureCoord = a_texturecoord; } ================================================ FILE: dev-utils/shaders/texture/tint/fragment.glsl ================================================ `#version 300 es precision highp float; in vec2 vTextureCoord; uniform vec4 uTintColor; uniform sampler2D u_sampler; out vec4 fragColor; void main(void) { fragColor = texture(u_sampler, vTextureCoord, -0.5) * uTintColor; // Apply a mipmap LOD bias so as to make the textures sharper. } ================================================ FILE: dev-utils/shaders/texture/tint/vertex.glsl ================================================ #version 300 es // This shader is capable of tinting all textures // a specific color via a uniform in vec4 a_position; in vec2 a_texturecoord; uniform mat4 u_transformmatrix; out vec2 vTextureCoord; void main(void) { gl_Position = u_transformmatrix * a_position; vTextureCoord = a_texturecoord; } ================================================ FILE: dev-utils/shaders/voronoi/fragment.glsl ================================================ #version 300 es precision highp float; // This shader was replaced by a voronoi_distortion shader // for the Echo Rift zone effect. I am not 100% sure this // shader is working as is, nor polished. But if not, its // arithmetic could be modeled after the voronoi_distortion // shader to produce results and tileable randomness as desired. // Uniforms for customization uniform vec2 u_resolution; uniform float u_time; uniform float grid_density; // Controls the density of points uniform float evolution_strength; // 0.0 for static, higher for more movement // The brightness range for the voronoi effect // 0.0 = black, 1.0 = original brightness, >1.0 = brighter uniform float u_min_brightness; uniform float u_max_brightness; // The input texture uniform sampler2D u_texture; in vec2 v_uv; out vec4 fragColor; // 2D pseudo-random function vec2 random_2d(vec2 p) { return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453); } // 3D noise function to get displacement values // It returns a vec2 for x and y displacement vec2 noise_3d_to_2d(vec3 p) { float x = fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); float y = fract(sin(dot(p, vec3(269.5, 183.3, 246.3))) * 43758.5453); return vec2(x, y); } void main() { // --- VORONOI CALCULATION --- // Normalize coordinates and adjust for aspect ratio vec2 uv = gl_FragCoord.xy / u_resolution.xy; float aspect_ratio = u_resolution.x / u_resolution.y; uv.x *= aspect_ratio; // Scale coordinates by density vec2 uv_scaled = uv * grid_density; // Get the integer and fractional parts of the coordinate vec2 cell_index = floor(uv_scaled); vec2 fractional_coord = fract(uv_scaled); float min_dist = 1.0; // Initialize with a large value // Loop through a 3x3 grid of neighboring cells for (int i = -1; i <= 1; i++) { for (int j = -1; j <= 1; j++) { vec2 neighbor_cell = vec2(float(i), float(j)); vec2 point_position = cell_index + neighbor_cell; // Generate a random, stable offset for the point in the cell vec2 point_offset = random_2d(point_position); // Animate the point using 3D noise // The third dimension is time, allowing the noise to evolve vec3 noise_input = vec3(point_position, u_time * 0.1); // Get a displacement vector from the noise function // Map noise from [0, 1] to [-1, 1] vec2 displacement = (noise_3d_to_2d(noise_input) - 0.5) * 2.0; // The final animated point position vec2 animated_point = neighbor_cell + point_offset + displacement * evolution_strength; // Calculate distance from the current fragment to the animated point float dist = distance(fractional_coord, animated_point); // Keep the minimum distance min_dist = min(min_dist, dist); } } // --- TEXTURE AND BRIGHTNESS MODIFICATION --- // The final color is the distance, clamped to ensure it's between 0 and 1 // 1. Get the final greyscale voronoi value float voronoi_value = smoothstep(0.0, 1.0, min_dist); // 2. Sample the texture using the object's own UVs from the vertex shader vec4 texture_color = texture(u_texture, v_uv); // 3. Map the voronoi value to your desired brightness range float brightness_factor = mix(u_min_brightness, u_max_brightness, voronoi_value); // 4. Modify the texture color's brightness vec3 final_rgb = texture_color.rgb * brightness_factor; // --- [OPTIONAL] Add Red Glow Near Points --- // This block adds a red glow to the darkest areas (pockets). // To disable, just comment out this entire block. // 1. Define the glow color and its intensity. You can tweak these values. const vec3 glow_color = vec3(1.0, 0.0, 0.0); const float glow_intensity = 0.25; // How strong the glow is // 2. Calculate a "glow factor" based on the distance to the nearest point. // smoothstep(edge1, edge0, x) creates a smooth inverse falloff. // It's 1.0 when min_dist is at 0.0, and fades to 0.0 as min_dist approaches 0.15. float glow_factor = smoothstep(0.15, 0.0, min_dist); // 3. Add the glow to the final color using an additive blend. // The glow is strongest in the pockets and has no effect elsewhere. final_rgb += glow_color * glow_intensity * glow_factor; // 5. Output the final color fragColor = vec4(final_rgb, texture_color.a); } ================================================ FILE: dev-utils/sounds/SoundscapeGenerator.html ================================================ Ambient Synth & Soundscape Generator

Ambient Synth & Soundscape Generator

Global Settings
Config Actions
{}

Layers

================================================ FILE: dev-utils/spritesheet_generator/spritesheet.ts ================================================ // dev-utils/spritesheet_generator/spritesheet.ts /** * This script stores the spritesheet FOR THE CURRENT GAME, * and all the piece's texture coordinates within it. * * If no game is loaded, no spritesheet is loaded. */ import type { Board } from '../../../../../shared/chess/logic/gamefile.js'; import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import imagecache from '../../chess/rendering/imagecache.js'; import { GameBus } from '../GameBus.js'; import TextureLoader from '../../webgl/TextureLoader.js'; import { generateSpritesheet } from '../../chess/rendering/spritesheetGenerator.js'; // Types -------------------------------------------------------------------------------- /** A bounding box storing texture coords info. */ interface TextureData { texleft: number; texbottom: number; texright: number; textop: number; } // Variables --------------------------------------------------------------------------- /** * The spritesheet texture for rendering the pieces of the current game. * * Using a spritesheet instead of 1 texture for each piece allows us to * render all the pieces with a single mesh, and a single texture. */ let spritesheet: WebGLTexture | undefined; // Texture. Grid containing every texture of every piece, black and white. /** * Contains where each piece is located in the spritesheet (texture coord). * Texture coords of a piece range from 0-1, where (0,0) is the bottom-left corner. */ let spritesheetData: | { /** The width of each texture in the whole spritesheet, as a fraction. */ pieceWidth: number; /** * The texture locations of each piece type in the spritesheet, * where (0,0) is the bottom-left corner of the spritesheet, * and the coordinates provided are the bottom-left corner of the corresponding type. */ texLocs: { [type: number]: DoubleCoords }; } | undefined; // Events --------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { deleteSpritesheet(); }); // Functions --------------------------------------------------------------------------- function getSpritesheet(): WebGLTexture { if (!spritesheet) throw new Error('Should not be getting the spritesheet when not loaded!'); return spritesheet; } function getSpritesheetDataPieceWidth(): number { if (!spritesheetData) throw new Error('Should not be getting piece width when the spritesheet is not loaded!'); return spritesheetData.pieceWidth; } function getSpritesheetDataTexLocation(type: number): DoubleCoords { if (!spritesheetData) throw new Error( 'Should not be getting texture locations when the spritesheet is not loaded!', ); if (!spritesheetData!.texLocs[type]) throw new Error('No texture location for piece type: ' + type); return spritesheetData!.texLocs[type]; } /** Loads the spritesheet texture we'll be using to render the provided gamefile's pieces */ async function initSpritesheetForGame(gl: WebGL2RenderingContext, boardsim: Board): Promise { // Filter our voids from all types in the game. const types: number[] = boardsim.existingTypes.filter( (type) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type)), ); // Convert each SVG element to an Image const readyImages: HTMLImageElement[] = types.map((t) => imagecache.getPieceImage(t)); const spritesheetAndSpritesheetData = await generateSpritesheet(gl, readyImages); // console.log(spritesheetAndSpritesheetData.spritesheetData); // Optional: Append the spritesheet to the document for debugging // spritesheetAndSpritesheetData.spritesheet.style.display = 'none'; // document.body.appendChild(spritesheetAndSpritesheetData.spritesheet); // Load the texture into webgl and initiate our spritesheet // data that contains the texture coordinates of each piece! spritesheet = TextureLoader.loadTexture(gl, spritesheetAndSpritesheetData.spritesheet, { mipmaps: true, }); spritesheetData = spritesheetAndSpritesheetData.spritesheetData; } /** * Call when the gameslot unloads the gamefile. * The spritesheet and data is no longer needed. */ function deleteSpritesheet(): void { spritesheet = undefined; spritesheetData = undefined; } // Generating Texture Data For Going Into A Mesh ---------------------------------------------------- /** * Returns the texture data of a piece type. */ function getTexDataOfType(type: number, rotation: number = 1): TextureData { const texLocation: DoubleCoords = getSpritesheetDataTexLocation(type); const texWidth: number = getSpritesheetDataPieceWidth(); return getTexDataFromLocationAndWidth(texLocation, texWidth, rotation); } /** * Returns the texture data of a a single instance with texcoords [0,0]. * THE INSTANCE-SPECIFIC data needs to further contain texcoord offsets! */ function getTexDataGeneric(rotation = 1): TextureData { const texLocation: DoubleCoords = [0, 0]; const texWidth: number = getSpritesheetDataPieceWidth(); return getTexDataFromLocationAndWidth(texLocation, texWidth, rotation); } /** * Returns the texture data from a given texture location and width. */ function getTexDataFromLocationAndWidth( texLocation: DoubleCoords, texWidth: number, rotation = 1, ): TextureData { const texleft = texLocation[0]; const texbottom = texLocation[1]; if (rotation === 1) { // Regular rotation return { texleft, texbottom, texright: texleft + texWidth, textop: texbottom + texWidth, }; } else { // Inverted rotation return { texleft: texleft + texWidth, texbottom: texbottom + texWidth, texright: texleft, textop: texbottom, }; } } // Exports ------------------------------------------------------------------- export default { initSpritesheetForGame, getSpritesheet, getSpritesheetDataPieceWidth, getSpritesheetDataTexLocation, // Texture Data getTexDataOfType, getTexDataGeneric, }; ================================================ FILE: dev-utils/spritesheet_generator/spritesheetGenerator.ts ================================================ // dev-utils/spritesheet_generator/spritesheetGenerator.ts /** * This script takes a list of images, and converts it into a renderable * spritesheet, also returning the texture locations of each image. */ import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; type SpritesheetData = { /** A fraction 0-1 representing what percentage of the total spritesheet's width one piece takes up. */ pieceWidth: number; texLocs: { [key: number]: DoubleCoords }; }; /** * The preferred image width each pieces image in a spritesheet should be. * This may be a little higher, in order to make the spritesheet's total width a POWER OF 2. * BUT, the spritesheet's width will NEVER exceed WebGL's capacity! */ const PREFERRED_IMG_SIZE = 512; /** * Generates a spritesheet from an array of HTMLImageElement objects. * The spritesheet is created by arranging the images in the smallest square grid. * Each image is placed in a grid of 512x512px. * @param gl - The webgl rendering context that will be rendering this spritesheet. We need this to determine the maximum-supported size. * @param images - An array of HTMLImageElement objects to be merged into a spritesheet. * @returns A promise that resolves with the generated spritesheet as an HTMLImageElement. */ async function generateSpritesheet( gl: WebGL2RenderingContext, images: HTMLImageElement[], ): Promise<{ spritesheet: HTMLImageElement; spritesheetData: SpritesheetData }> { // Ensure there are images provided if (images.length === 0) throw new Error('No images provided when generating spritesheet.'); // Calculate the grid size: Find the smallest square grid to fit all images const numImages = images.length; const gridSize = Math.ceil(Math.sqrt(numImages)); // Square root of number of images, rounded up const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); // Naviary's is 16,384 /** * The actual maximum size each image could be before exceeding web GL's boundaries. * This is not how big we actually want to render the textures because we prefer they be 512x512. */ const maxImgSizePerMaxTextureSize = maxTextureSize / gridSize; const spritesheetSizeIfPreferredImgSizeUsed = roundUpToNextPowerOf2( PREFERRED_IMG_SIZE * gridSize, ); // Round up to nearest power of 2 const actualImgSizeIfUsingPreferredImgSize = spritesheetSizeIfPreferredImgSizeUsed / gridSize; /** Whichever is smaller of the two */ const actualImgSize = Math.min( actualImgSizeIfUsingPreferredImgSize, maxImgSizePerMaxTextureSize, ); // Calculate the total width and height of the canvas (spritesheet) const canvasWidth = gridSize * actualImgSize; const canvasHeight = gridSize * actualImgSize; // Create a canvas element for the spritesheet const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; const ctx = canvas.getContext('2d'); if (ctx === null) throw new Error('2D context null.'); // Positioning variables let xIndex = 0; let yIndex = 0; // Draw all the images onto the canvas for (let i = 0; i < numImages; i++) { const x = xIndex * actualImgSize; const y = yIndex * actualImgSize; // Draw the image at the current position ctx.drawImage(images[i]!, x, y, actualImgSize, actualImgSize); // Update the position for the next image xIndex++; if (xIndex === gridSize) { xIndex = 0; yIndex++; } } // Create an HTMLImageElement from the canvas const spritesheetImage = new Image(); spritesheetImage.src = canvas.toDataURL(); // Return a promise that resolves when the image is loaded await spritesheetImage.decode(); const spritesheetData = generateSpriteSheetData(images, gridSize); return { spritesheet: spritesheetImage, spritesheetData }; } /** * Generates the sprite sheet data (texture coordinates and width) for each image. * @param images - An array of HTMLImageElement objects to be merged into a spritesheet. * @param gridSize - How many images fit one-way. * @returns A sprite data object with texture coordinates for each image. */ function generateSpriteSheetData(images: HTMLImageElement[], gridSize: number): SpritesheetData { const pieceWidth = 1 / gridSize; const texLocs: { [key: number]: DoubleCoords } = {}; // Positioning variables let x = 0; let y = 0; // Loop through the images to create the sprite data images.forEach((image) => { const texX = x / gridSize; const texY = 1 - (y + 1) / gridSize; // Store the texture coordinates // Use the image id as the key for the data object texLocs[Number(image.id)] = [texX, texY]; // Update the position for the next image x++; if (x === gridSize) { x = 0; y++; } }); return { pieceWidth, texLocs, }; } /** * Rounds up the given number to the next lowest power of two. * * Time complexity O(1), because bitwise operations are extremely fast. * @param num - The number to round up. * @returns The nearest power of two greater than or equal to the given number. */ function roundUpToNextPowerOf2(num: number): number { if (num <= 1) return 1; // Handle edge case for numbers 0 and 1 num--; // Step 1: Decrease by 1 to handle numbers like 8 num |= num >> 1; // Step 2: Propagate the most significant bit to the right num |= num >> 2; num |= num >> 4; num |= num >> 8; num |= num >> 16; // Additional shift for 32-bit numbers return num + 1; // Step 3: Add 1 to get the next power of 2 } export { generateSpritesheet }; ================================================ FILE: docs/COPYING.md ================================================ # Copying Infinite Chess Any file in this project that does not state otherwise and is not listed as an exception below is part of Infinite Chess and copyright © 2023-2026 Naviary (InfiniteChess.org). Infinite Chess is free software; you can redistribute and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. Any works derived from this software must also be released under the same license. Infinite Chess is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.en.html) for more details. See [LICENSE](../LICENSE) for a copy of the GNU Affero General Public License. ## Exceptions (free) | Files | Author(s) | License | | --------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | dev-utils/pieces/themes/cburnett | [Cburnett](https://en.wikipedia.org/wiki/User:Cburnett) | [CC BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/) | | dev-utils/pieces/themes/green_chess | [Green Chess](https://greenchess.net/index.php) | [CC BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/) | | dev-utils/pieces/themes/stockfish | [Stockfish](https://github.com/official-stockfish/Stockfish) | [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) | | dev-utils/pieces/themes/pychess | [Pychess](https://github.com/pychess/pychess) | [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) | | dev-utils/sounds/lichess | [Lichess](https://github.com/lichess-org/lila) | [AGPL v3.0](https://www.gnu.org/licenses/#AGPL) | | dev-utils/sounds/fesliyan_studios | [Fesliyan Studios](https://www.fesliyanstudios.com/) | No credit required, but cannot be reposted elsewhere for download | | dev-utils/scripts/gl-matrix.js | Brandon Jones and Colin MacKenzie IV | [MIT](https://opensource.org/license/mit) | | src/client/scripts/esm/chess/logic/icn/icnconverter.ts | [Andreas Tsevas](https://github.com/tsevasa) and [Naviary](https://github.com/Naviary2) | [Unlicense](https://en.wikipedia.org/wiki/Unlicense) | | src/client/scripts/esm/chess/logic/icn/icncommentutils.ts | [Andreas Tsevas](https://github.com/tsevasa) and [Naviary](https://github.com/Naviary2) | [Unlicense](https://en.wikipedia.org/wiki/Unlicense) | ================================================ FILE: docs/GRAPHICS.md ================================================ # Graphics Rendering Guide [← Back to Navigation Guide](./NAVIGATING.md) | [Contributing Guide](./GUIDELINES.md) This guide explains how graphics rendering works on the board, and how to add new visuals. An infinite board provides a few unique considerations to the rendering system than typical 2D games. All visuals are rendered with raw WebGL for maximum control. No external libraries are used, like for example Three.js. ## Coordinate Spaces There are two coordinate spaces to know of: ### Grid Space (Coord/Model Space) Grid space uses integer coordinates where each unit is one chess square. The origin of a square is its center, so the coordinate `[3n, 5n]` refers to the middle of the square at (3,5). Piece rendering uses this coordinate space, including square highlights. When decimal precision is needed on top of BigInts (like knowing, for example, the exact coordinate the edges of the screen are at) we use **BigDecimals** from `@naviary/bigdecimal`, a custom designed number package that adds decimal precision to arbitrarily large coordinates. The package provides fully type safe arithmetic methods for working with them when needed. The bounding box of the screen over the grid space can be retrieved with `boardtiles.gboundingBoxFloat()` (decimal precision) or `boardtiles.gboundingBox()` (rounded away from screen center to next integer coordinates). ### World Space World space is the coordinate system the GPU and camera use. The camera is fixed at `[0, 0, 12]` at all times, looking straight down at the board, while the board moves and scales underneath it. The board spans the entire X/Y plane, and the Z axis is away from the board (or up when in perspective mode). This is the final coordinate space all vertex data is converted to before rendering. The center of the screen is always `[0, 0]` in world space. The bounding box of the screen can be retrieved with `camera.getRespectiveScreenBox()`, which automatically expands the box to the horizon when in perspective mode. Panning/zooming the board has no effect on this box's coordinates, only resizing the window does. The horizon is `1500` (chebyshev) world space units away from the center of the screen, anything beyond that gets clipped. For this reason, arbitrarily large grid-space coordinates _always_ have to be converted to world space before rendering, and clamped to that range, to prevent visual artifacts. ### Converting Between Spaces [`space.ts`](../src/client/scripts/esm/game/misc/space.ts) provides key conversion functions for converting from one coordinate space to the other. - `convertCoordToWorldSpace(coords)` — Grid → World. You may first have to cast BigInt coords to BigDecimal coords via `bdcoords.FromCoords(coords)`. - `convertWorldSpaceToCoords(worldCoords)` — World → Grid (includes decimal precision). - `convertWorldSpaceToCoords_Rounded(worldCoords)` — World → Grid, returning the integer tile coordinates the world space position is over. [`mouse.ts`](../src/client/scripts/esm/game/misc/mouse.ts) can be used to locate the mouse position in either coordinate space. - `getMouseWorld()` — Mouse position in world space. - `getTileMouseOver_Float()` — Mouse position in grid space (with decimal precision). - `getTileMouseOver_Integer()` — Mouse position in grid space, returning the integer tile coordinates the mouse is over. ## Creating Vertex Data All geometry rendered to the screen starts as an array of vertex data. Each vertex contains its attributes packed sequentially—position components first, then optionally color and/or texture coordinates. So for example, the vertex data of a red line from (-1,0) to (1,0) would be: ```ts // prettier-ignore const vertexData = [ // x, y, r, g, b, a -1, 0, 1, 0, 0, 1, // Vertex 1 1, 0, 1, 0, 0, 1, // Vertex 2 ]; ``` The exact attributes you include in the vertex data depends on the shader you plan on rendering your object with, and whether you're using instanced rendering. More info below. ### Primitives [`primitives.ts`](../src/client/scripts/esm/game/rendering/primitives.ts) provides many helpers for calculating the vertex data of various shapes: squares, rectangles, circles, etc. from just their dimensions and color. ### Instanced Shape Data [`instancedshapes.ts`](../src/client/scripts/esm/game/rendering/instancedshapes.ts), if you're using instanced rendering (which is a lot simpler to create vertex & instance data for, if you're rendering many copies of the same shape), provides helpers for obtaining the vertex data of the shape you want to render: legal move square, dot, special rights plus sign, etc. If you use instanced rendering, you bypass the need to calculate instance-specific vertex data, often only needing to specify the position offset of each of your objects in the instance data. This is used by piece rendering inside [`piecemodels.ts`](../src/client/scripts/esm/game/rendering/piecemodels.ts) (that example renders textures), and by legal move model generation inside [`legalmovemodel.ts`](../src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts). ### Mesh Helpers [`meshes.ts`](../src/client/scripts/esm/game/rendering/meshes.ts) provides higher-level helpers for automatically generating the vertex data for you if all you have is the integer coordinate and color of the square you want vertex data for. It can also convert a grid space bounding box into world space for you. ### Square Highlights For the common task of highlighting squares on the board, [`squarerendering.genModel()`](../src/client/scripts/esm/game/rendering/highlights/squarerendering.ts) is high-level helper that internally handles the vertex data and instance data creation for you from just a list of integer coordinates and a color, returning a ready-to-render object. ## Rendering Vertex Data Once you have vertex data, pass it to [`createRenderable()`](../src/client/scripts/esm/webgl/Renderable.ts) or [`createRenderable_Instanced()`](../src/client/scripts/esm/webgl/Renderable.ts) to create a GPU-ready object that can instantly be rendered. They accept arguments for vertex data, instance data (if using instanced rendering), information on how you packed your vertex data with the position & color attributes, the drawing mode to use ('TRIANGLES', 'LINES', etc.), and the name of the shader you want to use (see options below). The returned `Renderable` object has a `render()` property for instantly rendering it. If you generated your vertex data in world space, you don't have to specify transformation arguments when rendering for the item to appear in the correct place. If however your vertex data is in grid space (which is common for instance rendering), you should provide the `position` and `scale` arguments when rendering. Position is dependent on the board position (`meshes.getModelPosition()`), and scale is dependant on the board scale (`boardpos.getBoardScaleAsNumber()`). The render method uses these to automatically transform the points to world space when rendering. The `Renderable` object also has properties for updating its vertex/instance data internally, allowing you the option to skip generating a whole new Renderable every single frame. This is optimal when you have arbitrarily many objects to render, and their positions change infrequently. [`piecemodels.ts`](../src/client/scripts/esm/game/rendering/piecemodels.ts) for example does this when updating the model of the piece sprites. ## Shader Picking Different shaders are compatible with different ways of packing vertex data. Some are compatible with rendering colored vertices, some with textured vertices, and another with both. There are many shaders the game uses, many custom made for specific object rendering, but here are the most common we use: | Shader Name | Vertex Data Packing | Instance Data Packing | When to Use | | -------------------- | ------------------------- | --------------------- | -------------------------------------------- | | `'color'` | position + color | - | Solid colored shapes | | `'colorInstanced'` | position + color | position | Solid colored shapes via instanced rendering | | `'texture'` | position + texture coords | - | Textured shapes | | `'textureInstanced'` | position + texture coords | position | Textured shapes with via instanced rendering | Other shaders can allow for more unique properties for each instance, such as `'arrows'` for the indicator arrows rendering, which allows a unique position, color (for opacity), and rotation, per arrow instance, or `'starfield'` which allows a unique position, color, and size, for each animated star. For a full list of available shaders and their compatible vertex data packing, see [`ProgramManager.ts`](../src/client/scripts/esm/webgl/ProgramManager.ts). ## Integrating Into the Render Loop The render loop lives in `game.ts`. The `renderScene()` function renders all items in the order: 1. **Background** — Starfield / void rendering (uses masking) 2. **Board** — Infinite tile grid, promotion lines 3. **Below-piece overlays** — Square highlights, rays, check indicators, legal move highlights 4. **Pieces** — All piece sprites 5. **Above-piece overlays** — Arrows, animations, crosshair Call your script's render method in the appropriate section. ## Conclusion Ultimately, always refer to how the existing code renders objects for inspiration for rendering your own! ================================================ FILE: docs/GUIDELINES.md ================================================ # Pull Request Requirements and Guidelines [← Back to Navigation Guide](./NAVIGATING.md) | [Setup Guide](./SETUP.md) | [Graphics Guide](./GRAPHICS.md) ### All pull requests should only add **one** feature, fix **one** bug, or perform **one** refactoring. If your changes affect more than one feature, it **must** be refactored into multiple pull requests. If those additional PRs would depend on the code of the first PR, you must wait until the first one is merged before opening the additional ones. To avoid this, while you wait, try to work on features that have no overlap in the codebase, thus allowing multiple PRs at once. ### Title & Description Titles must be clear to understand. Description guidelines are in the automatic template when opening a new pull request. ### Scopes you should NOT submit pull requests for: Only Naviary should make these types of changes (but you may request me to do so): Adding/removing package dependancies. Type or variable renames spanning several files (time consuming for me to review, but taking one minute to make the changes myself). Massive refactors covering dozens of files in the codebase, unless it's required to fulfill the prompt. ## Code Standards > [!NOTE] > Any guidelines automatically enforced via our linter, prettifier, type checker, and builder, are not listed here. Fix them as you encounter them. The use of AI to help you write and modify code is permitted, but you must carefully review and polish its output to ensure the quality of the code meets all standards of the project! Keep all coding languages to their respective files. For example, shader code goes inside `.glsl` files, and html goes inside `.html` or `.ejs` files, not scripts. `// prettier-ignore`s are permitted to bypass the prettifier, for any one code block, if you're style is easier to read. ### No code duplication There may not be any code redundancy. Always refactor to the simplest way things can be expressed. Use as many prexisting helper methods in the codebase as possible. At times, you may have to refactor out helpers out of existing codebase functions in order to satisfy this. No dead code or functions that are never called. ### Avoid Complexity Don't add unnecessary complexity. Use the minimum amount of code & features needed to get the job done. 1. Identify the requirements for adding a new feature. 2. Identify where the website currently lacks in those requirements. 3. Make the **minimum** changes necessary to fulfill those requirements. Start minimal. Sometimes requirements may not be fully known until halfway through implementation. Start small and only increase requirements when needed. ### Type Safety All new scripts are required to be written in TypeScript, vs JavaScript. To retain maximum type safety, no casting via `as` is permitted, only in rare circumstances when it is not simple to get typescript to infer the type, and we are 100% confident of the type. Try to use generics where you can. No `// ts-ignore`s are permitted, either. For arguments defined by user input, or needing to be sanitized from the client, use the `zod` package to achieve full type safety. For all methods that accept a function callback for an argument, like `map()`, `filter()`, `forEach()`, `setTimeout()`, etc., to obtain type safety, don't pass in the function directly, but use a wrapper. For example, don't do `array.map(functionCallback)`, but do do `array.map((item) => functionCallback(item))`. The exception is when adding callbacks for event listeners, as we have to retain the reference to the original function in order to remove the listener later. ### No magic strings There must be no magic strings. All precise strings that are used in multiple locations must be stored in a constant variable. A string is considered magic if changing it in one place, but not everywhere else, would create a bug. ### Single Responsibility Principle (SRP) Each script should have one responsibility only. If it has multiple, you **must** refactor it into multiple scripts. Be aware of context. A script in charge reading and managing the pieces inside the gamefile should not be in charge of knowing the fallback bounding box of the pieces, if there are none. Remember, one responsibility per script. ### Target the Root Cause Do not opt for "band-aid" patches for bugs that only patch symptoms. Bugs are a sign of something not working how it's designed to. Find the root cause, patch that. ### Functions Should have one single purpose. If it does multiple things, refactor it out into multiple functions. Aim for under 40 lines. Require atleast one sentence of JSDoc. Do not make the documentation too verbose. Arguments only need documentation if it is not common sense what they would be for, or what we should pass in for them (for example, `boardsim` is common sense and doesn't require documentation), or if they don't provide any additional information than what's already in the function description. Function bodies should also have comments for documentation, to help understand what it's doing and how it works. Don't be too verbose. ### Imports Opt for using `import type` over `import`, when an import is only used for its type. Imports are automatically ordered according to the project standard when committing changes. ### Exports In general, use default exports (e.g. `export default { ... }`) over normal exports `export { ... }`. This reduces global scope pollution. The only exception is when a script has only one exported function, then it may export that function normally. ## Naming All files, types, and variable names should have clear and easy to understand names. When writing names, keep context in mind. For example, a script whos responsibility is to save board editor positions should not be named `save.ts`, as `save` doesn't infer any context about the board editor. A better name is `editorsave.ts`, or `esave.ts` for short. Another example: If a script named `guinavigation.ts` is using default exports, and we're writing a function that opens the navigation UI, then choose `open()` for the function name instead of say, `openNavigationUI()`, as for the latter, external application code calling the function would look like `guinavigation.openNavigationUI()`, which duplicates the needed context, vs the simpler `guinavigation.open()`. ### Casing **Scripts**: Use either lowercase (e.g. `boardutil.ts`) or PascalCase (e.g. `AudioManager.ts`), depending on how universally professional and reusable it is. If it's scope could only ever be used in our repository and game, use lowercase. If it could be pulled out and reused in other projects without significant refactoring, use PascalCase. **Types**: Use PascalCase (e.g. `OrganizedPieces`) **Constants**: Use UpperSnakeCase (e.g. `SOUND_OFFSET`). **Variables**: Use either CamelCase (e.g. `playerColor`) or SnakeCase (e.g. `player_color`), depending on what the script you are working on is using more apparently. Remaining consistent is trump: if many other scripts create a local variable named `timeoutId`, choose that for your local variable name instead of `timeout_id`. ================================================ FILE: docs/NAVIGATING.md ================================================ # Navigation Guide This guide gives you several pointers on the project structure, to help you get started with collaborating! [← Back to Setup Guide](./SETUP.md) | [Contributing Guide](./GUIDELINES.md) | [Graphics Guide](./GRAPHICS.md) It is assumed you have already gone through the Setup Guide. ## Terminal Output After starting up the server via `npm run dev`, there are a few different processes that run in parallel. The green `[build]` is in charge of compiling all scripts into javascript, bundling them together, and copying them into the `dist/` directory, along with all other client assets. This directory is deleted and rebuilt automatically on every file change. The grey `[server]` logs are the output of the actual running server, these are mainly what you're gonna be interested in. And the blue `[tsc]` logs report any typescript errors, those don't prevent the server from running, but any that pop up should be patched as you go. Screenshot 2025-09-19 at 10 05 44 PM ## Project Structure The entire source code of the project is located in [`src`](../src/). This contains all code that is ever run by either the server or client, and contains assets that are served to the client. ``` src/ ├── client/ # Frontend code and assets ├── server/ # Backend Node.js server └── shared/ # Common logic between client and server ``` | Directory | Description | | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`src/client/`](../src/client/) | Contains all clientside files and resources of the website, whether script, image, sound, etc. Any file inside here may be requested by and served to the client. No client-side code is ever imported by server-side scripts. | | [`src/server/`](../src/server/) | Contains all server-side files. The server begins running from [`server.js`](../src/server/server.js). This configures and starts our http, https, and websocket servers, and cleans up on closing. | | [`src/shared/`](../src/shared/) | Contains all shared scripts between the server and client. This includes lots of chess logic that both need. No shared script should **ever** reference environment variables in the Node.js or browser environment. A couple examples are `document` or `window` in the browser. | | [`src/client/views/`](../src/client/views) | Contains our EJS documents, which are converted to HTMLs on startup. Modify these to change the content on the website pages. In order to support multiple languages, these documents reference many of the translations in [`en-US.toml`](../translation/en-US.toml). Any changes to the toml file requires you increment the version number at the top of it, and record the change you made inside [`changes.json`](../translation/changes.json). Additional information on working other languages of the website is in [TRANSLATIONS.md](./TRANSLATIONS.md). | | [`src/client/scripts/esm/game/`](../src/client/scripts/esm/game/) | Contains all our code for running the game on the play page of the website! It starts inside [`main.js`](../src/client/scripts/esm/game/main.js), which contains our game loop. Most scripts includes a basic description at the top. Feel free to ask for greater details on what a specific script does, or for help finding the code that does a specific task! | | [`src/server/game/`](../src/server/game/) | Contains the server-side code for running online play, including the invites manager and game manager. Both of these use websocket messaging to broadcast changes out to the clients in real-time. | | `database.db` | Automatically generated at the root level of the project. This stores all user accounts, login sessions, games, etc. You can view the contents of the database via the SQLite VSCode extension. | ## Accounts There are 4 automatically generated accounts for you to test with. The password for every one of these accounts is `1`- - `Member`: Regular permissions. - `Patron`: At the moment this holds no difference to member accounts. - `Admin`: Is able to send commands on the admin panel page found at url `https://localhost:3443/admin`. Sending `help` will list the available commands. - `Owner`: The only current difference to admin accounts being that they are able to delete other admin accounts via the admin panel. ## Debugging Keyboard Shortcuts While in-game, there are a few keys that will activate useful debugging modes- - `` ` ``: The backtick key (typically right below your escape button) will toggle the camera's debug mode. This places the camera position further back in space, allowing you to see a little beyond the normal screen edges. Useful for making sure rendered items don't exceed the edge! - `1`: If you are in a local game, this will toggle "Edit Mode", which allows you to move any piece anywhere else on the board, bar whether it's legal. - `2`: Prints the entire gamefile to the console. Useful for checking for expected properties. - `3`: Greatly slows the animation of pieces, and renders the spline path the piece will travel. Especially useful for debugging curved movement paths, such as the Rose. - `4`: Simulates 1 second of websocket message latency. This helps you to catch bugs caused by low ping, something you have zero of when developing. - `5`: Copies the currently loaded game as a single position, according to the move you are viewing. This strips the moves list from the resulting notation. - `6`: Toggles specialrights highlights. This displays a `+` sign next to what pieces still have their special ability (pawns that can double push, kings/rooks that can castle). In addition, this also highlights the square enpassant capture is legal on, if possible. - `7`: Toggles engine move generation highlights. This indicates all the moves the engine will consider in the position. Only works for the HydroChess engine. ## Making changes to the repository All pull requests MUST meet the standards outlined in the [Contributing Guide](./GUIDELINES.md)! Please seek approval in the [discord server](https://discord.com/channels/1114425729569017918/1115358966642393190) before you start making changes you expect will be merged! I am very particular about what gets added, I have a vision for the course of the project. Generally, if you've spoken about the desired change with me, and we're on the same page about how it will be implemented, you don't have to worry! Also, check out the [list of open issues](https://github.com/Infinite-Chess/infinitechess.org/issues) for tasks you could claim! Sometimes after you modify a file, the browser doesn't detect that it was changed, so it doesn't load the new code after a refresh. To avoid this, I highly recommend enabling automatic hard refreshing in your browser's developer tools. Here's how to do that in Chrome: 15 And under the "Preferences" tab, check the box next to "Disable cache (while DevTools is open)". 16 Now, as long as you have developer tools open whenever you refresh, the game will always hard refresh and load your new code. ## Mobile Testing Mobile devices differ from pc behavior because the user interacts with touch events instead of mouse events. There are 2 ways you can test your code to make sure it works on mobile: - Chrome dev tools has a "Toggle device toolbar" button which allows you to interact with the page as if the mouse was your finger. It also easily lets you grow and shrink the size of the window to see how the content fits on each device width. However, it does not let you use multiple fingers. For that: - Connect to the web server with another device in your home network (like your phone). The machine you’re using to run the server is the only device that connects through `https://localhost:3443`. To connect from other devices in your home network, first they need to be connected to the same wifi, then you need to replace `localhost` with the IP address of your computer running the server. You can find your computers IP address within the network settings on your computer. An example of what your IP may look like is `192.168.1.2`. If this was your computer's IP address, then to connect to the web server on other devices you would go to `https://192.168.1.2:3443`. ## Conclusion Those are the basics! Have at it! Check out the [Issues](https://github.com/Infinite-Chess/infinitechess.org/issues) for tasks you could assist with! Working on these directly helps the next update to come quicker! If you want easier ones, look for the ones with the "simple" tag! ================================================ FILE: docs/SETUP.md ================================================ # Setting up your workspace This guide walks you through the initial setup phase of the infinitechess.org server on your machine. [← Back to README](../README.md) | [Navigation Guide](./NAVIGATING.md) | [Contributing Guide](./GUIDELINES.md) | [Translation Guide](./TRANSLATIONS.md) This only needs to be done once. Afterward, you will be able to run the website locally on your computer, write and modify code, suggesting changes to the github! **This is a team project!!** Join [the discord](https://discord.gg/NFWFGZeNh5) server to work with others, discuss how to improve the website, and ask questions! If you have trouble during this setup process, many people are willing to assist you in the [#help](https://discord.com/channels/1114425729569017918/1257506171376504916) channel! **SUMMARY of the setup process for experienced users:** Install Node.js. Fork the repo and install the project dependencies via `npm i`. Now you can run `npm run dev` to launch a live infinite chess server at `https://localhost:3443`. Using the suggested [list of VSCode Extensions](#step-6-install-vscode-extensions) is highly recommended but optional. Read the [Navigation Guide](./NAVIGATING.md) to get a brief rundown of the project structure. ## Step 1: Install Git Let's check to make sure you have Git already installed. Open a command prompt (windows) or terminal (mac), and enter the following: ``` git version ``` If this outputs a version number, you have it installed, proceed to the next step! If it outputted unknown command, [follow this guide](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) to install it! ## Step 2: Download VSCode This guide will use VSCode, which is **highly** recommended, but you may use another code editor if you wish, as long as it is compatible with Node, npm, and has source control features. This guide will walk you through the process using VSCode. [Go here](https://code.visualstudio.com/) to download and install VSCode. Be sure you have Visual Studio **Code**, and not Visual Studio (they are different). ## Step 3: Install Node.js [Go here](https://nodejs.org/en/download) to download and install Node. Select version `v22.XX.X (LTS)`, `x64` for the architecture, then download the Installer (.msi on Windows or .pkg on Mac). Then run the installer. ## Step 4: Forking the repository Go to the [repository's home page](https://github.com/Infinite-Chess/infinitechess.org), then click "Fork"! You will need a github account. 21 copy On the next page, click "Create Fork". Next, open VSCode, and click "Clone Git Repository..." 18 Click "Clone from GitHub". 19 copy Then click "Allow" to sign in with your github account, and, in the browser window that opened, click "Open Visual Studio Code.app". The fork you just created should be at or near the top of the list, click on it! Be sure it has your github username on it! If it says "Infinite-Chess", don't click that one as it is the main repository, which you don't have write access to. Screen Shot 2024-07-02 at 1 03 01 PM Choose a location on your machine to store the repository. Then when prompted whether to open the cloned repository, click "Open". ## Step 5: Install project dependencies Inside the opened VSCode project, open a terminal window within it by going to Terminal > New Terminal. Run the following command to auto-install all project dependancies: ``` npm i ``` To test run the server, and start it up from now on, enter the command: ``` npm run dev ``` The first time you run this, you should see something like: Screenshot 2025-09-19 at 9 52 21 PM Subsequent startups will look something like: Screenshot 2025-09-19 at 9 53 17 PM You should now be able to connect to the server through local host! Open a web browser and go to: ``` https://localhost:3443 ``` It will warn us our connection is not private. 345182644-ffedcc95-7ca8-46ab-bf67-26ff96dbe0f4 copy Click "Advanced", then "Proceed to localhost (unsafe)"! Screen Shot 2024-07-02 at 1 57 05 PM copy Now you should now be able to browse the website and all it’s contents! Hooray! 5 orig Don't worry about the url bar telling you it's not secure. This can safely be ignored as you develop. It IS possible to get your computer to trust our newly created certificate, but it is not required, and these directions won’t include that. [This one guy](https://stackoverflow.com/a/49784278) was able to figure it out though. Now, stop the server by clicking in the VSCode terminal window to re-focus it, and hit Ctrl > C. If done correctly, you should be met with the following. This means the server has stopped. Screenshot 2025-09-19 at 9 56 26 PM ## Step 6: Install VSCode Extensions 1. **ESLint** Installing the ESLint VSCode extension will help your pull requests be approved quicker, by holding your code semantics to the standards of the project! ESLint will give you errors when you have undefined variables, missing semicolons, and other items, making it easier to catch bugs before runtime! Go to the extensions tab, search for "eslint", click the one by "Microsoft", then Click "Install"! Screen Shot 2024-08-16 at 10 26 33 PM copy

2. **Prettier - Code formatter** Using this extension will help your code changes be stylistically consistent with the rest of the codebase. After installing this extension, open your VScode settings, set Prettier as your default code formatter in `Editor: Default Formatter` and enable `Editor: Format On Save`. This will automatically "prettify" the style every time you save a file; for example, it will fix indentation issues and replace double quotation marks with single quotation marks. You can have Prettier ignore a code block via `// prettier-ignore` if you think your style is more readable! 3. **SQLite** Installing this extension will allow you to preview the contents of the database during development. The database stores all account information. 4. **GitHub Pull Requests** Installing this extension is not required, but highly recommended. It allows you to test run the code of other peoples pull requests on your system, so you can give collective feedback! ### **You are all set up now to start developing!** 🥳 Let's move on to learn how to suggest changes to the repository! Or, skip right to the [Conclusion](#conclusion). ## Creating a Pull Request All pull requests MUST meet the standards outlined in the [Contributing Guide](./GUIDELINES.md)! After you have made some changes to the code, you can push those changes to your personal fork by going to the Source Control tab. Screen Shot 2024-07-03 at 9 48 08 AM copy Only changes you "stage" will be sent to your fork! You can stage specific changes, or you can stage all your changes by clicking the "+" in the above image. Then click "Commit". Enter a brief commit message, then click the checkmark in the top-right corner. Screen Shot 2024-07-03 at 9 56 51 AM copy Now click "Sync Changes" back in the top-left! If you now visit the fork you created on your own github account, the changes you made should now be found there as well! Next, let's suggest this change to the official infinitechess.org repository by creating a "Pull Request"! On the home page of the fork you created ON YOUR GITHUB account, click on "Pull Requests" 26 copy Now click "New pull request", followed by "Create pull request"! Your changes will be reviewed soon and either be accepted, rejected, or commented on! ## Conclusion Infinite Chess is a team project! Join [the discord](https://discord.gg/NFWFGZeNh5) to discuss with the others how we should best go about things! Next, read the [Navigation Guide](./NAVIGATING.md) to get a rundown of the project structure, where the game code is located, etc.! For a list of available tasks, please see the [Issues](https://github.com/Infinite-Chess/infinitechess.org/issues), or inquire in the [discord server](https://discord.gg/NFWFGZeNh5). Also, read the [Contributing Guide](./GUIDELINES.md) to adopt the coding standards of the project! ================================================ FILE: docs/TRANSLATIONS.md ================================================ # Translation guide This guide will walk you through the process of translating [InfiniteChess.org](https://www.infinitechess.org) into another language. [← Back to README](../README.md) | [Setup Guide](./SETUP.md) | [Contributing Guide](./GUIDELINES.md) | [Translation Directory](../translation/) | [English TOML](../translation/en-US.toml) | [Changelog](../translation/changes.json) | [English News Posts](../translation/news/en-US/) It is assumed you have already gone through the Setup Guide. ## Navigation Anything that matters to you as a translator is located in the [translation](../translation/) directory. Translation files are stored in TOML format (you can read more about its syntax [here](https://toml.io/)). Generally, it is a very aproachable format, and you only need to understand the absolute basics of it, which are explained below. ## Translation files ### Name Each file is named after its language [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag). BCP 47 tags are composed of this format (notice the capitalization): `lng-(script)-REGION-(extensions)` For example, `en-US` for American English, `sv` for Swedish, `zh-Hant-HK` for Chinese spoken in Hong Kong written in traditional script. You should name your file this way and only this way, otherwise it won't be correctly detected. ### Content Translation files in TOML format consist of keys, values, table headers and comments, like this: ```toml [table-header] # Comment key1 = "value1" key2 = "value2" ``` > [!IMPORTANT] > **You should only change values. Please, leave everything else, including comments, unmodified when translating!**. ## Translation process In case you are translating a language that is currently not present in the project, you can start the process by copying [en-US.toml](../translation/en-US.toml) and renaming it as described above. If you are updating an existing language, the only thing you need to do is to update the `version` variable at the top of your TOML document to the value of the `version` variable in [en-US.toml](../translation/en-US.toml), indicating that the translation is up to date. > [!IMPORTANT] > You should always use [en-US.toml](../translation/en-US.toml) as a reference. It is the only file that is up to date and comes straight from the developers. Do not use any other files! Then you can start a test server with `npm run dev` and start translating. If you insert the address `https://localhost:3443` into your browser, the website should be there and it should automatically update as you make your changes (after reloading the page). Make sure that you have selected the language that you are editing in the settings in the top right. In case you are updating an existing language and you aren't sure what has changed since the last update, you can view changes of `en-US.toml` in [the official changelog](../translation/changes.json) or in the [file commit history](https://github.com/Infinite-Chess/infinitechess.org/commits/main/translation/en-US.toml). In general, a translation is only considered up to date if the `version` variable on top matches the `version` value of the English TOML file. > [!IMPORTANT] > If there is an HTML tag in the value you want to translate, do not modify it! > > Example of an HTML tag: > > ```html > Hello World > ``` > > In this example you should only change the words _Hello World_. ### Translating News Articles In addition to the TOML translation files, you may optionally translate news articles located in the `translation/news/` directory, but this isn't required. Here are the steps to translate those: 1. **Make a copy of the [translation/news/en-US](../translation/news/en-US/) folder**: Rename it to your language's BCP 47 tag. 2. **Translate the content**: For each `.md` file within (e.g. `2024-09-11.md`), translate it from english into your language. Each news article supports [markdown](https://www.markdownguide.org/basic-syntax/), please don't modify hyperlinks, bullet points, headers indicated by `#`, or html tags (e.g. ``). 3. **Commit your changes**: Once the translations are complete, commit the changes as you would with TOML files. When you are finished, you should open a pull request as described in [SETUP.md](./SETUP.md). ## Conclusion Thank you for your contribution! In case of any trouble or questions, you can join [the discord](https://discord.gg/NFWFGZeNh5). ================================================ FILE: ecosystem.config.cjs ================================================ // ecosystem.config.cjs /* * PM2 process configuration for the Infinite Chess production server. */ module.exports = { apps: [ { name: 'infinitechess', script: 'dist/server/server.js', max_restarts: 10, min_uptime: '10s', }, ], }; ================================================ FILE: eslint.config.js ================================================ // eslint.config.js import globals from 'globals'; import pluginJs from '@eslint/js'; import pluginTypescript from '@typescript-eslint/eslint-plugin'; import parserTypescript from '@typescript-eslint/parser'; import eslintConfigPrettier from 'eslint-config-prettier/flat'; export default [ pluginJs.configs.recommended, { ignores: ['dev-utils/**', 'dist/**', 'src/client/pkg/**'], }, { files: ['**/*.js', '**/*.ts'], // Apply the following rule overrides to both js and ts files... // plugins: { "@typescript-eslint": pluginTypescript }, // Define plugins as an object. SUPPOSEDLY THIS IS NOT NEEDED?? rules: { // Overrides the preset defined by "pluginJs.configs.recommended" above 'no-undef': 'error', // Undefined variables not allowed // Unused variables give a warning 'no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], semi: ['error', 'always'], // Enforces semicolons be present at the end of every line. // Enforces semicolons have a space after them if they are proceeded by other statements. 'semi-spacing': [ // Enforces semicolons have a space after them if they are proceeded by other statements. 'error', { before: false, after: true, }, ], // Requires a space be after if, else, for, and while's. 'keyword-spacing': [ 'error', { before: true, after: true, }, ], 'space-before-function-paren': ['error', 'never'], // Enforces there be NO space between function DECLARATIONS and () 'space-before-blocks': ['error', 'always'], // Enforces there be a space between function parameters and the {} block 'arrow-spacing': ['error', { before: true, after: true }], // Requires a space before and after "=>" in arrow functions 'func-call-spacing': ['error', 'never'], // Enforces there be NO space between function CALLS and () 'space-infix-ops': ['error', { int32Hint: false }], // Enforces a space around infix operators, like "=" in assignments 'no-eval': 'error', // Disallows use of `eval()`, as it can lead to security vulnerabilities and performance issues. // All indentation must use tabs indent: [ 'error', 'tab', { SwitchCase: 1, // Enforce switch statements to have indentation (they don't by default) ignoredNodes: ['ConditionalExpression', 'ArrayExpression'], // Ignore conditional expressions "?" & ":" over multiple lines, AND array contents over multiple lines! }, ], 'prefer-const': 'error', // "let" variables that are never redeclared must be declared as "const" 'no-var': 'error', // Disallows declaring variables with "var", as they are function-scoped (not block), so hoisting is very confusing. // "max-depth": ["warn", 4], // Maximum number of nested blocks allowed. eqeqeq: ['error', 'always'], // Disallows "!=" and "==" to remove type coercion bugs. Use "!==" and "===" instead. 'dot-notation': 'error', // Forces dot notation `.` instead of bracket notation `[""]` wherever possible 'no-empty': 'off', // Disable the no-empty rule so blocks aren't entirely red just as we create them 'no-prototype-builtins': 'off', // Allows Object.hasOwnProperty() to be used // "no-multi-spaces": "error", // Disallows multiple spaces that isn't indentation. // "max-lines": ["warn", 500] // Can choose to enable to place a cap on how big files can be, in lines. // "complexity": ["warn", { "max": 10 }] // Can choose to enable to cap the complexity, or number of independant paths, which can lead to methods. }, languageOptions: { parser: parserTypescript, // Use the TypeScript parser sourceType: 'module', // Can also be "commonjs", but "import" and "export" statements will give an eslint error globals: { ...globals.node, // Defines "require" and "exports" NodeJS: 'readonly', // Manually add NodeJS namespace, BECAUSE FOR SOME REASON ESLINT DOESN'T KNOW IT ...globals.browser, // Defines all browser environment variables for the game code // Game code scripts are considered public variables // MOST OF THE GAME SCRIPTS are ESM scripts, importing their own definitions, so we don't need to list them below. translations: 'readonly', // Injected into the html through ejs header: 'readonly', htmlscript: 'readonly', EventListener: 'readonly', }, }, }, { // TYPESCRIPT SETTINGS THAT OVERWRITE THE ABOVE files: ['**/*.ts'], // Required for us to use the @typescript-eslint/explicit-function-return-type rule below plugins: { '@typescript-eslint': pluginTypescript }, rules: { 'no-unused-vars': 'off', // Default rule causes false positives on Enums // Typescript-specific unused variable rule '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], // Disables dot-notation, as bracket notation is required by TS compiler if the keys of an object are STRINGS 'dot-notation': 'off', 'no-undef': 'off', // Prevent ESLint from flagging TypeScript types as undefined // Enforces all functions to declare their return type '@typescript-eslint/explicit-function-return-type': [ 'error', { allowExpressions: true, // Adds arrow functions as exceptions, as their return types are usually inferred }, ], }, }, eslintConfigPrettier, ]; ================================================ FILE: nodemon.json ================================================ { "watch": [ "dist/server", "dist/shared", "src/client/views", "src/client/scripts/cjs/game/htmlscript.js" ], "ext": "js,ejs,html", "exec": "node --enable-source-maps dist/server/server.js", "delay": "200" } ================================================ FILE: package.json ================================================ { "name": "infinite-chess-server", "version": "1.4.3", "description": "infinitechess.org server", "author": "Naviary", "license": "AGPL", "main": "dist/server/server.js", "type": "module", "dependencies": { "@aws-sdk/client-sesv2": "^3.985.0", "@aws-sdk/credential-providers": "^3.985.0", "@naviary/bigdecimal": "^1.0.1", "abort-controller": "^3.0.0", "bcrypt": "^6.0.0", "better-sqlite3": "^11.5.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "date-fns": "^2.23.0", "dotenv": "^16.0.3", "ejs": "^3.1.10", "express": "^4.18.2", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "i18next": "^23.12.1", "i18next-http-middleware": "^3.6.0", "jsonwebtoken": "^9.0.3", "marked": "^14.1.2", "node-email-verifier": "^2.0.0", "node-forge": "^1.3.1", "nodemailer": "^7.0.11", "obscenity": "^0.4.5", "proper-lockfile": "^4.1.2", "smol-toml": "^1.2.2", "sns-validator": "^0.3.5", "uuid": "^8.3.2", "ws": "^8.16.0", "xss": "^1.0.15", "zod": "^4.0.5" }, "devDependencies": { "@eslint/js": "^9.39.1", "@swc/core": "^1.7.0", "@types/bcrypt": "^5.0.2", "@types/better-sqlite3": "^7.6.12", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/ejs": "^3.1.5", "@types/express": "^5.0.0", "@types/express-rate-limit": "^5.1.3", "@types/jsonwebtoken": "^9.0.10", "@types/madge": "^5.0.3", "@types/node": "^22.10.1", "@types/node-forge": "^1.3.14", "@types/nodemailer": "^6.4.17", "@types/proper-lockfile": "^4.1.4", "@types/sns-validator": "^0.3.3", "@types/supertest": "^6.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.37.0", "@typescript-eslint/parser": "^8.37.0", "browserslist": "^4.23.2", "concurrently": "^9.2.1", "cpx": "^1.5.0", "esbuild": "^0.25.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "fake-indexeddb": "^6.2.5", "glob": "^11.0.0", "globals": "^15.15.0", "glsl-strip-comments": "^1.0.0", "husky": "^9.1.7", "lightningcss": "^1.25.1", "lint-staged": "^16.2.7", "madge": "^8.0.0", "nodemon": "^3.1.10", "prettier": "^3.7.3", "rimraf": "^6.0.1", "sharp": "^0.33.4", "supertest": "^7.1.4", "tsx": "^4.20.6", "typescript": "^5.7.2", "typescript-eslint": "^8.48.0", "vitest": "^4.0.2", "wait-on": "^9.0.0" }, "scripts": { "clean": "rimraf dist", "build": "npm run generate:types && npm run clean && npm run copy:views && npm run prod:assets && tsx build/index.ts", "start": "node dist/server/server.js", "prod:assets": "cpx \"src/**/*.{css,png,jpg,webp,avif,svg,ico,gif,json,mp3,wav,opus,glsl,md,woff2,woff}\" dist", "copy:views": "cpx \"src/client/views/**/*\" dist/client/views", "dev": "npm run clean && concurrently -k \"npm:dev:*\" --prefix-colors \"green,blue,grey,white\"", "dev:build": "npm run generate:types && npm run copy:views && tsx build/index.ts --dev", "dev:tsc": "tsc --noEmit --watch --preserveWatchOutput", "dev:server": "wait-on dist/server/server.js && nodemon", "dev:assets": "cpx \"src/**/*.{css,png,jpg,webp,avif,svg,ico,gif,json,mp3,wav,opus,glsl,md,woff2,woff}\" dist --watch", "generate:types": "tsx scripts/generate-translation-types.ts", "optimize-images": "tsx scripts/optimize-images.ts", "generate-dependency-graph": "tsx src/server/utility/generateDependancyGraph.ts", "test": "vitest run", "test:watch": "vitest", "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint .", "prepare": "husky || true" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown", "**/*.ts": "tsx scripts/organize-imports.ts", "**/*.{js,ts,cjs}": "tsx scripts/add-file-paths.ts" } } ================================================ FILE: scripts/add-file-paths.ts ================================================ // scripts/add-file-paths.ts /** * This script ensures all .js and .ts files have their relative file path * on the first line in the format: `// ` * followed by an empty line. * * It intelligently detects existing path comments (correct or incorrect) * and updates them as needed to avoid duplicates. */ import { relative, resolve } from 'node:path'; import { readFileSync, writeFileSync } from 'node:fs'; /** * Checks if a line looks like a file path comment. * Returns the path if it matches the pattern, otherwise null. */ function extractPathFromComment(line: string): string | null { const match = line.match(/^\/\/\s*(.+)$/); if (!match || !match[1]) return null; const content = match[1].trim(); // Check if it looks like a file path (ends with .ts, .js, or .cjs) if (content.match(/\.(ts|js|cjs)$/)) { return content; } return null; } /** Processes a single file to ensure it has the correct path comment. */ function processFile(filePath: string): void { const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); // Calculate the correct relative path from repo root const repoRoot = process.cwd(); const absolutePath = resolve(filePath); const relativePath = relative(repoRoot, absolutePath); const correctPath = relativePath.replace(/\\/g, '/'); const correctComment = `// ${correctPath}`; // Check the first line const firstLine = lines[0] || ''; const existingPath = extractPathFromComment(firstLine); // Determine what changes are needed let needsUpdate = false; let newLines: string[]; if (existingPath === null) { // No path comment exists on line 1 // Check if line 1 is empty and line 2 might have a path comment if (firstLine === '' && lines.length > 1) { const secondLinePath = extractPathFromComment(lines[1] || ''); if (secondLinePath !== null) { // There's an empty line followed by a path comment - fix it lines.shift(); // Remove the empty first line if (secondLinePath !== correctPath) { // Incorrect path on line 2 (now line 1) lines[0] = correctComment; needsUpdate = true; } // Ensure empty line after path if (lines.length < 2 || lines[1] !== '') { lines.splice(1, 0, ''); needsUpdate = true; } newLines = lines; } else { // Empty first line but no path comment - add path comment at the beginning newLines = [correctComment, '', ...lines]; needsUpdate = true; } } else { // No path comment at all - add it at the beginning newLines = [correctComment, '', ...lines]; needsUpdate = true; } } else { // Path comment exists on line 1 if (existingPath !== correctPath) { // Incorrect path - update it lines[0] = correctComment; needsUpdate = true; } // Ensure there's an empty line after the path comment if (lines.length < 2 || lines[1] !== '') { lines.splice(1, 0, ''); needsUpdate = true; } newLines = lines; } // Write the file if changes were made if (needsUpdate) { const newContent = newLines.join('\n'); writeFileSync(filePath, newContent, 'utf-8'); console.log(filePath); } } /** Main entry point for the script. */ function main(): void { const args = process.argv.slice(2); if (args.length === 0) { console.error('No files provided. Usage: tsx add-file-paths.ts ...'); process.exit(1); } // Filter for only .js, .ts, and .cjs files const jsAndTsFiles = args.filter((file) => file.match(/\.(js|ts|cjs)$/)); for (const file of jsAndTsFiles) { try { processFile(file); } catch (error) { console.error(`Error processing ${file}:`, error); } } console.log(`Updated path in ${jsAndTsFiles.length} files.`); } main(); ================================================ FILE: scripts/generate-translation-types.ts ================================================ // scripts/generate-translation-types.ts /** * Generates TypeScript types from the English translation TOML file. * Creates two type structures: * 1. Flat dot-notation union type for server-side i18next * 2. Nested object type for client-side property access */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'node:url'; import { parse, TomlTable } from 'smol-toml'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const translationFile = path.join(__dirname, '../translation/en-US.toml'); const relativeOutputFilePath = 'src/types/translations.ts'; const outputFile = path.join(__dirname, `../${relativeOutputFilePath}`); /** * Recursively generates all dot-notation paths for a nested object. * @param obj - The object to traverse * @param prefix - The current path prefix * @returns Array of dot-notation paths */ function generateDotPaths(obj: TomlTable | any, prefix = ''): string[] { const paths: string[] = []; for (const [key, value] of Object.entries(obj)) { const path = prefix ? `${prefix}.${key}` : key; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { // Recursively traverse nested objects paths.push(...generateDotPaths(value, path)); } else { // Leaf node - add the path paths.push(path); } } return paths; } /** * Generates a TypeScript interface from a nested object structure. * @param obj - The object to convert to a type * @param indentLevel - Current indentation level * @returns TypeScript interface string */ function generateNestedType(obj: TomlTable | any, indentLevel = 1): string { const indent = '\t'.repeat(indentLevel); const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { // Handle keys with hyphens or other special characters const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { // Nested object - add index signature for dynamic access lines.push(`${indent}${safeKey}: {`); lines.push(generateNestedType(value, indentLevel + 1)); lines.push(`${indent}};`); } else if (Array.isArray(value)) { // Array type lines.push(`${indent}${safeKey}: string[];`); } else { // Primitive type lines.push(`${indent}${safeKey}: string;`); } } return lines.join('\n'); } /** Main function to generate translation types. */ function generateTypes(): void { const tomlContent = fs.readFileSync(translationFile, 'utf-8'); const parsed = parse(tomlContent); const dotPaths = generateDotPaths(parsed); const nestedType = generateNestedType(parsed); // Create the output TypeScript file const output = `// ${relativeOutputFilePath} /** * This file is auto-generated by scripts/generate-translation-types.ts on build. * Do NOT edit manually! */ /** * Flat dot-notation union type for server-side i18next. * Use with i18next.t() function. * @example * i18next.t("play.javascript.termination.checkmate") */ export type TranslationKeys =${dotPaths.map((p) => `\n\t| '${p}'`).join('')}; /** * Nested object type for client-side translation access. * Represents the full structure of the translation object. */ export interface TranslationsObject { ${nestedType} } `; fs.writeFileSync(outputFile, output, 'utf-8'); console.log( `[generate-translation-types] Generated translation types (${dotPaths.length} keys).`, ); } // Run the generator try { generateTypes(); } catch (error) { console.error('Error generating translation types:', error); process.exit(1); } ================================================ FILE: scripts/optimize-images.ts ================================================ // scripts/optimize-images.ts /** * This script automatically finds and compresses all images from the source * directory that haven't already been fully optimized in the destination directory. * * Steps: * * 1. Place new or updated images in `dev-utils/image-sources/`. * The same subdirectory structure will be maintained. * * 2. Run the command: * npm run optimize-images * * Any images that already have at least one version .webp, .png, or .avif * in `src/client/img/` will be skipped. This is because sometimes we only need one format. */ import path from 'path'; import sharp from 'sharp'; import { fileURLToPath } from 'node:url'; import { existsSync, readdirSync, statSync, mkdirSync } from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // --- CONFIGURATION --- // Effort values. Higher mean better compression but longer processing time. const webp_options = { effort: 6, // 0-6 quality: 100, // Controls visual quality (1-100). Default if not specified: 80. USE 100 FOR NOISE TEXTURES! }; const png_options = { effort: 10, // 1-10. LOWER YIELDS BETTER COMPRESSION??? But lower image quality. quality: 100, // Default if not specified: 100. }; const avif_options = { effort: 9, // 0-9 quality: 100, // Default if not specified: 50. }; // Source folder for original images const src_path = path.join(__dirname, `dev-utils/image-sources/`); // Destination folder for compressed images const dest_path = path.join(__dirname, `src/client/img/`); const supportedExtensions = ['.png', '.jpg', '.jpeg']; // --- LOGIC --- /** * Recursively finds all image files in a directory. * @param {string} dirPath The directory to search. * @returns {string[]} An array of full paths to image files. */ function getAllImagePaths(dirPath: string): string[] { const allEntries = readdirSync(dirPath); const files: string[] = []; for (const entry of allEntries) { const fullPath = path.join(dirPath, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { files.push(...getAllImagePaths(fullPath)); // Recurse into subdirectories } else if (supportedExtensions.includes(path.extname(entry).toLowerCase())) { files.push(fullPath); } } return files; } console.log('Scanning for images to process...'); // 1. Find all source images const allSourceImages = getAllImagePaths(src_path); // 2. Filter out images that are already fully optimized const imagesToProcess = allSourceImages.filter((sourceImagePath) => { // Get the path relative to the source directory (e.g., 'badges/my-badge.png') const relativePath = path.relative(src_path, sourceImagePath); // Remove the original extension to create a base path for output files const destBasePath = path.join(dest_path, relativePath.replace(/\.[^/.]+$/, '')); // Check if all three target formats already exist const webpExists = existsSync(`${destBasePath}.webp`); const pngExists = existsSync(`${destBasePath}.png`); const avifExists = existsSync(`${destBasePath}.avif`); // If at least one exists, we can skip it. Otherwise, it needs processing. return !(webpExists || pngExists || avifExists); }); if (imagesToProcess.length === 0) { console.log('All images are already up-to-date. Nothing to do.'); process.exit(0); } console.log(`Found ${imagesToProcess.length} image(s) that need optimization.`); // 3. Process the filtered images let finished_images = 0; const total_images = imagesToProcess.length * 3; function logProgress(imageName: string, format: string): void { finished_images += 1; const percentage = Math.round((finished_images / total_images) * 100); console.log( `[${percentage}%] Optimized ${path.basename(imageName)} to ${format.toUpperCase()}`, ); if (finished_images === total_images) { console.log('\nDone. All images have been processed.'); } } console.log('Converting images...'); for (const sourceImagePath of imagesToProcess) { const relativePath = path.relative(src_path, sourceImagePath); const destBasePath = path.join(dest_path, relativePath.replace(/\.[^/.]+$/, '')); // Ensure the output directory exists before writing files const outputDir = path.dirname(destBasePath); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } const imageProcessor = sharp(sourceImagePath); // Generate .webp imageProcessor.webp(webp_options).toFile(`${destBasePath}.webp`, (err) => { if (err) console.error(`Error converting ${relativePath} to WEBP:`, err); logProgress(relativePath, 'webp'); }); // Generate .png (re-optimizing the original) imageProcessor.png(png_options).toFile(`${destBasePath}.png`, (err) => { if (err) console.error(`Error converting ${relativePath} to PNG:`, err); logProgress(relativePath, 'png'); }); // Generate .avif imageProcessor.avif(avif_options).toFile(`${destBasePath}.avif`, (err) => { if (err) console.error(`Error converting ${relativePath} to AVIF:`, err); logProgress(relativePath, 'avif'); }); } ================================================ FILE: scripts/organize-imports.ts ================================================ // scripts/organize-imports.ts /** * TypeScript Import Organizer * * PREREQUISITES: * - All import statements must end in a semicolon `;` * * Usage: tsx scripts/organize-imports.ts ... * * Run on all files: * npx tsx scripts/organize-imports.ts $(find build src scripts -name "*.ts") *.ts * * ======================================== * IMPORT ORGANIZATION RULES * ======================================== * * BOUNDARY DETECTION: * - Import section starts at the first import statement * - Import section ends at the last import statement, or where we encounter the first non-import, non-comment line. * - Everything above and below is preserved as-is * - All comments within the import boundary (except @ts-ignore, and inline comments on import lines) are deleted * * GROUPING (groups separated by blank line): * 1. Type imports (package and source together, no separation) * 2. Regular package imports * 3. Regular source imports from shared (src/shared/) * 4. Regular source imports from client (src/client/) * 5. Regular source imports from tests (src/tests/) * 6. Regular source imports from server (src/server/) * 7. Side-effect imports * * SORTING WITHIN GROUPS: * - Multi-line imports last * - Then by length before "from" * * SPACING: * - One blank line above imports (unless at file top) * - One blank line below imports * - Blank lines between groups */ import * as fs from 'fs'; import * as path from 'path'; // Constants --------------------------------------------------------------- /** Regex pattern to match " from " followed by a quote in import statements */ const FROM_WITH_QUOTE_PATTERN = /\sfrom\s+['"]/; /** Path to the shared directory */ const SHARED_DIR = path.resolve(process.cwd(), 'src/shared'); /** Path to the client directory */ const CLIENT_DIR = path.resolve(process.cwd(), 'src/client'); /** Path to the tests directory */ const TESTS_DIR = path.resolve(process.cwd(), 'src/tests'); /** Path to the server directory */ const SERVER_DIR = path.resolve(process.cwd(), 'src/server'); // Types ------------------------------------------------------------------- interface Import { raw: string; isType: boolean; isPackage: boolean; isSideEffect: boolean; isMultiLine: boolean; lengthBeforeFrom: number; /** Which source directory this relative import belongs to, or null if it's a package import or not in shared/client/tests/server directories */ sourceDir: 'shared' | 'client' | 'tests' | 'server' | null; } // Helper Functions -------------------------------------------------------- /** * Resolves an import path from the current file and determines which source directory it belongs to. * @param currentFilePath - Absolute path to the file being processed * @param importPath - The path from the import statement (e.g., '../../../shared/util/timeutil.js') * @returns 'shared', 'client', 'tests', 'server', or null if not in any of these directories */ function resolveImportSourceDir( currentFilePath: string, importPath: string, ): 'shared' | 'client' | 'tests' | 'server' | null { // Don't process package imports if (!importPath.startsWith('.') && !path.isAbsolute(importPath)) { return null; } // Resolve the import path relative to the current file's directory const currentFileDir = path.dirname(currentFilePath); const resolvedImportPath = path.resolve(currentFileDir, importPath); // Check if the resolved path is within one of our source directories // We need to ensure proper directory boundaries (not just string prefix matching) const sharedDirWithSep = SHARED_DIR + path.sep; const clientDirWithSep = CLIENT_DIR + path.sep; const testsDirWithSep = TESTS_DIR + path.sep; const serverDirWithSep = SERVER_DIR + path.sep; if (resolvedImportPath === SHARED_DIR || resolvedImportPath.startsWith(sharedDirWithSep)) { return 'shared'; } else if ( resolvedImportPath === CLIENT_DIR || resolvedImportPath.startsWith(clientDirWithSep) ) { return 'client'; } else if (resolvedImportPath === TESTS_DIR || resolvedImportPath.startsWith(testsDirWithSep)) { return 'tests'; } else if ( resolvedImportPath === SERVER_DIR || resolvedImportPath.startsWith(serverDirWithSep) ) { return 'server'; } return null; } function parseImport(importText: string, hasTsIgnore: boolean, currentFilePath: string): Import { const lines = importText.split('\n'); const importLine = hasTsIgnore ? lines[lines.length - 1]! : lines[0]!; const trimmed = importLine.trim(); // Check if type import const isType = trimmed.startsWith('import type ') || trimmed.startsWith('import type{'); // Check if side-effect import (no bindings) const isSideEffect = /^import\s+['"][^'"]+['"];?/.test(trimmed); // Determine if package or source const fromMatch = importText.match(/from\s+(['"])(.*?)\1/); const fromPath = fromMatch ? fromMatch[2]! : ''; const isPackage = !!fromPath && !fromPath.startsWith('.') && !fromPath.startsWith('/'); // Determine which source directory the import belongs to const sourceDir = isPackage ? null : resolveImportSourceDir(currentFilePath, fromPath); // Calculate length before "from" followed by whitespace and a quote // For ts-ignore imports, calculate from the import line only, not including the comment const textForLength = hasTsIgnore ? importLine : importText; const match = FROM_WITH_QUOTE_PATTERN.exec(textForLength); const lengthBeforeFrom = match ? match.index : textForLength.length; // Check if multi-line const isMultiLine = importText.includes('\n') && !hasTsIgnore; return { raw: importText, isType, isPackage, isSideEffect, isMultiLine, lengthBeforeFrom, sourceDir, }; } function compareImports(a: Import, b: Import): number { // First: multi-line imports come last if (a.isMultiLine !== b.isMultiLine) { return a.isMultiLine ? 1 : -1; } // Second: by length before "from" return a.lengthBeforeFrom - b.lengthBeforeFrom; } // Import Extraction ------------------------------------------------------- function findImportBoundaries(lines: string[]): { start: number; end: number } | null { let start = -1; let end = -1; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i]!.trim(); // Skip empty lines if (!trimmed) continue; // Check for comments else if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) { continue; } // Check for import else if (trimmed.startsWith('import ')) { if (start === -1) start = i; end = i; // Handle multi-line imports // Imports end at a non-commented semicolon while (i < lines.length - 1 && !lines[i]!.split('//')[0]!.includes(';')) { i++; end = i; } } else { // SAFETY STOP: We hit code that is NOT an import and NOT a comment. // If we have found an import block already, stop looking. if (start !== -1) break; } } return start !== -1 ? { start, end } : null; } function extractImports( content: string, filePath: string, ): { imports: Import[]; beforeImports: string; afterImports: string; } { const lines = content.split('\n'); const boundaries = findImportBoundaries(lines); // console.log('Import boundaries:', boundaries); if (!boundaries) { return { imports: [], beforeImports: content, afterImports: '', }; } // Find all @ts-ignore lines before the start let actualStart = boundaries.start; while (actualStart > 0 && lines[actualStart - 1]!.trim().startsWith('// @ts-ignore')) { actualStart--; } const beforeImports = lines.slice(0, actualStart).join('\n'); const afterImports = lines.slice(boundaries.end + 1).join('\n'); // Extract imports within boundaries const imports: Import[] = []; let i = actualStart; let hasTsIgnore = false; // Move outside loop let tsIgnoreLine = ''; // Move outside loop while (i <= boundaries.end) { const line = lines[i]!; const trimmed = line.trim(); // Check for @ts-ignore if (trimmed.startsWith('// @ts-ignore')) { hasTsIgnore = true; tsIgnoreLine = line; i++; if (i > boundaries.end) break; continue; // Continue to next line } // Skip empty lines and all comments (except we already handled @ts-ignore) if (!trimmed || (trimmed.startsWith('//') && !trimmed.startsWith('import '))) { i++; continue; } // Skip multi-line comments if (trimmed.startsWith('/*') || trimmed.startsWith('/**')) { while (i <= boundaries.end && !lines[i]!.includes('*/')) { i++; } i++; // Skip the closing */ continue; } // Process import statement if (trimmed.startsWith('import ')) { let importText = line; i++; // Collect multi-line import // TODO: WHY IS THIS SO WEIRD?! // "Stop if the previous line contains a semicolon"?! while (i <= boundaries.end && !lines[i - 1]!.split('//')[0]!.includes(';')) { importText += '\n' + lines[i]; // Add the next line i++; // console.log('Collecting multi-line import:', lines[i]); } // Prepend ts-ignore if present if (hasTsIgnore) { importText = tsIgnoreLine + '\n' + importText; } // console.log('Whole import:'); // console.log(importText); // console.log('\n'); const parsedImport = parseImport(importText, hasTsIgnore, filePath); // console.log('Parsed import:', parsedImport, '\n'); imports.push(parsedImport); // Reset ts-ignore flag after using it hasTsIgnore = false; tsIgnoreLine = ''; } else { i++; } } return { imports, beforeImports, afterImports }; } // Import Sorting ---------------------------------------------------------- function organizeImports(imports: Import[]): string { // Group imports const typeImports: Import[] = []; const packageImports: Import[] = []; const sharedImports: Import[] = []; const clientImports: Import[] = []; const testsImports: Import[] = []; const serverImports: Import[] = []; const otherSourceImports: Import[] = []; // For relative imports outside shared/client/tests/server (e.g., from src/types) const sideEffectImports: Import[] = []; for (const imp of imports) { if (imp.isSideEffect) { sideEffectImports.push(imp); } else if (imp.isType) { typeImports.push(imp); } else if (imp.isPackage) { packageImports.push(imp); } else { // Source imports - categorize by directory if (imp.sourceDir === 'shared') { sharedImports.push(imp); } else if (imp.sourceDir === 'client') { clientImports.push(imp); } else if (imp.sourceDir === 'tests') { testsImports.push(imp); } else if (imp.sourceDir === 'server') { serverImports.push(imp); } else { otherSourceImports.push(imp); } } } // Sort each group typeImports.sort((a, b) => { // Within types: package before source if (a.isPackage !== b.isPackage) { return a.isPackage ? -1 : 1; } return compareImports(a, b); }); packageImports.sort(compareImports); sharedImports.sort(compareImports); clientImports.sort(compareImports); testsImports.sort(compareImports); serverImports.sort(compareImports); otherSourceImports.sort(compareImports); sideEffectImports.sort((a, b) => a.raw.length - b.raw.length); // Build groups array const groups: string[] = []; if (typeImports.length > 0) { groups.push(typeImports.map((i) => i.raw).join('\n')); } if (packageImports.length > 0) { groups.push(packageImports.map((i) => i.raw).join('\n')); } // Add source imports in order: shared, client, tests, server if (sharedImports.length > 0) { groups.push(sharedImports.map((i) => i.raw).join('\n')); } if (clientImports.length > 0) { groups.push(clientImports.map((i) => i.raw).join('\n')); } if (testsImports.length > 0) { groups.push(testsImports.map((i) => i.raw).join('\n')); } if (serverImports.length > 0) { groups.push(serverImports.map((i) => i.raw).join('\n')); } // Other source imports that don't belong to shared/client/server if (otherSourceImports.length > 0) { groups.push(otherSourceImports.map((i) => i.raw).join('\n')); } if (sideEffectImports.length > 0) { groups.push(sideEffectImports.map((i) => i.raw).join('\n')); } // Join groups with blank lines return groups.join('\n\n'); } // File Processing --------------------------------------------------------- function processFile(filePath: string): boolean { try { const content = fs.readFileSync(filePath, 'utf-8'); const absoluteFilePath = path.resolve(filePath); const { imports, beforeImports, afterImports } = extractImports(content, absoluteFilePath); if (imports.length === 0) { return false; } const organizedImports = organizeImports(imports); // Build new content let newContent = ''; // Add content before imports if (beforeImports) { newContent = beforeImports.trimEnd() + '\n\n'; } // Add organized imports newContent += organizedImports; // Add content after imports if (afterImports) { newContent += '\n\n' + afterImports.trimStart(); } // Write if changed if (content !== newContent) { fs.writeFileSync(filePath, newContent, 'utf-8'); return true; } } catch (error) { console.error(`Error processing ${filePath}:`, error); } return false; } // Main Execution ---------------------------------------------------------- function main(): void { const args = process.argv.slice(2); if (args.length === 0) { console.error('No files provided. Usage: tsx organize-imports.ts ...'); process.exit(1); } // Filter for only .ts files const tsFiles = args.filter((f) => f.endsWith('.ts')); let changed = 0; for (const file of tsFiles) { if (!fs.existsSync(file)) continue; if (!processFile(file)) continue; const relative = path.isAbsolute(file) ? path.relative(process.cwd(), file) : file; console.log(relative); changed++; } if (changed > 0) { console.log(`Organized imports in ${changed} file(s).`); } } main(); ================================================ FILE: scripts/readme.md ================================================ This directory contains scripts used explicitly by project configuration, such as npm scripts, or pre-commit hooks. They are not imported into any source code. ================================================ FILE: src/client/css/404.css ================================================ * { margin: 0; padding: 0; } body { padding-top: 30px; background-color: aliceblue; text-align: center; } h1 { font-size: 40px; } p { margin: 1em; } ================================================ FILE: src/client/css/admin.css ================================================ * { background-color: rgb(20, 20, 20); color: white; } body { padding: 30px 40px; } textarea { width: 100%; height: 80vh; } .inputContainer { display: flex; flex-direction: row; width: 100%; } input { width: 400px; } button { width: 150px; margin-left: 4px; } ================================================ FILE: src/client/css/createaccount.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 450px; } #content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } #content h1 { font-size: 40px; font-family: georgia; margin-bottom: 60px; } .formfield { width: fit-content; text-align: center; margin: auto; } #username-input-line { text-align: center; line-height: 2.2em; } #emailinputline, #password-input-line { vertical-align: middle; margin-top: 12px; } .line { width: fit-content; display: inline-block; text-align: center; line-height: 2.2em; } label { font-size: 18px; vertical-align: middle; margin-right: 2px; } form input { border: 0; border-radius: 4px; padding: 0.4em; box-shadow: 0 0 8px rgba(0, 0, 0, 0.63); font-size: 15px; width: 180px; /* Must also change div.error width! */ } form input:focus { outline: solid 1px black; } form input[type='text']:hover, form input[type='email']:hover, form input[type='password']:hover { box-shadow: 0 0 8px rgb(0, 0, 0); } div.error { display: inline-block; font-size: 12px; text-align: left; color: red; width: 192px; /* Must be exactly 12 pixels more than input width! */ line-height: 1.2em; } form input[type='submit'] { height: 30px; min-width: 0; width: fit-content; height: fit-content; background-color: white; font-size: 16px; transition: 0.1s; margin-top: 25px; outline: 0; } form input[type='submit'].ready:hover { transition: 0.1s; font-size: 18px; box-shadow: 0 0 8px rgb(0, 0, 0); margin-top: 23px; } form input[type='submit'].ready:focus { outline: solid 1px black; } /* Honeypot Bot Catcher: visually hidden but still present in DOM and form submission */ .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; border: 0 !important; } .agreement { margin: 1em 0 0 0; font-size: 13px; color: rgb(68, 68, 68); line-height: 1.5; margin: 20px 0px; } .center { text-align: center; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } .unavailable { color: rgba(0, 0, 0, 0.199); } /* Right align error bars */ @media only screen and (min-width: 345px) { .formfield { text-align: right; } } /* Start increasing header links width */ @media only screen and (min-width: 450px) { #content h1 { font-size: calc(40px + 0.027 * (100vw - 450px)); } form input { width: calc(180px + 0.3 * (100vw - 450px)); /* Must also change div.error width! */ } div.error { /* Must be exactly 12 pixels more than input width! */ width: calc(192px + 0.3 * (100vw - 450px)); } } /* Stop increasing header links width */ @media only screen and (min-width: 715px) { form input { width: 260px; /* Must also change div.error width! */ } div.error { width: 272px; /* Must be exactly 12 pixels more than input width! */ } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { #content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } #content h1 { font-size: 50px; margin-bottom: 70px; } } ================================================ FILE: src/client/css/credits.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } #content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } #content h1 { font-size: 40px; font-family: georgia; margin-bottom: 40px; } h2 { text-align: center; font-size: 25px; margin-top: 1.5em; } h3 { /* Thank you! */ font-size: 18px; text-align: center; font-weight: normal; } #content p { line-height: 1.5; font-size: 16px; margin: 10px 0px; } a { color: black; } .grey { color: rgba(0, 0, 0, 0.345); } .center { text-align: center; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } /* Start increasing header links width */ @media only screen and (min-width: 450px) { #content h1 { font-size: calc(40px + 0.028 * (100vw - 450px)); } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { #content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } #content h1 { font-size: 50px; margin-bottom: 50px; } } ================================================ FILE: src/client/css/footer.css ================================================ footer { text-align: center; padding: 10px 0; } footer a { display: inline-block; color: rgb(207, 207, 207); margin: 10px 10px; text-decoration: underline; } footer label { font-size: 16px; color: rgb(207, 207, 207); margin: 10px 2px 10px 10px; } footer select { width: min-content; color: rgb(207, 207, 207); font-size: 1em; background-color: rgba(0, 0, 0, 0); margin: 10px 10px 10px 0px; cursor: pointer; } /* This ONLY changes the color of the text in the language-selection dropdown list so that the contrast is easier to read on Windows */ footer .language-option { color: rgb(33, 33, 33); } ================================================ FILE: src/client/css/guide.css ================================================ /* Guide page styles */ * { margin: 0; padding: 0; font-family: Verdana; border: 0; } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } .content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } .center { text-align: center; } .guide-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0; max-width: 100%; } .content h1 { font-size: 40px; font-family: georgia; text-transform: uppercase; margin: 0px; padding: 0; text-align: center; flex: 1; } .back-arrow { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s; border-radius: 0.5em; flex-shrink: 0; } .back-arrow-spacer { width: 40px; height: 40px; flex-shrink: 0; } .back-arrow:hover { background-color: rgba(0, 0, 0, 0.05); } .back-arrow:active { background-color: rgba(0, 0, 0, 0.1); } .back-arrow svg { width: 100%; height: 100%; padding: 8px; } .content h2 { margin: 2.25em 0 0; font-weight: normal; } .content .line-break { border: 0; border-top: 1px solid #adadad; margin: 0.5em 0 1em; } .content { line-height: 1.2; } .content p { margin: 1.5em 0; } .content li { margin: 0.75em 0 0.5em 0.5em; } .clear-float { clear: both; } .content img { box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.157); border-radius: 0.7em; border: 2px solid rgb(101, 101, 101); box-sizing: content-box; max-width: 100%; height: auto; } .img-promotionlines { margin: 0.75em 0 0.75em 1.5em; width: 50%; float: right; } .img-kingrookfork { margin: 0.75em 1.5em 0.75em 0; float: left; width: 42%; } .img-arrowindicators { margin: 0.75em 0 0.75em 1.5em; width: 25%; float: right; } .fairy-pieces { display: flex; height: min(35vmin, 400px); align-items: stretch; justify-content: center; margin: 1.5em 0; } .img-fairymoveset { box-sizing: border-box; margin: 0 1em 0 0; height: 100%; aspect-ratio: 1 / 1; } .img-fairymoveset img { width: 100%; height: 100%; object-fit: contain; } .fairy-card-container { font-size: min(2vmin, 20px); display: flex; box-sizing: border-box; } .left-arrow, .right-arrow { display: flex; width: 13%; flex-shrink: 0; cursor: pointer; } .left-arrow svg, .right-arrow svg { box-sizing: border-box; width: 100%; padding: 5%; border-radius: 0.5em; } .left-arrow svg:hover, .right-arrow svg:hover { background-color: rgb(234, 234, 234); } .left-arrow svg:active, .right-arrow svg:active { background-color: rgb(221, 221, 221); } .opacity-0_25 { opacity: 0.25; } .fairy-card { margin: 0 1em; display: flex; flex-direction: column; } .space-1 { flex-grow: 1; } .fairy-card-title { text-align: center; font-size: 1.6em; font-weight: bold; margin: 0 0 1em; text-shadow: 0 0.12em 0.2em rgba(0, 0, 0, 0.203); flex-grow: 0; } .fairy-card-description { margin: 0; font-size: 1em; flex-grow: 0; } .space-2 { flex-grow: 2; } /* Responsive styles */ /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { .content { max-width: calc(870px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } .content h1 { font-size: 50px; margin: 0px; } } @media only screen and (max-width: 700px) { .img-promotionlines, .img-kingrookfork { float: none; width: 95%; margin: 0.75em auto; display: block; } } @media only screen and (max-width: 500px) { .img-arrowindicators { width: 96px; } } @media only screen and (max-width: 600px), (max-height: 648px) { .img-fairymoveset { width: 95%; height: unset; margin: 0 0 0.75em; } .fairy-card-container { padding-bottom: 0.75em; min-height: 18em; } .left-arrow, .right-arrow { display: flex; max-width: 50px; } .fairy-pieces { flex-wrap: wrap; height: unset; } .fairy-card-title { font-size: 2.4em; } .fairy-card-description { font-size: 1.5em; } } ================================================ FILE: src/client/css/header.css ================================================ :root { /* THIS GETS OVERWRITTEN BY JAVASCRIPT in header.js that correctly sets the viewport height based on how much screen space the home button bar takes up! */ --vh: 100vh; --header-height: 40px; --dropdown-item-height: 43px; --header-link-hover-color: rgb(230, 230, 230); --currPage-background-color: rgb(237, 237, 237); --switch-on-color: rgb(97, 97, 97); /* Default value. Can be modified using javascript */ --header-link-max-padding: 16px; --header-link-min-padding: 8px; --CBC-in: cubic-bezier(0, 1.05, 0.47, 1); /* Settings dropdown IN curve */ --CBC-out: cubic-bezier(0.54, 0, 1, 0.97); /* Settings dropdown OUT curve */ --CBC-CM-in: cubic-bezier(0.09, 1.61, 0.36, 1); /* Checkmark IN curve */ --CBC-CM-out: cubic-bezier(0, 1.1, 1, 1); /* Checkmark OUT curve */ --CBC-switch: cubic-bezier(0, 1.05, 0.47, 1); /* Toggle switch curve */ } header { /* box-shadow: 0px 1px 5px rgb(107, 107, 107); overflow: scroll; white-space: nowrap; text-align: center; background-color: white; z-index: 1; */ position: fixed; left: 0; top: 0; right: 0; z-index: 1; display: flex; justify-content: space-between; height: var(--header-height); background-color: white; border-bottom: 1px solid black; box-shadow: 0 3px 4px rgba(0, 0, 0, 0.08); font-size: 16px; align-items: center; user-select: none; /* Prevent text selection */ } header a { text-decoration: none; color: black; display: flex; align-items: center; } header label { font-size: inherit; /* Prevents createaccount.css changing the font size. */ } .italic { font-style: italic; } /* All SVG settings. (Most settings dropdown SVGs are the same width in the document, we just scale them here to make them all VISUALLY the same size */ .svg-pawn { /* The pawn svg and loading animation that we use in several spots */ position: relative; bottom: 3px; height: 65%; aspect-ratio: 1; stroke: #666; fill: #666; } /* The spinny pawn animation */ .spinny-pawn { transform-origin: 50% 60%; /* Rotate around the center of mass (slightly downward) */ animation: spin 0.65s linear infinite; /* Spin animation with continuous loop */ } .svg-language, .svg-board, .svg-legalmove, .svg-perspective, .svg-selection, .svg-squares, .svg-mouse, .svg-sound, .svg-camera, .checkmark { width: 19px; aspect-ratio: 1; padding: 0 2px; } .svg-language { transform: scale(1.21); } .svg-perspective { transform: scale(1.1); } .svg-mouse { transform: scale(1.47); } .svg-sound { transform: scale(1.25); } .svg-camera { transform: scale(1.3); } .svg-undo { transform: scale(1.8); aspect-ratio: 1; } /* The Infinite Chess text and logo, left side of header */ .home { display: flex; gap: 5px; height: 100%; align-items: center; padding: 0 8px; white-space: nowrap; /* Prevent text from wrapping */ overflow: hidden; /* Hide overflow if needed */ } .home picture { height: 90%; } .home picture img { height: 100%; } .home p { font-family: georgia; font-size: 24px; } .home:hover p { text-decoration: underline; } /* Hide the "Infinite Chess" text when we are at compactness level 1 */ .home.compact-1 p { display: none; } .home.compact-1:hover { background-color: var(--header-link-hover-color); } /* The navigation hyperlinks, middle of header. Play, News, Leaderboard, Login, Create Account */ nav { display: flex; height: 100%; } nav a { padding: 0 calc(var(--header-link-max-padding)) 0; white-space: nowrap; /* Prevent text from wrapping */ overflow: clip; /* Hide overflow if needed. FIREFOX NEEDS THIS TO BE "CLIP" */ position: relative; /* Required for correct absolute positioning of news notification badge */ } nav span { padding-left: 4px; } nav .svg-pawn { bottom: 1px; } nav #svg-news { height: 55%; padding: 0 5px; } nav #svg-leaderboard { height: 55%; padding-left: 4px; } nav #svg-login { height: 60%; padding-left: 4px; } nav #svg-profile { height: 47.5%; padding: 0 4px 0 6px; } nav #svg-createaccount { height: 50%; padding-left: 7px; position: relative; top: 1px; } nav #svg-logout { height: 63%; padding-left: 5px; } nav a:hover { background-color: var(--header-link-hover-color); } /* Hide the navigation SVGs when we are at compactness level 2 */ nav.compact-2 svg { display: none; } nav.compact-2 span { padding: 0 4px; } /* Navigation SVGs are visible again, but not the text */ nav.compact-3 span { display: none; } nav.compact-3 #svg-news { padding: 0 4px; } nav.compact-3 #svg-leaderboard { padding: 0 4px; } nav.compact-3 #svg-profile { padding: 0 4px; } nav.compact-3 #svg-createaccount { padding-left: 5px; } nav.compact-3 #svg-logout { padding-left: 4px; } /* The gear and settings dropdown menu, right side of header. */ .settings { height: 100%; width: var(--header-height); display: flex; justify-content: center; align-items: center; cursor: pointer; } .settings.open { background-color: var(--currPage-background-color); } .settings:hover { background-color: var(--header-link-hover-color); } .settings:active { /* Prevents blue highlight when holding finger over the gear button */ background-color: var(--header-link-hover-color); -webkit-tap-highlight-color: transparent; } .gear { width: 45%; transition: transform 0.2s var(--CBC-out); } .settings.open .gear { transition: transform 0.3s var(--CBC-in); transform: rotate(60deg); } .dropdown { position: absolute; /* Position relative to the nearest positioned ancestor */ top: 100%; /* Aligns the top of the dropdown content to the bottom of the gear */ right: 0; min-width: 195px; width: fit-content; /* Polish needs to be able to fit content because it's a little bit wider */ /* Can't enable these because words "Perspective Sensitivity" won't wrap but instead increase the length of the whole dropdown. */ /* min-width: 195px; width: fit-content; */ background-color: white; box-shadow: -2px 3px 4px rgba(0, 0, 0, 0.1); z-index: 1; border: 1px solid black; border-right: unset; cursor: auto; border-radius: 0 0 0 5px; overflow: hidden; /* Prevent children from rendering outside the border */ transform: translateX(0); /* Slide into view */ transition: transform 0.3s var(--CBC-in), visibility 0s, opacity 0.25s ease-in-out; } .dropdown.visibility-hidden { transform: translateX( 100% ); /* Just off screen to the right, to start out, until it's animated in. */ transition: transform 0.2s var(--CBC-out), visibility 0s 0.2s, opacity 0.25s ease-in-out; } .dropdown-title { /* The back button at the top of 2+ deep dropdown */ display: flex; align-items: center; height: var(--dropdown-item-height); padding: 0 15px; cursor: pointer; border-bottom: 1px solid grey; } .dropdown-title:hover, .settings-dropdown-item:hover, .language-dropdown-item:hover, .legalmove-option:hover, .boolean-option:hover { background-color: var(--header-link-hover-color); } /* Dropdown items */ .settings-dropdown-item { display: flex; align-items: center; height: var(--dropdown-item-height); padding: 0 15px 0 8px; cursor: pointer; } p.text { padding: 10px 6px; max-width: 150px; margin-right: auto; } span.arrow-head-right, span.arrow-head-left { width: 8px; height: 8px; border-right: 3px solid #666; border-top: 3px solid #666; } span.arrow-head-right { margin-left: auto; transform: rotate(45deg) /* skew(10deg, 10deg); */; } span.arrow-head-left { margin-right: auto; transform: rotate(225deg) /* skew(10deg, 10deg); */; } .checkmark { width: 30px; aspect-ratio: 1; margin-left: auto; fill: #444; transition: transform 0.5s var(--CBC-CM-in); transform: scale(1); } .checkmark.visibility-hidden { transition: transform 0.2s var(--CBC-CM-out), visibility 0s 0.5s; transform: scale(0); } /* Switch toggles */ .switch { position: relative; } .switch input { display: none; } .switch > input + * { position: absolute; inset: 0; border-radius: 14px; background-color: #777; border: 2px solid #777; transition: 0.2s var(--CBC-switch); transition-property: background-color, border-color; } .switch > input + ::before { content: ''; display: block; border-radius: 14px; background-color: white; width: 50%; height: 100%; transition: transform 0.2s var(--CBC-switch); box-shadow: 0px 1px 2px #00000076; } .switch input:checked + ::before { transform: translateX(100%); } .switch input:checked + * { background-color: var(--switch-on-color); border-color: var(--switch-on-color); } /* Language nested dropdown */ .language-dropdown-item, .legalmove-option, .boolean-option { display: flex; align-items: center; cursor: pointer; } .language-dropdown-item { height: 48px; padding: 0 15px; } /* .language-dropdown-item p.name { } */ .language-dropdown-item p.englishName { color: grey; font-size: 0.7em; } /* Board theme nested dropdown */ .dropdown-scrollable { /* Set max height to bottom of screen */ max-height: calc(var(--vh) - var(--header-height) - var(--dropdown-item-height)); overflow-y: auto; } .appearance-dropdown { width: 211px; } p.theme-title { text-align: center; padding-top: 8px; } .theme-list { display: grid; grid-template-columns: repeat(auto-fill, 45.5px); justify-content: center; gap: 14px; /* Combined margin from both axes (7px) */ padding: 12px 0px 16px; border-bottom: 1px solid grey; } .theme-list img { width: 45.5px; image-rendering: pixelated; border-radius: 2px; outline: 3px solid rgb(97, 97, 97); cursor: pointer; justify-self: center; align-self: center; } .theme-list img:hover, .theme-list img.selected { outline: 5px solid black; } /* Legalmove shape nested dropdown */ /* .legalmove-dropdown { } */ .legalmove-option { height: 43px; padding: 0 15px 0 8px; } /* Gameplay dropdown */ .selection-option-title { display: flex; justify-content: center; align-items: center; height: 35px; } .boolean-option p.text { padding: 10px 6px 10px 3px; } .boolean-option { min-height: 43px; padding: 0 8px 0 8px; } .boolean-option .switch { width: 36px; height: 20px; margin: 0 2px 0 4px; } /* Perspective & Sound dropdowns */ .perspective-option, .sound-option { font-size: 14px; padding: 5px 0 10px; } .perspective-option .perspective-option-title, .sound-option .sound-option-title { display: flex; justify-content: center; align-items: center; height: 35px; } .perspective-option .perspective-option-title p { /* "Mouse Sensitivity", "Field of View" */ padding-left: 6px; } .perspective-option .slider-container, .sound-option .slider-container { display: flex; margin-left: 8px; } .perspective-option .slider, .sound-option .slider { width: 100%; } .perspective-option .slider:hover, .sound-option .slider:hover { cursor: pointer; } .perspective-option .value, .sound-option .value { padding-left: 5px; text-align: left; flex-shrink: 0; } .perspective-option.mouse-sensitivity .value, .sound-option.master-volume .value { width: 50px; } .perspective-option.fov .value { width: 35px; } /* Reset default buttons */ .reset-default-container { display: flex; justify-content: center; width: 100%; margin-top: 5px; } .reset-default { display: flex; align-items: center; width: fit-content; height: fit-content; border-radius: 15px; padding: 3px 8px; } .reset-default:hover { background-color: rgb(233, 233, 233); cursor: pointer; } .reset-default span { padding-left: 2px; } .reset-default-container .svg-undo { width: 19px; transform-origin: 70% 55%; } /* Ping Meter */ .ping-meter { display: flex; justify-content: space-between; align-items: center; height: 50px; padding: 0 15px; border-top: 1px solid grey; overflow: hidden; /* Don't let the connection bars glow effect leak above */ font-size: 0px; /* Prevents a small amount of margin between each element */ } .ping-meter .ping { font-size: 15px; } .ping-meter .ping-value { font-size: 15px; padding: 0 3px 0 6px; } .ping-meter .ms { font-size: 13px; } /* .ping-bars { } */ .ping-bar { outline: 1px solid #0000008c; display: inline-block; width: 9px; /* box-shadow: 0px 0px 5px 0px #0000007a; */ background-color: rgb(210, 210, 210); } .ping-bar.green { background-color: #78ff78; } .ping-bar.yellow { background-color: #f8f878; } .ping-bar.red { background-color: #ff8b8b; } .ping-glow { /* Relatively positioned 0-space element that only glows */ box-shadow: 0px 0px 80px 30px #000000c4; position: relative; bottom: 7px; z-index: -1; /* Places glow behind all bars */ left: 10px; overflow: hidden; /* transform: scaleY(0.7); */ } /* Miscellaneous (some of these can probably be put in a universal stylesheet for all pages, not just the header stylesheet) */ /* Greys the background of the navigation hyperlink we are currently in */ .currPage { background-color: var(--currPage-background-color); } .hidden { display: none; } .center { text-align: center; } .visibility-hidden { visibility: hidden; } .transparent { opacity: 0%; pointer-events: none; } /* Used for disallowing changing your coordinates in an online game */ .set-cursor-to-not-allowed { cursor: not-allowed; } .unselectable { /* Makes text inside the element unselectable (sometimes worsens the experience if you don't intent to) */ user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; } .selectable { /* Makes text inside the elements with the .unselectable class re-selectable */ user-select: text; -moz-user-select: text; -webkit-user-select: text; -ms-user-select: text; } /* Animations */ @keyframes spin { 0% { transform: rotate(0deg); /* Start at 0 degrees */ } 100% { transform: rotate(360deg); /* Complete a full 360 degree rotation */ } } /* * Tooltips * * The JS tooltip system injects a fixed #tooltip-popup div and #tooltip-arrow div * directly into document.body, so no chance of being clipped by their parent containers. */ /* Tooltip box */ #tooltip-popup { position: fixed; background-color: black; color: rgb(236, 236, 236); text-align: center; border-radius: 6px; font-size: 12px; width: max-content; max-width: 150px; padding: 5px; opacity: 0; transition: opacity 0.1s ease-in-out; pointer-events: none; word-wrap: break-word; word-break: break-word; white-space: normal; z-index: 10000; } /* Tooltip arrow */ #tooltip-arrow { position: fixed; width: 0; height: 0; border-width: 5px; /* MUST match ARROW_HALF in tooltips.ts */ border-style: solid; opacity: 0; transition: opacity 0.1s ease-in-out; pointer-events: none; z-index: 10000; } /* Arrow pointing downward (used for tooltip-d / tooltip-dl / tooltip-dr) */ #tooltip-arrow.tooltip-arrow-down { border-color: transparent transparent black transparent; } /* Arrow pointing upward (used for tooltip-u / tooltip-ul / tooltip-ur) */ #tooltip-arrow.tooltip-arrow-up { border-color: black transparent transparent transparent; } /* Badge shine properties - needed both for play and for member page */ #checkmate-badge-bronze { --shine-color: rgba(229, 203, 180, 0.3); } #checkmate-badge-silver { --shine-color: rgba(192, 192, 192, 0.22); } #checkmate-badge-gold { --shine-color: rgba(255, 215, 0, 0.24); } .badge .shine-clockwise, .badge .shine-anticlockwise { position: absolute; top: 50%; left: 50%; width: 200%; height: 200%; transform: translate(-50%, -50%); /* Conic gradient produces the rays */ background: repeating-conic-gradient(var(--shine-color) 0deg 15deg, transparent 15deg 40deg); /* Use a radial mask to fade the rays proportional to distance */ mask-image: radial-gradient(circle, black 30%, transparent 55%); -webkit-mask-image: radial-gradient(circle, black 30%, transparent 55%); opacity: 0; transition: opacity 0.4s ease; animation: rotateShine linear infinite; pointer-events: none; /* This prevents the shine from being part of the hover area */ z-index: -1; } .badge .shine-clockwise { animation-direction: normal; animation-duration: 13s; } .badge .shine-anticlockwise { animation-direction: reverse; animation-duration: 26s; } .badge:hover .shine-clockwise, .badge:hover .shine-anticlockwise { opacity: 1; } @keyframes rotateShine { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } } /* Username Embed Containers */ .username-embed { display: flex; align-items: center; /* flex-wrap: wrap; */ gap: 0.3em; width: fit-content; } .username-embed .svg-profile, .svg-engine { width: 1em; height: 1em; aspect-ratio: 1; } .username-embed .username { font-size: 1em; font-weight: bold; color: #000; text-decoration: none; } .username-embed .elo { color: #666; font-size: 0.9em; } .username-embed .eloChange { font-size: 0.9em; } .username-embed .eloChange.positive { color: green; } .username-embed .eloChange.negative { color: red; } /* Fades the right side of the element away to hide overflow text */ .fade-element { /* prettier-ignore */ mask-image: linear-gradient( to right, black 0%, black calc(100% - 20px), transparent 100% ); -webkit-mask-image: linear-gradient( to right, black 0%, black calc(100% - 20px), transparent 100% ); } .justify-content-right { justify-content: right; } ================================================ FILE: src/client/css/icnvalidator.css ================================================ :root { --bg-color: #191919; --text-color: #d4d4d4; --primary-color: #4a90e2; --secondary-color: #252526; --border-color: #333; --accent-color: #569cd6; --success-color: #4caf50; --warning-color: #ff9800; --danger-color: #f44336; --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; } body { font-family: var(--font-family); background-color: var(--bg-color); color: var(--text-color); margin: 0; padding: 2rem; display: flex; justify-content: center; } .container { width: 100%; max-width: 1200px; } h1, h2 { color: var(--primary-color); border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; } .upload-section { background-color: var(--secondary-color); border: 2px dashed var(--border-color); border-radius: 8px; padding: 2rem; text-align: center; margin-bottom: 2rem; transition: border-color 0.3s; } .upload-section:hover { border-color: var(--primary-color); } .upload-section.drag-over { border-color: var(--accent-color); background-color: rgba(74, 144, 226, 0.1); } input[type='file'] { display: none; } .file-label { display: inline-block; background-color: var(--primary-color); color: white; padding: 1rem 2rem; border-radius: 4px; cursor: pointer; font-weight: bold; transition: background-color 0.2s; } .file-label:hover { background-color: var(--accent-color); } button { background-color: var(--primary-color); color: white; border: none; padding: 0.75rem 1.5rem; font-weight: bold; cursor: pointer; transition: background-color 0.2s; border-radius: 4px; font-size: 1em; } button:hover { background-color: var(--accent-color); } button:disabled { background-color: #666; cursor: not-allowed; } .progress-section { background-color: var(--secondary-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; display: none; } .progress-bar { width: 100%; height: 30px; background-color: var(--bg-color); border-radius: 4px; overflow: hidden; margin: 1rem 0; } .progress-fill { height: 100%; background-color: var(--primary-color); transition: width 0.3s; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; } .summary-section { background-color: var(--secondary-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; display: none; } .summary-hero { display: flex; align-items: center; justify-content: center; background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 2rem; margin-bottom: 2rem; gap: 3rem; } .hero-stat { display: flex; flex-direction: column; align-items: center; } .hero-value { font-size: 3.5rem; font-weight: 800; line-height: 1; margin-bottom: 0.5rem; } .hero-label { color: var(--text-color); opacity: 0.7; font-size: 0.9rem; font-weight: bold; letter-spacing: 1px; } .hero-divider { width: 2px; height: 60px; background-color: var(--border-color); } /* Dynamic colors for the percentage */ .hero-value.perfect { color: var(--success-color); } .hero-value.good { color: var(--accent-color); } .hero-value.bad { color: var(--warning-color); } .hero-value.terrible { color: var(--danger-color); } .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1rem; } .stat-card { background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: 4px; padding: 1rem; } .stat-card h3 { margin: 0 0 0.5rem 0; color: var(--accent-color); font-size: 0.9em; text-transform: uppercase; } .stat-value { font-size: 2em; font-weight: bold; margin: 0; } .stat-value.success { color: var(--success-color); } .stat-value.error { color: var(--danger-color); } .stat-value.warning { color: var(--warning-color); } .details-section { background-color: var(--secondary-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; } .error-list { max-height: 800px; overflow-y: auto; margin-top: 1rem; } .error-item { background-color: var(--bg-color); border-left: 4px solid var(--danger-color); padding: 1rem; margin-bottom: 1rem; border-radius: 4px; } .error-item.icnconverter { border-left-color: var(--warning-color); } .error-item.formulator { border-left-color: var(--danger-color); } .error-item.illegal-move { border-left-color: #e91e63; } .error-item.termination-mismatch { border-left-color: #9c27b0; } .error-header { font-weight: bold; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; } .error-type { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8em; text-transform: uppercase; } .error-type.icnconverter { background-color: var(--warning-color); color: var(--bg-color); } .error-type.formulator { background-color: var(--danger-color); color: white; } .error-type.illegal-move { background-color: #e91e63; color: white; } .error-type.termination-mismatch { background-color: #9c27b0; color: white; } .error-message { font-family: 'Courier New', Courier, monospace; font-size: 0.9em; white-space: pre-wrap; word-break: break-all; margin-top: 0.5rem; padding: 0.5rem; background-color: rgba(0, 0, 0, 0.3); border-radius: 4px; } .variant-stats { margin-top: 1rem; } .variant-item { display: flex; flex-direction: column; padding: 0.75rem; margin-bottom: 0.5rem; background-color: var(--bg-color); border-radius: 4px; } .variant-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 0.5rem; } .variant-details { display: flex; gap: 0.5rem; flex-wrap: wrap; font-size: 0.85em; } .v-stat { background-color: #333; padding: 2px 8px; border-radius: 4px; color: #aaa; } .v-stat.active { background-color: rgba(255, 255, 255, 0.1); color: var(--text-color); } .v-stat span { font-weight: bold; } .v-stat.warn span { color: var(--warning-color); } .v-stat.err span { color: var(--danger-color); } .variant-errors.warn { color: var(--warning-color); } .variant-errors.err { color: var(--danger-color); } pre { background-color: #111; border: 1px solid var(--border-color); border-radius: 4px; padding: 1rem; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; } .log-section { background-color: var(--secondary-color); border: 1px solid var(--border-color); border-radius: 8px; padding: 1.5rem; margin-top: 2rem; } .log-output { max-height: 300px; overflow-y: auto; background-color: #111; border: 1px solid var(--border-color); border-radius: 4px; padding: 1rem; font-family: 'Courier New', Courier, monospace; font-size: 0.85em; } .log-entry { padding: 0.25rem 0; border-bottom: 1px solid #222; } .log-entry:last-child { border-bottom: none; } .log-entry.error { color: var(--danger-color); } .log-entry.warning { color: var(--warning-color); } .log-entry.success { color: var(--success-color); } .log-entry.info { color: var(--accent-color); } ================================================ FILE: src/client/css/index.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.102); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; } .content { background-color: rgba(255, 255, 255, 0.805); margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } .logo { text-align: center; margin-bottom: 40px; } .logo h1 { display: inline-block; vertical-align: bottom; font-size: 40px; font-family: georgia; text-shadow: 0 0 5px rgba(0, 0, 0, 0.318); } .logo img { display: inline-block; vertical-align: bottom; display: none; width: 70px; } .logo p { margin-top: 15px; } iframe { --videoWidth: 85vw; width: var(--videoWidth); height: calc(var(--videoWidth) * 9 / 16); } .content h2 { font-size: 30px; margin: 35px 0 20px; } .content h3 { font-size: 25px; margin: 35px 0 20px; } .content p { line-height: 1.5; font-size: 17px; margin: 20px 0px; } .patreon-container { display: flex; flex-wrap: wrap; justify-content: center; } .content .patreon-container p { font-size: 18px; background-color: rgb(244, 244, 244); padding: 0.5em 0.8em; border-radius: 0.5em; box-shadow: 0 0 18px rgba(0, 0, 0, 0.325); /* text-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); */ transition: box-shadow 0.25s, transform 0.25s; margin: 10px 10px; cursor: default; } .content .patreon-container p:hover { transform: translate(0%, 0%) scale(1.1); box-shadow: 0 0 18px rgba(0, 0, 0, 0.54); } .IM { color: rgb(0, 38, 255); font-weight: bold; font-size: 0.9em; text-shadow: -2px 0px 0.6em rgba(0, 38, 255, 0.2); } /* Play button */ .play-button { position: relative; display: inline-block; padding: 0.6em 1.5em; font-size: 2em; text-align: center; text-decoration: none; color: black; background-color: rgb(233, 233, 233); border: 2px solid #2e2e2e; border-radius: 0.5em; margin-bottom: 50px; overflow: hidden; transition: background-color 0.3s ease, transform 0.2s ease, box-shadow 0.2s ease; box-shadow: 0 0 50px rgb(0 0 0 / 25%); } .play-button::before { content: ''; position: absolute; top: 0; left: -75%; width: 50%; height: 100%; background: linear-gradient( 120deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0.4) 100% ); transform: skewX(-20deg); } .play-button:hover { transform: scale(1.05); box-shadow: 0 0 50px rgb(0 0 0 / 45%); } .play-button:hover::before { animation: shine 0.75s ease-in-out; } @keyframes shine { from { left: -75%; } to { left: 125%; } } /* GitHub Contributors */ .github-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 1em; } .github-container a { position: relative; box-shadow: 0px 0px 15px 0 #0000003d; } .github-container a, .github-container img { border-radius: 50%; width: 80px; height: 80px; } .github-container .github-stats { display: flex; position: absolute; top: 0; justify-content: center; flex-direction: column; border-radius: 50%; height: 100%; width: 100%; opacity: 0; background-color: rgb(33, 33, 33); color: #fff; transition: opacity 200ms; } .github-container a:hover .github-stats { opacity: 0.7; } .github-container .github-stats p { padding: 0 4px; word-wrap: break-word; /* Allows words to break onto the next line */ line-height: 1; text-align: center; margin: 0; } .github-container .github-stats p.name { font-weight: bold; font-size: 10px; margin: 0 0 5px 0; } .github-container .github-stats p.contribution-count { font-size: 9px; } .grey { color: rgba(0, 0, 0, 0.345); } .center { text-align: center; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } .bold { font-weight: bold; } /* Reveal pictures in logo */ @media only screen and (min-width: 480px) { .logo img { display: unset; width: calc(70px + 0.09 * (100vw - 475px)); } .logo h1 { font-size: calc(40px + 0.059 * (100vw - 475px)); } } /* Cap content width size */ @media only screen and (min-width: 810px) { .content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px 100px; min-height: 800px; } .logo h1 { font-size: 60px; } .logo img { width: 100px; } iframe { width: 700px; height: 394px; } } ================================================ FILE: src/client/css/leaderboard.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } .content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } .content h1 { font-size: 40px; font-family: georgia; text-transform: uppercase; margin-bottom: 40px; } .content p { line-height: 1.5; font-size: 12px; color: gray; margin: 1em; } /* Don't hide borders on hr tags */ hr { border: 1px solid #00000057; margin-bottom: 18px; } #user_ranking_container { font-size: 18px; font-weight: bold; } #user_ranking_text { margin-right: 10px; } /* Table styling */ table { width: 95%; border-collapse: collapse; margin: 20px; align-self: center; table-layout: fixed; /* Makes table width work for small screens */ } thead tr { border: none; } tr { border: 1px solid #d2d2d2; } tr:nth-child(even) { background-color: #f7f7f7; } .logged_in_user_entry { /* background-color: rgba(0, 128, 0, 0.3) !important; */ --mid: #cbccff; --end: #eaeaff; background: linear-gradient(0deg, var(--end), var(--mid), var(--mid), var(--mid), var(--end)); } th, td { padding: 8px 12px; text-align: left; } /* Column widths (usernames might be long) */ th:nth-child(1), td:nth-child(1) { width: 20%; } th:nth-child(2), td:nth-child(2) { width: 50%; } th:nth-child(3), td:nth-child(3) { width: 30%; } /* Show More Button */ .button-wrapper { margin-bottom: 1em; } .button-wrapper button { padding: 10px 16px; font-size: 17px; cursor: pointer; border: 2px solid #767676; border-radius: 12px; background-color: #f4f4f4; } .button-wrapper button:hover { background-color: #dfdbdb; } /* Start increasing header links width */ @media only screen and (min-width: 450px) { .content h1 { font-size: calc(40px + 0.028 * (100vw - 450px)); } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { .content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } .content h1 { font-size: 50px; margin-bottom: 50px; } } ================================================ FILE: src/client/css/login.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.214); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 425px; } #content { background-color: rgba(255, 255, 255, 0.805); min-height: 425px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } #content h1 { font-size: 40px; font-family: georgia; margin-bottom: 60px; } .formfield { width: fit-content; text-align: right; margin: auto; line-height: 2.2em; } #username-input-line, #password-input-line, #email-input-line { text-align: center; } #password-input-line { margin-top: 12px; display: inline-block; } #confirm-password-line { margin-top: 12px; } .formfield label { font-size: 18px; vertical-align: middle; margin-right: 2px; } form input { border: 0; border-radius: 4px; padding: 0.4em; box-shadow: 0 0 8px rgba(0, 0, 0, 0.63); font-size: 15px; width: 180px; } form input:focus { outline: solid 1px black; } form input:not([type='submit']):hover { box-shadow: 0 0 8px rgb(0, 0, 0); } /* The instructions for the request password reset form */ .form-instruction { font-size: 16px; color: #333; /* A dark grey, easy to read */ margin-bottom: 20px; /* Add some space between the text and the email input */ max-width: 350px; /* Optional: Constrain width on wider screens */ margin-left: auto; /* Optional: Center the text block */ margin-right: auto; /* Optional: Center the text block */ line-height: 1.4em; } div.error { font-size: 16px; text-align: center; color: red; margin-top: 1em; line-height: 1em; } form input[type='submit'] { height: 30px; min-width: 0; width: fit-content; height: fit-content; background-color: white; font-size: 16px; transition: 0.1s; margin-top: 25px; outline: 0; transition: 0.1s; } form input[type='submit'].ready:hover { box-shadow: 0 0 8px rgb(0, 0, 0); transform: scale(1.125); } form input[type='submit'].ready:focus { outline: solid 1px black; } .center { text-align: center; } .unavailable { color: rgba(0, 0, 0, 0.199); } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); color: black; } /* Start increasing header links width */ @media only screen and (min-width: 450px) { #content h1 { font-size: calc(40px + 0.027 * (100vw - 450px)); } form input { width: calc(180px + 0.15 * (100vw - 450px)); } } /* Stop increasing header links width */ @media only screen and (min-width: 715px) { form input { width: 220px; } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { #content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } #content h1 { font-size: 50px; margin-bottom: 70px; } } /* Password Reset Form */ /* Container for the "Forgot?" and "Back to Login" links */ .forgot-link-container { margin-top: 24px; font-size: 14px; } /* Style for the links to make them look clickable */ .forgot-link-container a { color: #0056b3; /* A standard hyperlink blue */ text-decoration: underline; cursor: pointer; } .forgot-link-container a:hover { color: #003d7a; } /* Style for the success message (e.g., after sending the email) */ div.success { font-size: 16px; text-align: center; color: green; margin-top: 1em; line-height: 1em; } ================================================ FILE: src/client/css/member.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.145); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } #content { display: flex; flex-direction: column; background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; text-align: center; } #verifyerror h2 { font-size: 16px; } #verifyerror p { font-size: 11px; margin-top: 0.5em; margin-bottom: 20px; } #verifyconfirm { font-size: 16px; margin-bottom: 20px; } #content a { color: rgb(0, 0, 0); } #sendemail:hover { cursor: pointer; } .member, section { background-color: rgba(238, 238, 238, 0.655); border-radius: 6px; border: solid 1px rgba(0, 0, 0, 0.123); margin-bottom: 20px; padding: 12px; } .member { display: flex; gap: 4%; } .member img { display: inline-block; height: 100px; vertical-align: top; } .membername-container { display: flex; flex-direction: column; justify-content: end; } .member h1 { font-size: 16px; font-family: georgia; } /* Badges */ #badgelist { display: flex; height: 60px; } #badgelist img { height: 100%; } .badge { position: relative; transition: transform 0.4s ease; user-select: none; width: 60px; height: 60px; } .badge:hover { transform: scale(1.1); } /* Badge shine properties are in header.css since they are shared with badges on play page */ .stats { padding: 12px 12px; } .stats p { display: inline-block; margin: 0px 16px; line-height: 2em; } #content-container { display: flex; flex-direction: column; flex-grow: 2; } .action-button { margin-bottom: 20px; padding: 0.7em 1em; border-radius: 0.5em; background-color: white; box-shadow: 0 0 8px rgba(0, 0, 0, 0.502); transition: 0.15s; } .action-button:hover { padding: 0.8em 1.15em; box-shadow: 0 0 8px rgba(0, 0, 0, 0.799); cursor: pointer; } #delete-account { margin-bottom: 0; background-color: #fff1f1; color: red; font-weight: bold; border: 1.5px solid red; box-shadow: 0 0 8px rgba(148, 0, 0, 0.502); } #action-button:hover { transform: scale(1.1); box-shadow: 0 0 8px rgba(255, 211, 211, 0.799); } #show-account-info:active { box-shadow: 0 0 8px rgb(0, 0, 0); } #delete-account:active { background-color: #ffcaca; } #accountinfo { /* background-color: rgba(219, 219, 219, 0.655); */ padding: 10px 16px 12px; text-align: left; } #accountinfo h6 { text-transform: uppercase; margin-bottom: 6px; } .hidden { display: none; } .currPage { background-color: rgb(236, 236, 236); } .center { text-align: center; } .red { color: red; } .green { color: rgb(0, 162, 0); } .underline { text-decoration: underline; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } /* Start increasing header links width */ @media only screen and (min-width: 450px) { .member { padding: calc(12px + (100vw - 450px) * 0.05); } .member img { height: calc(100px + (100vw - 450px) * 0.165); } } /* Stop increasing header links width */ @media only screen and (min-width: 715px) { #verifyerror h2, #verifyconfirm { font-size: 20px; } #verifyerror p { font-size: 13px; } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { #content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: calc(100vh - 182px); } .member { padding: 30px; } .member img { height: 160px; } } ================================================ FILE: src/client/css/news.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } .content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } .content h1 { font-size: 40px; font-family: georgia; text-transform: uppercase; margin-bottom: 40px; } .news-posts .news-post h1 { font-size: 1.7em; margin-bottom: 1em; font-family: inherit; text-transform: none; } .content h2, .content h3 { margin: 1em 0; } .content p { line-height: 1.5; font-size: 17px; margin-bottom: 1em; } .content p.status { font-style: italic; font-size: 16px; margin-bottom: 35px; } .content p.date { margin-top: 35px; font-weight: bold; font-size: 18px; } .content ul li { margin-bottom: 0.7em; } .center { text-align: center; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } .red { color: red; } /* Don't hide borders on hr tags */ hr { border: 1px solid #00000057; } .news-post { display: flex; flex-direction: column; width: 100%; height: 100%; } .news-post-date { font-size: 0.75em; font-weight: 700; color: rgb(0 0 0 / 47%); margin-top: 1em; } .news-post-markdown { margin: 1em 0; } iframe { width: 350px; /* Makes the iframe take the full width of the parent */ max-width: 100%; aspect-ratio: 16 / 9; /* Set your desired aspect ratio (16:9 in this case) */ height: auto; /* Automatically adjust the height based on the aspect ratio */ } /* Start increasing header links width */ @media only screen and (min-width: 450px) { .content h1 { font-size: calc(40px + 0.028 * (100vw - 450px)); } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { .content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } .content h1 { font-size: 50px; margin-bottom: 50px; } } ================================================ FILE: src/client/css/play.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; border: 0; -webkit-tap-highlight-color: transparent; /* Suppress blue flash on tap */ /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { background-color: rgb(33, 33, 33); } /* Variables */ :root { /* 100vw, but with a maximum, so some UIs don't get too big. */ --vw-capped: clamp(0px, 100vw, 1086px); --nav-bar-height: 41px; /* 40 + 1 for border */ /* The viewport height, subtract the navigation bar height. */ --vh-sub-nav: calc(100vh - var(--nav-bar-height)); /* The viewport height on phones can change. */ --dvh-sub-nav: calc(100dvh - var(--nav-bar-height)); /* The minimum between the viewport width and height */ --vwh: min(var(--vh-sub-nav), var(--vw-capped)); } /* Everything besides the top navigation bar */ main { position: fixed; top: var(--nav-bar-height); bottom: 0; left: 0; right: 0; display: flex; flex-direction: row; /* Board editor sidebar dimensions */ --editor-sidebar-width: 240px; --editor-tab-width: 28px; --editor-menu-shadow: 3px 0px 10px 0px rgba(0, 0, 0, 0.4); --editor-transition: transform 0.25s ease; } button { cursor: pointer; } /* Left vertical bar of Board Editor */ .editor-menu { position: relative; height: 100%; overflow-x: clip; overflow-y: auto; box-shadow: var(--editor-menu-shadow); background-color: #ffffff; z-index: 4; /* Stops nav bar shadow from being overtop the editor menu */ /* Controls the size of all its children. */ font-size: 16px; width: var(--editor-sidebar-width); } /* Toggle button: only visible on narrow screens, positioned as a sibling of the sidebar */ /* Uses transform (GPU-accelerated) to stay perfectly in sync with the sidebar animation */ .editor-menu-toggle { display: none; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: var(--editor-tab-width); height: 70px; background-color: #ffffff; border-radius: 0 6px 6px 0; box-shadow: var(--editor-menu-shadow); /* Clip any leftward shadow bleed so it doesn't visually separate from the sidebar */ clip-path: inset(-100px -100px -100px 0); align-items: center; justify-content: center; z-index: 4; /* Must be above .load-position-modal */ font-size: 14px; color: #333; transition: var(--editor-transition); } .editor-menu-toggle::after { content: '▶'; } /* * Narrow screens: sidebar becomes a collapsible overlay. * MUST MATCH guifloatingwindow.NARROW_THRESHOLD */ @media only screen and (max-width: 727px) { .editor-menu { position: absolute; transform: translateX(-100%); transition: var(--editor-transition); } .editor-menu.expanded { transform: translateX(0); } /* Show the toggle whenever the editor is open (sidebar not hidden) */ .editor-menu:not(.hidden) ~ .editor-menu-toggle { display: flex; } /* Collapsed: only vertical centering — tab sits flush at left: 0 */ /* (inherits base transform: translateY(-50%) — no override needed) */ /* Expanded: tab slides to the right edge of the sidebar */ .editor-menu.expanded ~ .editor-menu-toggle { transform: translateX(var(--editor-sidebar-width)) translateY(-50%); } .editor-menu.expanded ~ .editor-menu-toggle::after { content: '◀'; } } .editor-header { text-align: center; font-weight: bold; margin: 0.5em 0; color: #333; } .editor-separator { border: none; border-top: 1px solid #ccc; margin: 0.2em 0.2em; } /* The row containing the position name and dirty indicator. */ .editor-positionname-row { display: flex; justify-content: center; align-items: center; gap: 0.35em; padding: 0.3em 1em; } /* Dirty (unsaved changes) indicator: an amber dot immediately left of the position name */ .dirty-indicator { flex-shrink: 0; width: 0.6em; height: 0.6em; border-radius: 50%; background-color: #f59e0b; } .editor-positionname { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* SVG resizings */ .svg-reset { transform: scale(0.85) translate(2%, 0%); } .svg-delete { aspect-ratio: 1; transform: scale(0.8); } .svg-load-position { transform: scale(0.75); } .svg-save-position-as { transform: scale(0.82); } .svg-save-position { transform: scale(0.82); } .svg-copy-notation { transform: scale(-0.75); } .svg-paste-notation { transform: scale(0.75); } .svg-gamerules { transform: scale(0.75); } .svg-start-local-game { transform: scale(0.9); } .svg-start-engine-game { transform: scale(0.8); } .svg-normal { transform: scale(0.65) translate(3%, 0%); } .svg-eraser { transform: scale(0.85); } .svg-selection-tool { transform: scale(0.9) translate(-1%, -1%); } .svg-select-all { transform: scale(0.8); } .svg-delete-selection { transform: scale(1); } .svg-copy-selection { transform: scale(0.8); } .svg-paste-selection { transform: scale(0.75); } .svg-flip-selection-horizontally { transform: scale(0.8); } .svg-flip-selection-vertically { transform: scale(0.8); } .svg-rotate-selection-left { transform: scale(0.85); } .svg-rotate-selection-right { transform: scale(-0.85, 0.85); } .svg-invert-selection-color { transform: scale(0.8); } /* Specific section stylings... */ .position-actions, .selection-actions { display: grid; grid-template-columns: repeat(5, 1fr); } /* Buttons that can't receive the .active class (only 1 state): Give them an active state */ .position-actions .instant:active, .selection-actions div:active { background-color: var(--background-theme-color); } .position-actions div, .editor-tools div, .selection-actions div, .editor-types .piece { cursor: pointer; } .editor-tools { display: grid; grid-template-columns: repeat(4, 1fr); } .selection-actions div.disabled { opacity: 0.4; pointer-events: none; } /* Palette */ .color-select { border: 0.2em solid black; border-radius: 1.5em; margin: 0.5em; height: 2.8em; box-sizing: border-box; display: flex; justify-content: center; align-items: center; } .color-select:hover { outline: 0.25em solid black; outline-offset: -0.125em; cursor: pointer; } /* Text with this class takes the opposite color of the background beneath. */ .opposite-color-text { color: white; /* base color to difference from */ mix-blend-mode: difference; } /* The grid of piece types in the Palette section */ .editor-types { display: grid; grid-template-columns: repeat(4, 1fr); } .editor-types .piece, .editor-tools div, .position-actions div, .selection-actions div { display: flex; align-items: center; justify-content: center; border-radius: 0.2em; } .editor-types .piece { margin: 0.1em; } .editor-tools div, .position-actions div, .selection-actions div { margin: 0.2em; } .editor-types .piece:hover, .editor-tools div:hover, .position-actions div:hover, .selection-actions div:hover { outline: 0.25em solid black; outline-offset: -0.125em; } .active { outline-offset: -0.125em; outline: 0.25em solid black; background-color: var(--background-theme-color); } .void { background-color: black; margin: 5px; } .editor-types .void:hover { outline: 0.25em solid #757575; } .editor-types .void.active { outline: 0.25em solid #6d6d6d; } /* Entire board UI, including loading screen, canvas and overlay */ #boardUI { position: relative; flex-grow: 1; min-width: 0; height: 100%; } /* Loading Page. A COUPLE OF THSEE CLASSES are also used for the game's loading animation page! */ .animation-container { transition: opacity 0.4s; z-index: 1; pointer-events: none; display: flex; background-color: black; justify-content: center; /* Center horizontally */ align-items: center; /* Center vertically */ position: absolute; top: 0; bottom: 0; left: 0; right: 0; overflow: hidden; } .loading-glow { position: absolute; top: 0; bottom: 0; left: 0; right: 0; --ring-color: rgb(60, 60, 60, 1); background: radial-gradient(circle, var(--ring-color) 0%, black 70%); color: red; z-index: -1; /* Render below checkers */ transition: 0.5s; } .loadingGlowAnimation { animation: loadingGlow 1.2s alternate infinite cubic-bezier(0.42, 0, 0.58, 1); } @keyframes loadingGlow { 0% { transform: scale(1.2); opacity: 70%; } 100% { transform: scale(2); } } .loading-glow.loading-glow-error { --ring-color-error: rgb(60, 45, 45); background: radial-gradient(circle, var(--ring-color-error) 0%, black 70%); } .loading-text { color: white; position: absolute; font-family: Verdana; font-size: calc(30px + 1.2vw); letter-spacing: 0.05em; font-weight: bold; animation: 0.6s infinite cubic-bezier(0.42, 0, 0.58, 1) alternate loadingPulsing, 1.2s infinite cubic-bezier(0.42, 0, 0.58, 1) alternate loadingExpand; } @keyframes loadingPulsing { from { opacity: 100%; } to { opacity: 60%; } } @keyframes loadingExpand { from { } to { transform: scale(1.04); } } .loading-error { color: red; position: absolute; font-family: Verdana; text-align: center; } .loading-error h1 { font-size: calc(30px + 1.2vw); letter-spacing: 0.05em; font-weight: bold; margin-bottom: 0.1em; } .loading-error p { font-size: 16px; padding: 0 1em; } .checkerboard { width: 100vw; height: 100svh; background: repeating-conic-gradient(black 0% 25%, transparent 0% 50%) 50% / 20vmin 20vmin; } /* Canvas and the Overlay containing all html elements above the canvas */ canvas { position: absolute; width: 100%; height: 100%; } /* The game loading screen when loading svgs and generating spritesheet */ .game-loading-screen { position: absolute; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; } .game-loading-screen.transparent { /* Adding this rule here instead of in the non-transparent loading screen means that the opacity will only be transitioned one-way */ transition: opacity 0.3s; } .game-loading-screen .spinny-pawn { --width: 90px; --color: #e5e5e5; width: var(--width); height: var(--width); stroke: var(--color); fill: var(--color); } /* The overlay that contains all UI elements overtop the canvas. */ #overlay { position: absolute; width: 100%; height: 100%; container-type: inline-size; /* Enables container queries on this element */ } /* Discord & Game Credits external links on title screen and invite creation screen */ .menu-external-links { position: absolute; bottom: 0; right: 0; left: 0; z-index: 1; } .menu-external-links .discord-icon { position: absolute; left: 0; bottom: 0; width: calc(30px + var(--vw-capped) * 0.03); margin: 8px 17px; opacity: 0.4; } .menu-external-links .discord-icon:hover { opacity: 0.55; } .menu-external-links .github-icon { position: absolute; left: 0; bottom: 0; width: calc(30px + var(--vw-capped) * 0.03); margin: 10px calc(40px + var(--vw-capped) * 0.054); opacity: 0.4; } .menu-external-links .github-icon:hover { opacity: 0.55; } .menu-external-links .credits { opacity: 0.5; font-weight: bold; position: absolute; right: 0; bottom: 0; color: black; text-decoration: none; margin: 12px 17px; font-size: calc(16px + var(--vw-capped) * 0.012); } .menu-external-links .credits:hover { opacity: 0.7; } /* Title Screen: Play, guide, board editor */ .title { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: grid; grid-template: min(16vw, 173px, calc(var(--vh-sub-nav) * 0.184)) repeat( 4, min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) ) / 1fr min(50vw, 542px, calc(var(--vh-sub-nav) * 0.575)) 1fr; gap: min(2vw, 22px, calc(var(--vh-sub-nav) * 0.023)); padding-bottom: min(10vw, 108px, calc(var(--vh-sub-nav) * 0.115)); justify-content: center; align-content: center; } .title h1 { font-size: min(10vw, 108px, calc(var(--vh-sub-nav) * 0.115)); font-family: Georgia; color: rgb(0, 0, 0); text-shadow: 1px 2px 3px rgb(255, 255, 255); text-align: center; overflow: visible; grid-column: 1 / 4; } /* All bubble buttons on title screen have similar design */ .titlebubble { box-shadow: 2px 4px 6px 0px rgb(0, 0, 0); border: 2px solid rgb(139, 139, 139); border-radius: min(1.3vw, 14px, calc(var(--vh-sub-nav) * 0.015)); color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); background: linear-gradient(to bottom, white, rgb(226, 226, 226), white); } .title button { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); grid-column: 2 / 3; } .title button:hover { /* box-shadow: 0 0 15px 0 rgba(255, 255, 255, 0.51); */ background: linear-gradient(to bottom, white, rgb(242, 242, 242), white); } .title button:active { /* background-color: rgb(255, 255, 255); */ background: linear-gradient(to bottom, white, rgb(255, 255, 255), white); } /* Practice Page: Practice selection screen */ .practice-selection { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: grid; /* prettier-ignore */ grid-template: min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) min(58vw, 628px, calc(var(--vh-sub-nav) * 0.667)) min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) / repeat(6, min(13vw, 141px, calc(var(--vh-sub-nav) * 0.15))); gap: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.0173)); justify-content: center; align-content: center; margin-bottom: 8vh; } .practice-selection button { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); } .practice-selection button:hover { background: linear-gradient(to bottom, white, rgb(242, 242, 242), white); } .practice-selection button:active { background: linear-gradient(to bottom, white, rgb(255, 255, 255), white); } .practice-selection .practice-name { grid-column: 1 / 7; align-self: center; justify-self: center; font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); } .practice-selection .checkmate-practice { grid-column: 1 / 4; } .practice-selection .tactics-practice { grid-column: 4 / 7; } .practice-selection .practice-play { grid-column: 1 / 4; background: linear-gradient(to bottom, white, rgb(226, 226, 226), white); } .practice-selection .practice-back { grid-column: 4 / 7; } .practice-selection .selected { box-shadow: none; } .practice-box { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); grid-column: 1 / 7; display: flex; flex-direction: column; } .practice-head { font-family: Verdana; background: linear-gradient(to bottom, white, rgb(229, 229, 229), white); border-bottom: 2px solid rgb(168, 168, 168); border-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015)); display: flex; justify-content: space-between; align-items: center; align-content: center; padding: 0.5em 2em; height: 4em; } .difficulty-title { font-size: 0.8em; } .checkmate-list { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); overflow-y: scroll; display: flex; flex-direction: column; flex-grow: 1; } .checkmate { display: flex; align-items: center; font-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02)); justify-content: center; /* OR: space-between */ margin: 0.3em; border-radius: 0.3em; border-width: 0em; height: 3em; } .checkmate { background-color: rgba(199, 199, 199, 1); } .checkmate.selected { outline-style: solid; outline-width: 0.25em; outline-offset: -0.15em; } .checkmate.beaten { background-color: rgba(0, 128, 0, 0.3); } .checkmate:hover { background-color: rgba(168, 168, 168, 0.8); cursor: pointer; } .checkmate:active { background-color: rgba(157, 156, 156, 0.8); } .checkmate.beaten:hover { background-color: rgba(0, 128, 0, 0.2); } .checkmate-child { padding: 0 0.3em; margin: 0.8em; } .completion-mark { width: 10%; height: 100%; } /* Add the checkmark */ .checkmate.beaten .completion-mark { background-image: url('/img/game/checkmatepractice/checkmark.svg'); background-size: contain; background-repeat: no-repeat; } .piecelistW { display: flex; justify-content: center; width: 40%; height: 100%; margin-right: 5%; } .checkmate-child.versus { width: 5%; } .piecelistB { display: flex; justify-content: center; width: 10%; height: 100%; } .checkmate-difficulty { display: flex; justify-content: center; align-content: center; width: 20%; } .checkmate-progress { width: 15%; font-size: 1em; } .checkmate-progress-bar { position: relative; width: 60%; height: 1.2em; outline-style: solid; outline-width: 0.1em; border-radius: 0.25em; font-size: 0.8em; } /* Badges */ .badge { position: absolute; height: 2.6em; user-select: none; } .badge img { height: 100%; } .badge:hover img { transition: transform 0.4s ease; transform: scale(1.1); } .unearned { filter: contrast(calc(1 / 3)) brightness(1.5); } #checkmate-badge-bronze { left: 50%; top: 50%; transform: translate(-50%, -50%); } #checkmate-badge-silver { left: 75%; top: 50%; transform: translate(-50%, -50%); } #checkmate-badge-gold { left: 100%; top: 50%; transform: translate(-50%, -50%); } /* Badge shine properties are in header.css since they are shared with badges on play page */ .checkmatepiececontainer { align-self: center; height: 100%; background-repeat: no-repeat; background-size: 0; padding: 0.02em; margin: 0.25em; border-radius: 1em; } .checkmatepiececontainer.collated { margin-left: -0.65em; } .checkmatepiececontainer.collated-strong { margin-left: -1.75em; } .checkmatepiece { width: 3em; height: 3em; background-image: inherit; background-repeat: no-repeat; /* NEEDS TO BE as many times greater than 100% as there are pieces in a row in the spritesheet! 8 pieces => 800% */ background-size: 800%; } /* Play Page: Invite creation screen */ .play-selection { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: grid; /* prettier-ignore */ grid-template: repeat(2, min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092))) min(50vw, 542px, calc(var(--vh-sub-nav) * 0.575)) min(8vw, 86px, calc(var(--vh-sub-nav) * 0.092)) / repeat(6, min(13vw, 141px, calc(var(--vh-sub-nav) * 0.15))); gap: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.0173)); justify-content: center; align-content: center; margin-bottom: 8vh; } .play-selection button { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); } .play-selection button:hover { background: linear-gradient(to bottom, white, rgb(242, 242, 242), white); } .play-selection button:active { background: linear-gradient(to bottom, white, rgb(255, 255, 255), white); } .play-selection .play-name { grid-column: 1 / 7; align-self: center; justify-self: center; font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); } .play-selection .online { grid-column: 1 / 3; } .play-selection .local { grid-column: 3 / 5; } .play-selection .computer { grid-column: 5 / 7; } .play-selection .create-invite { grid-column: 1 / 4; background: linear-gradient(to bottom, white, rgb(226, 226, 226), white); } .play-selection .play-back { grid-column: 4 / 7; } .play-selection .selected { box-shadow: none; } .play-selection .game-options { font-size: min(2.5vw, 27px, calc(var(--vh-sub-nav) * 0.029)); grid-column: 1 / 7; overflow-y: auto; display: flex; flex-direction: column; } /* Target the scrollbar */ .game-options::-webkit-scrollbar { width: 9px; /* Set the width of the scrollbar */ } /* Set the background color of the scrollbar track */ .game-options::-webkit-scrollbar-track { background-color: #f1f1f1; border-radius: 5px; /* Set the border radius of the track */ } /* Set the color and border radius of the scrollbar thumb */ .game-options::-webkit-scrollbar-thumb { background-color: rgb(174, 174, 174); border-radius: 5px; /* Set the border radius of the thumb */ } .game-options .options { background: linear-gradient(to bottom, white, rgb(229, 229, 229), white); border-bottom: 2px solid rgb(168, 168, 168); /* border-radius: min(1.3vw, 30px) min(1.3vw, 30px) 0 0; */ border-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015)); display: flex; justify-content: center; } .option-card { display: flex; flex-flow: column; align-items: center; padding: 0.35em 1.1em; } .game-options .option-card p { font-size: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.017)); text-align: center; padding-bottom: 0.3em; } .game-options select { border: 1.5px solid grey; border-radius: 0.75em; padding: 0.6em 0.9em; font-size: min(1.5vw, 16px, calc(var(--vh-sub-nav) * 0.017)); box-sizing: content-box; min-width: 3em; max-width: 6em; text-align: center; /* Remove arrow */ -webkit-appearance: none; -moz-appearance: none; appearance: none; } #option-clock { max-width: 5em; } .invite-list { flex-grow: 1; } .game-options .join-existing { text-align: center; font-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02)); padding: 0.5em; } .game-options .invite { background-color: rgba(0, 0, 255, 0.227); height: 3em; display: flex; align-items: center; font-size: min(1.7vw, 18px, calc(var(--vh-sub-nav) * 0.02)); justify-content: space-between; margin: 0.4em; border-radius: 0.3em; cursor: pointer; } .invite .invite-child { padding: 0 0.6em; } .invite .invite-child.accept { margin-right: 0.8em; padding: 0.5em 0.8em; border-radius: 0.5em; } .invite.hover { background-color: rgba(48, 145, 255, 0.442); } .invite.hover .accept { background-color: rgba(255, 255, 255, 0.299); } .invite.ours { background-color: rgba(156, 36, 255, 0.303); } .invite.ours.hover { background-color: rgba(255, 36, 178, 0.266); } .invite.private { background-color: rgba(0, 0, 0, 0.266); } .invite.private.hover { background-color: rgba(0, 0, 0, 0.22); } .join-private, .invite-code { display: flex; justify-content: center; align-items: center; font-size: 0.9em; background: linear-gradient(to bottom, white, rgb(229, 229, 229), white); padding: 0.5em 0; border-top: 2px solid rgb(168, 168, 168); border-radius: min(1.3vw, 30px, calc(var(--vh-sub-nav) * 0.015)); } .textbox-private { font-size: 0.8em; margin: 0 1.8em 0 1em; text-align: center; background-color: rgba(255, 255, 255, 0.291); border: 0; border-radius: 0.5em; padding: 0.4em 0; box-shadow: 0 0 0.4em rgba(0, 0, 0, 0.398); width: 4.6em; } .textbox-private:hover { box-shadow: 0 0 0.4em rgba(0, 0, 0, 0.631); } .textbox-private:focus { outline: solid 1px black; } .invite-code-code { font-size: 1.1em; margin: 0 1.1em 0 0.7em; text-shadow: 0.05em 0.1em 0.15em rgba(0, 0, 0, 0.175); font-weight: bold; } button.join-button, button.copy-button { font-size: 0.8em; background-color: white; padding: 0.45em 0.65em; border-radius: 0.6em; box-shadow: 0 0 0.4em rgba(0, 0, 0, 0.649); background: linear-gradient(to bottom, white, rgb(226, 226, 226), white); } /* Top Navigation: Zoom buttons, coordinates, rewind/forward game, pause */ .navigation-bar { position: absolute; top: 0; left: 0; width: 100%; font-size: 84px; /* Update with doc!! */ height: 1em; display: flex; justify-content: space-between; box-shadow: 0px 1px 7px 0px rgba(0, 0, 0, 0.659); background: linear-gradient( to top, rgba(255, 255, 255, 0.104), rgba(255, 255, 255, 0.552), rgba(255, 255, 255, 0.216) ); -webkit-backdrop-filter: blur( 5px ); /* Must be BEFORE the unprefixed rules, so Lightning CSS correclty parses! */ backdrop-filter: blur(8px); /* Apply a blur effect to the background */ } .teleport, .coords, .right-nav { display: flex; align-items: center; } .teleport { justify-content: flex-start; padding-left: 0.14em; } .coords { justify-content: center; flex-grow: 1; } .right-nav { justify-content: flex-end; padding-right: 0.14em; } #position { box-sizing: border-box; font-size: 0.19em; height: 4em; margin: 0.44em; border-radius: 0.5em; background-color: rgb(255, 255, 255); box-shadow: 0px 0px 7px 0px rgba(0, 0, 0, 0.878); display: flex; flex-direction: column; justify-content: center; } .x, .y { height: 50%; display: flex; align-items: center; justify-content: space-between; } .x { padding: 0.13em 0 0 0.44em; border-radius: 0.5em 0.5em 0 0; border-bottom: 1px solid rgb(161, 161, 161); } .y { padding: 0 0 0.13em 0.44em; border-radius: 0 0 0.5em 0.5em; } #x, #y { margin-right: 0.31em; padding: 0.06em 0.19em; border-radius: 0.19em; width: 7.5em; font-size: 1em; background-color: rgb(245, 245, 245); color: rgb(37, 37, 37); } /* The increment and decrement arrow heads spin buttons on the input element */ #x::-webkit-inner-spin-button, #y::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .navigation-bar .button { position: relative; width: 0.74em; height: 0.74em; margin: 0.07em; border-radius: 0.16em; background-color: rgb(255, 255, 255); box-shadow: 0px 0px 7px 0px rgba(0, 0, 0, 0.878); transition: transform 0.15s; cursor: pointer; -webkit-tap-highlight-color: transparent; /* No more blue highlight when tapping buttons on mobile */ } .navigation-bar .button:hover { transform: scale(1.07); } .navigation-bar .button:active { transform: scale(1); } .navigation-bar svg { position: absolute; } svg.pencil { transform: scale(0.7) translate(0.02em, 0); } svg.erase { transform: scale(0.75) translate(-0.015em, 0); } svg.collapse { transform: scale(0.75) translate(0, 0.02em); } /* Color annotations button bright blue when enabled */ #annotations.enabled { background: radial-gradient( rgb(255, 100, 0), rgb(255, 100, 0), rgb(255, 100, 100), rgb(255, 170, 170), rgb(255, 255, 255) ); } /* Annotation buttons aren't visible on desktop */ @media only screen and (pointer: fine) { /* Desktop */ .buttoncontainer.annotations { display: none; } .buttoncontainer.erase { display: none; } .buttoncontainer.collapse { display: none; } } /* Start shrinking top navigation bar */ @container (max-width: 700px) { @media only screen and (pointer: fine) { /* Desktop */ .navigation-bar { font-size: 12cqw; /* Update with doc!! */ } } } @container (max-width: 803px) { @media only screen and (pointer: coarse) { /* Mobile */ .navigation-bar { font-size: 10.5cqw; /* Update with doc!! */ } } } /* Small screens. HIDE the coords and make the buttons size constant! */ @container (max-width: 550px) { @media only screen and (pointer: fine) { /* Desktop */ .navigation-bar { justify-content: space-between; font-size: 66px; /* Update with doc!! */ } .coords { display: none; } } } @container (max-width: 625px) { @media only screen and (pointer: coarse) { /* Mobile */ .navigation-bar { font-size: 66px; /* Update with doc!! */ } .coords { display: none; } } } /* Mobile screen, start shrinking the size again */ @container (max-width: 368px) { @media only screen and (pointer: fine) { /* Desktop */ .navigation-bar { font-size: 17.9cqw; /* Update with doc!! */ } } } @container (max-width: 483px) { @media only screen and (pointer: coarse) { /* Mobile */ .navigation-bar { font-size: 13.7cqw; /* Update with doc!! */ } } } /* Bottom Navigation: Color to move, clocks, player names, draw offer UI */ .game-info-bar { position: absolute; bottom: 0; width: 100%; height: 84px; box-shadow: 0px -1px 7px 0px rgba(0, 0, 0, 0.659); display: flex; background: linear-gradient( to bottom, rgba(255, 255, 255, 0.307), white, rgba(255, 255, 255, 0.84) ); -webkit-backdrop-filter: blur( 5px ); /* Must be BEFORE the unprefixed rules, so Lightning CSS correclty parses! */ backdrop-filter: blur(8px); /* Apply a blur effect to the background */ } /* Stores their username container and timer */ .player-container { align-content: center; padding: 0 10px; width: fit-content; /* Capping the width to a percentage of the gameinfo bar prevents them overflowing & black's clock pushed off the screen. */ max-width: 35%; } /* Stores the username containers */ .playerwhite, .playerblack { display: flex; } .playerwhite { justify-content: left; } .playerblack { /* Don't need to justify here since the spacing is specially handled by guigameinfo.ts */ } /* Stores the timer */ .timer-container { display: flex; align-items: center; padding-top: 5px; } .timer-container.left { justify-content: left; } .timer-container.right { justify-content: right; } .timer { padding: 6px 9px; font-size: 18px; border-radius: 4px; border: 1px solid black; } .timer.white { background-color: rgb(255, 255, 255); color: rgb(0, 0, 0); } .timer.black { background-color: rgb(0, 0, 0); color: white; } .whosturn { flex-grow: 1; display: flex; align-items: center; justify-content: center; text-align: center; font-size: 20px; font-weight: bold; padding: 0 8px; min-width: 0px; /* Prevents the minimum width fitting the longest word */ } /* Draw Offer UI (in the bottom nav bar) */ .draw_offer_ui, .practice-engine-buttons { display: flex; align-items: center; gap: 0.3em; height: 100%; } .draw_offer_ui .offer_title { font-size: 0.9em; } .draw_offer_ui button, .practice-engine-buttons button { background-color: white; border-radius: 1em; width: 4em; height: 4em; display: flex; justify-content: center; align-items: center; border: 2px solid grey; font-size: 0.45em; } .draw_offer_ui svg { height: 90%; } .draw_offer_ui button:hover, .practice-engine-buttons button:hover { background: linear-gradient(to bottom, white, rgb(230, 230, 230), white); } .draw_offer_ui button:active, .practice-engine-buttons button:active { background: linear-gradient(to bottom, white, rgb(230, 230, 230), white); } /* Game Control Buttons (in the bottom nav bar) */ .practice-engine-buttons { font-size: 30px; display: flex; align-items: center; gap: 0.3em; height: 100%; margin: 0 0.5em; -webkit-tap-highlight-color: transparent; /* No more blue highlight when tapping buttons on mobile */ } .practice-engine-buttons .svg-undo { width: 72%; transform-origin: 53% 55%; } .practice-engine-buttons .svg-restart { aspect-ratio: 1; width: 84%; transform: translate(0.5px, 0.5px); } /* Promotion UI */ #promote { min-width: 280px; max-width: 400px; padding: 10px; border-radius: 10px; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); background-color: rgba(255, 255, 255, 0.949); box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.918); } .promotecolor { display: flex; justify-content: space-evenly; flex-wrap: wrap; } .promotepiece { width: 80px; height: 80px; padding: 3px; margin: 3px; border-radius: 10px; } .promotepiece:hover { background-color: rgba(0, 0, 0, 0.099); } .promotepiece:active { background-color: rgba(0, 0, 0, 0.158); } /* Shared floating Window UI */ .floating-window { display: flex; flex-direction: column; max-height: min(80vh, var(--dvh-sub-nav)); box-sizing: border-box; padding: 0 18px 16px 18px; border-radius: 12px; position: absolute; left: 1%; top: 11%; background-color: rgba(255, 255, 255, 0.96); box-shadow: 0 0 12px rgba(0, 0, 0, 0.75); font-family: 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #222; --editor-floating-window-btn-primary: #1d8ee4; --editor-floating-window-btn-primary-hover: #0d7ed4; --editor-floating-window-btn-secondary: #e0e0e0; --editor-floating-window-btn-secondary-hover: #d5d5d5; } .floating-window.scrolling { overflow: auto; } .floating-window.nonscrolling { overflow: hidden; } .floating-window.narrow { width: 100%; box-sizing: border-box; max-width: min(220px, 60vw); } .floating-window.wide { width: 100%; max-width: calc(2 * min(230px, 60vw)); } /* Header (draggable area + close button) */ .window-header { cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; padding: 8px 0 4px 0; font-size: 18px; font-weight: 600; } .close-floating-window { background: none; border: none; color: #616161; font-size: 20px; cursor: pointer; font-weight: bold; } .close-floating-window:hover { color: #000000; transform: scale(1.1); } .divider { border: none; border-bottom: 1px solid #ccc; margin-bottom: 12px; } /* Floating window UI confirmation button styling */ .confirmation-buttons { display: flex; justify-content: center; gap: 12px; margin-top: 18px; } .confirmation-buttons .btn { min-width: 80px; padding: 10px 18px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; border: 1px solid transparent; } .btn-primary { background-color: var(--editor-floating-window-btn-primary); color: #ffffff; } .btn-primary:hover { background-color: var(--editor-floating-window-btn-primary-hover); } .btn-secondary { background-color: var(--editor-floating-window-btn-secondary); color: #222; } .btn-secondary:hover { background-color: var(--editor-floating-window-btn-secondary-hover); } /* Floating window UI input styling */ .flwindow-section { margin-bottom: 10px; display: flex; flex-direction: column; } .flwindow-section label { font-weight: 500; margin-bottom: 4px; word-wrap: break-word; } .floating-window .invalid-input { /* Higher specificity to override default border-color */ background-color: #f8d7da; /* light red */ border-color: #f5c2c7; /* optional border */ } .input-pair { display: flex; align-items: center; gap: 6px; width: 100%; /* make the container take full width */ } .input-pair input { flex: 1; /* allow inputs to expand equally */ min-width: 0; /* ensures proper shrinking in flex layouts */ padding: 4px 8px; /* adjust padding as needed */ text-align: center; /* keep numbers/letters centered */ border: 1px solid #ccc; border-radius: 6px; transition: border 0.2s; } .flwindow-section input[type='text'] { border: 1px solid #ccc; border-radius: 6px; padding: 5px 8px; transition: border 0.2s; } .flwindow-section input:focus { border-color: #4285f4; outline: none; } .toggle-group { display: flex; align-items: center; justify-content: center; gap: 10px; } .toggle-group label { cursor: pointer; } .flwindow-list { display: flex; flex-direction: column; gap: 6px; /* space between items */ } .checkbox-item { display: flex; align-items: center; gap: 8px; /* space between checkbox and label */ line-height: 1.2; } .checkbox-item input[type='checkbox'] { width: 16px; height: 16px; cursor: pointer; accent-color: #4285f4; /* optional for modern browsers */ vertical-align: middle; position: relative; top: -2px; /* fine-tune to align checkbox center with label text */ } /* Tri-state (indeterminate) style enhancement */ .checkbox-item input[type='checkbox']:indeterminate { background-color: #ddd; border: 1px solid #888; appearance: none; display: inline-block; position: relative; } .checkbox-item input[type='checkbox']:indeterminate::after { content: ''; position: absolute; top: 50%; left: 50%; width: 10px; height: 2px; background-color: #333; transform: translate(-50%, -50%); border-radius: 1px; } .floating-window input::placeholder { color: #999; } /* Board editor load & save positions UI */ .saved-positions { /* Fixed column widths */ --w-count: 70px; --w-date: 95px; --w-header-actions: 52px; /* Shared column structure (everything left of the action buttons) */ --grid-layout: auto var(--w-count) var(--w-date); /* Allows flex items to shrink below their content size, allowing the positions list to be scrollable instead of clipping */ min-height: 0; } /* Expand header actions width when cloud button is present (user logged in) */ .saved-positions.with-cloud { --w-header-actions: 78px; } .saved-position-header { display: grid; grid-template-columns: var(--grid-layout) var(--w-header-actions); align-items: center; padding: 0 0.5em; margin-bottom: 0.4em; position: relative; } .saved-position-header > div { padding-right: 0.5em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* The last column of the saved-position-header holds the loading animation */ .saved-position-header > div:last-child { display: flex; justify-content: flex-end; padding-right: 0; } /* Spinny pawn in the saved positions header — fixed size for grid context */ .saved-position-header .svg-pawn { height: 28px; bottom: -1px; position: absolute; } .saved-position-list { min-height: 80px; overflow-y: auto; } .saved-position-list-empty { display: flex; align-items: center; justify-content: center; height: 80px; color: #888; font-style: italic; } .saved-position { height: 2.5em; border-bottom: 1px solid rgba(0, 0, 0, 0.12); display: grid; align-items: center; grid-template-columns: var(--grid-layout) max-content max-content max-content; padding: 0 0.5em; } .saved-position:nth-child(even) { background-color: #f7f7f7; } .saved-position.active-position { --mid: var(--background-theme-color); --end: color-mix(in srgb, var(--mid), white 50%); background: linear-gradient(var(--end), var(--mid), var(--end)); } .saved-position > div { padding-right: 0.5em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Hide piece count column on semi-small screens */ @container (max-width: 390px) { .saved-positions .piece-count { display: none; } .saved-positions { --grid-layout: auto var(--w-date); } } /* Also hide date column on very small screens */ @container (max-width: 320px) { .saved-positions .date { display: none; } .saved-positions { --grid-layout: auto; } } .position-name { display: flex; flex-direction: column; margin-bottom: 12px; } .position-name-row { display: flex; gap: 8px; } .position-name-row input { flex-grow: 1; min-width: 0; } .position-name-btnsave { flex-shrink: 0; width: 75px; border-radius: 8px; font-size: 15px; font-weight: 600; background-color: var(--editor-floating-window-btn-primary); color: #ffffff; } .position-name-btnsave:hover { background-color: var(--editor-floating-window-btn-primary-hover); } .saved-position-btn { background-color: transparent; display: inline-flex; align-items: center; justify-content: center; width: 26px; } .saved-position-btn:hover svg { transform: scale(1.1); } .saved-position-btn svg { width: 22px; aspect-ratio: 1; } /* Cloud save button: greyed-out when position is local (not saved on cloud) */ .cloud-save.local svg { opacity: 0.35; } /* The overlay covers ONLY the load-position-UI */ .load-position-modal-overlay { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.25); pointer-events: auto; } .load-position-modal { width: 90%; border-radius: 0.75em; background-color: rgba(255, 255, 255, 0.96); box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35); padding: 0em 1em 0.5em 1em; } .load-position-modal-header { cursor: unset; } /* Pause UI */ .pauseUI { position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: 5; /* Must be above .editor-menu-toggle */ background-color: rgba(0, 0, 0, 0.849); padding-bottom: 15vh; display: grid; grid-template: repeat(6, min(8vw, 86px, 15dvh)) / repeat(2, min(30vw, 320px)); gap: min(3vw, 32px, calc(4.2dvh - var(--nav-bar-height) / 6)); justify-content: center; align-content: center; } .pauseUI p.paused, .pauseUI button { font-size: min(2.5vw, 27px); } .pauseUI p.paused { color: white; text-align: center; align-self: center; } .pauseUI button { background-color: rgb(228, 228, 228); box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.27); border-radius: min(0.9vw, 10px); color: rgb(0, 0, 0); background: linear-gradient(to bottom, white, rgb(199, 199, 199), white); } .pauseUI button:hover { /* background-color: rgb(255, 255, 255); */ background: linear-gradient(to bottom, white, rgb(219, 219, 219), white); } .pauseUI button:active { /* box-shadow: 0 0 15px 0 rgba(255, 255, 255, 0.51); */ background: linear-gradient(to bottom, white, rgb(230, 230, 230), white); } .pauseUI p.paused, button.paused, button.resume, button.mainmenu, button.offerdraw, button.practicemenu { grid-column: 1 / 3; } /* Status text showing alerts and errors */ .toastmessage { position: absolute; bottom: 84px; left: 0; right: 0; padding: 1em 8%; z-index: 1; pointer-events: none; } .toastmessage .toast { margin: 0 auto; padding: 0.4em 3em; width: fit-content; font-size: 18px; text-align: center; opacity: 0; white-space: pre-wrap; line-height: 1.5; } .toast.ok { opacity: 1; color: black; --color: white; background: linear-gradient( to right, transparent, var(--color), var(--color), var(--color), var(--color), transparent ); } .toast.error { opacity: 1; color: white; --color: rgb(255, 0, 0); background: linear-gradient( to right, transparent, var(--color), var(--color), var(--color), var(--color), transparent ); } /* Status messages along the top-right showing detailed information (move count, fps meter) */ #stats { position: absolute; top: 0; width: 100%; font-size: 22px; /* Allows clicks to pass through to the elements underneath. FIXES A BUG that doesn't let you click arrows along the top of the screen while the move count is visible!! */ pointer-events: none; } .status { text-align: right; margin: 0.4em 0.6em; word-break: break-all; } /* General classes with basic properties */ .center { text-align: center; } a { -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } .hidden { display: none; } .opacity-0_5 { opacity: 0.5; } .opacity-0_25 { opacity: 0.25; } .rotate-180 { transform: rotate(180deg); } /* Animations */ @keyframes fade-in { from { opacity: 0%; } to { opacity: 100%; } } @keyframes fade-out { 0% { opacity: 1; } 100% { opacity: 0; } } .fade-in-1s { animation: fade-in 1s; } .fade-out-1s { animation: fade-out 1s; /* UPDATE 1s within the document in the toast module! */ } ================================================ FILE: src/client/css/termsofservice.css ================================================ * { margin: 0; padding: 0; font-family: Verdana; /* Helvetica */ border: 0; /* Enable temporarily during dev to see the borders of all elements */ /* outline: 1px solid rgba(0, 0, 0, 0.191); */ } html { height: 100%; background-color: rgb(33, 33, 33); } main { background-color: #fff; /* Using PNG because it was the smallest after compression */ background-image: url('/img/blank_board.png'); background-position: center; background-repeat: no-repeat; background-size: cover; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-attachment: fixed; margin-top: 40px; min-height: 400px; } #content { background-color: rgba(255, 255, 255, 0.805); min-height: 450px; margin: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.522); padding: 30px 20px; } #content h1 { font-size: 40px; font-family: georgia; margin-bottom: 40px; } h2 { text-align: center; font-size: 25px; margin-top: 1.5em; } h3 { font-size: 18px; text-align: center; font-weight: normal; } #content p { line-height: 1.5; font-size: 16px; margin: 20px 0px; } a { color: black; -webkit-tap-highlight-color: rgba(0, 0, 0, 0.099); } .grey { color: rgba(0, 0, 0, 0.345); } .center { text-align: center; } /* Start increasing header links width */ @media only screen and (min-width: 450px) { #content h1 { font-size: calc(40px + 0.028 * (100vw - 450px)); } } /* Cap content width size, revealing image on the sides */ @media only screen and (min-width: 810px) { #content { max-width: calc(810px - 60px); /* 60px less than 810 to account for padding */ padding: 40px 30px; min-height: 800px; } #content h1 { font-size: 50px; margin-bottom: 50px; } } ================================================ FILE: src/client/scripts/cjs/game/htmlscript.ts ================================================ // src/client/scripts/cjs/game/htmlscript.ts /* global main */ /** * The server injects this script directly into the html document * before serving that. * This is so we can execute code that needs to be executed preferrably * before the document fully loads (for example, the loading screen, * or pre-loading the sound spritesheet) * * This is also what calls our main() function when the page fully loads. */ globalThis.htmlscript = (function () { // Listen for the first user gesture... // If there's an error in loading, stop the loading animation // ... let loadingErrorOcurred = false; let lostNetwork = false; /** Called on failure to load a page asset. */ function callback_LoadingError(): void { // const type = event.type; // Event type: "error"/"abort" // const target = event.target; // Element that triggered the event // const elementType = target?.tagName.toLowerCase(); // const sourceURL = target?.src || target?.href; // URL of the resource that failed to load // console.error(`Event ${type} ocurred loading ${elementType} at ${sourceURL}.`); if (loadingErrorOcurred) return; // We only need to show the error text once loadingErrorOcurred = true; // Hide the "LOADING" text const element_loadingText = document.getElementById('loading-text')!; element_loadingText.classList.add('hidden'); // This applies a 'display: none' rule // Show the ERROR text const element_loadingError = document.getElementById('loading-error'); const element_loadingErrorText = document.getElementById('loading-error-text'); const element_loadingGlow = document.getElementById('loading-glow'); if (!element_loadingError || !element_loadingErrorText || !element_loadingGlow) { console.error('Loading error elements not found in document.'); return; } element_loadingError.classList.remove('hidden'); element_loadingErrorText.textContent = lostNetwork ? translations.lost_network : translations.failed_to_load; // Remove the glowing in the background animation element_loadingGlow.classList.remove('loadingGlowAnimation'); element_loadingGlow.classList.add('loading-glow-error'); } /** Removes this specific html element's listener for a loading error. It must be the "this" object. */ function removeOnerror(this: HTMLElement): void { this.removeAttribute('onerror'); this.removeAttribute('onload'); } // Add event listeners for when connection is dropped when loading (function initLoadingScreenListeners(): void { window.addEventListener('offline', callback_Offline); window.addEventListener('online', callback_Online); })(); function closeLoadingScreenListeners(): void { window.removeEventListener('offline', callback_Offline); window.removeEventListener('online', callback_Online); } function callback_Offline(): void { console.log('Network connection lost'); lostNetwork = true; callback_LoadingError(); } function callback_Online(): void { console.log('Network connection regained'); lostNetwork = false; if (loadingErrorOcurred) window.location.reload(); // Refresh the page } // When the document is loaded, start the game! window.addEventListener('load', function () { if (loadingErrorOcurred) return; // Page never finished loading, don't start the game. closeLoadingScreenListeners(); // Remove document event listeners for the loading screen main.start(); // Start the game! }); return Object.freeze({ callback_LoadingError, removeOnerror, }); })(); ================================================ FILE: src/client/scripts/esm/audio/AudioEffects.ts ================================================ // src/client/scripts/esm/audio/AudioEffects.ts /** * This module is responsible for creating and managing audio effects using the Web Audio API. */ // Types --------------------------------------------------------------------------------------------- /** A wrapper containing the input and output nodes of an effect graph. */ export interface NodeChain { input: AudioNode; output: AudioNode; } /** The base configuration for any effect. */ interface EffectConfigBase { /** * The volume of the "wet" (processed) signal. Default: 1. */ wetLevel?: number; /** * The volume of the "dry" (original) signal. Default: 0. * Increase to allow some of the original signal to pass through unaffected. */ dryLevel?: number; } /** The configuration for a single effect in the effects chain. */ export type EffectConfig = EffectConfigBase & { type: 'reverb'; durationSecs: number }; // Future effects will be added here, e.g.: // | { type: 'filter', filterType: BiquadFilterType, frequency: number } // Effect Creation --------------------------------------------------------------------------------- /** * Creates a complete, wrapped effect node graph based on the provided configuration. * @param audioContext - The global audio context. * @param config - The configuration object for the effect. * @returns An EffectWrapper containing the input and output nodes of the effect graph. */ export function createEffectNode(audioContext: AudioContext, config: EffectConfig): NodeChain { // 1. Create the core effect node based on its type. let coreEffectNode: AudioNode; switch (config.type) { case 'reverb': { coreEffectNode = generateConvolverNode(audioContext, config.durationSecs); break; } // When you add a filter: // case 'filter': { // coreEffectNode = audioContext.createBiquadFilter(); // coreEffectNode.type = config.filterType; // coreEffectNode.frequency.value = config.frequency; // break; // } default: throw new Error(`Unknown effect type specified in config.`); } // 2. Create the input and output nodes for parallel dry and wet signal paths. const input = new GainNode(audioContext); const output = new GainNode(audioContext); // Determine the dry level. Default to 0 (0% passthrough) if not specified. const dryLevel = config.dryLevel === undefined ? 0 : Math.max(0, config.dryLevel); if (dryLevel > 0) { const dryGain = new GainNode(audioContext, { gain: dryLevel }); input.connect(dryGain).connect(output); } // Determine the wet level. Default to 1 (100% effect) if not specified. const wetLevel = config.wetLevel === undefined ? 1 : Math.max(0, config.wetLevel); if (wetLevel > 0) { const wetGain = new GainNode(audioContext, { gain: wetLevel }); input.connect(coreEffectNode).connect(wetGain).connect(output); } // 3. Return the wrapped effect node. return { input, output }; } // Internal Helpers -------------------------------------------------------------------------------- /** Generates a reverb effect node. */ function generateConvolverNode(audioContext: AudioContext, durationSecs: number): ConvolverNode { const impulse = impulseResponse(audioContext, durationSecs); return new ConvolverNode(audioContext, { buffer: impulse }); } /** The mathematical function used by the convolver (reverb) node used to calculate the reverb effect! */ function impulseResponse(audioContext: AudioContext, duration: number): AudioBuffer { // Duration in seconds, decay const decay = 2; const sampleRate = audioContext.sampleRate; const length = sampleRate * duration; const impulse = audioContext.createBuffer(1, length, sampleRate); const IR = impulse.getChannelData(0); for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, decay); return impulse; } ================================================ FILE: src/client/scripts/esm/audio/AudioManager.ts ================================================ // src/client/scripts/esm/audio/AudioManager.ts /** * This module is responsible for creating and playing sounds using the Web Audio API. */ import AudioUtils from './AudioUtils'; import preferences from '../components/header/preferences'; import { DownsamplerNode } from './processors/downsampler/DownsamplerNode'; import { createEffectNode, EffectConfig, NodeChain } from './AudioEffects'; // Types --------------------------------------------------------------------------------------------- type AudioBufferWithGainNode = AudioBufferSourceNode & { gainNode: GainNode }; interface SoundObject { /** The source of the audio, with its attached `gainNode`. */ source: AudioBufferWithGainNode; /** Whether to loop the sound indefinitely. */ readonly looping: boolean; /** * Stops the sound from playing. * If this creates static pops, use fadeOut() instead. */ stop: () => void; /** * Fades out the sound. * [Looping sounds] Fades to silent and continues playing. * [Non-looping sounds] Fades to silent and then stops the sound entirely. * @param durationMillis - The duration of the fade out in milliseconds. */ fadeOut: (_durationMillis: number) => void; /** * Fades in the sound from its current volume to a target volume. * If you wish to fade-in a non-looping sound, initate the sound object with 0 volume initially. * @param targetVolume - The final volume level (0-1). * @param durationMillis - The duration of the fade-in effect in milliseconds. */ fadeIn: (_targetVolume: number, _durationMillis: number) => void; } /** Config options for playing a sound. */ interface PlaySoundOptions { /** The time of the audio buffer to start playing, if not from the beginning. */ startTime?: number; /** The duration to play the audio buffer for, if not for the whole duration. */ duration?: number; /** Volume of the sound. Default: 1. Typical range: 0-1. Capped at {@link VOLUME_DANGER_THRESHOLD} for safety. */ volume?: number; /** Delay before the sound starts playing in seconds. Default: 0 */ delay?: number; /** An array of effects to apply to the sound. */ effects?: EffectConfig[]; /** * Playback rate of the sound. Default: 1. 1 = normal speed & pitch * Lower = slower & lower pitch. Higher = faster & higher pitch. */ playbackRate?: number; /** Whether the sound should loop indefinitely. Default: false */ loop?: boolean; /** If true, the sound will bypass the global downsampler effect. Default: false */ bypassDownsampler?: boolean; } // Constants ---------------------------------------------------------------------------------------------- /** Any volume above this is probably a mistake, so we reset it to 1 and log an error in the console. */ const VOLUME_DANGER_THRESHOLD = 4; // State ---------------------------------------------------------------------------------------------- /** This context plays all our sounds. */ const audioContext: AudioContext = new AudioContext(); /** An input bus for all sound chains before they reach the master gain. Allows for global effects. */ const effectsBus = audioContext.createGain(); /** The global downsampler effect node. Null until the worklet is loaded. */ let globalDownsampler: DownsamplerNode | null = null; /** The gain node for the "dry" (unprocessed) signal path around the downsampler. */ const downsamplerDryGain = audioContext.createGain(); downsamplerDryGain.gain.value = 1; // Default to 100% dry signal /** The gain node for the "wet" (processed) signal path through the downsampler. */ const downsamplerWetGain = audioContext.createGain(); downsamplerWetGain.gain.value = 0; // Default to 0% wet signal /** A master gain node to control the overall volume of all sounds. */ const masterGain = audioContext.createGain(); masterGain.gain.value = preferences.getMasterVolume(); // Initialize to saved preference // Listen for changes to the master volume preference document.addEventListener('master-volume-change', (event) => { const newVolume = event.detail; masterGain.gain.setValueAtTime(newVolume, audioContext.currentTime); }); /** A final safety compressor to prevent clipping from very high gain. */ const limiter = new DynamicsCompressorNode(audioContext, { threshold: -0.1, // Start compressing just before the signal hits 0dB knee: 0, // Hard knee for a strict ceiling ratio: 20, // A 20:1 ratio is considered "limiting" attack: 0.001, // Very fast attack to catch transients release: 0.1, // Quick release }); // Connect the audio graph: Effects Bus -> Master Gain -> Limiter -> Destination (speakers) // Initially, connect the effectsBus directly to masterGain as a bypass until the downsampler loads. effectsBus.connect(masterGain); masterGain.connect(limiter); limiter.connect(audioContext.destination); // Asynchronously load and initialize the Downsampler worklet. (async () => { try { const downsamplerNode = await DownsamplerNode.create(audioContext); globalDownsampler = downsamplerNode; // Set the static parameters for the downsampler effect globalDownsampler.downsampling!.value = 20; // Default: 20 // Re-wire the audio graph to include the dry/wet downsampler paths effectsBus.disconnect(masterGain); // Disconnect the bypass // Dry path effectsBus.connect(downsamplerDryGain); downsamplerDryGain.connect(masterGain); // Wet path effectsBus.connect(globalDownsampler); globalDownsampler.connect(downsamplerWetGain); downsamplerWetGain.connect(masterGain); // console.log('Global downsampler effect initialized successfully.'); } catch (error) { console.error( 'Failed to initialize global downsampler effect. Audio will remain clean.', error, ); // If it fails, the initial bypass connection from effectsBus to masterGain remains active. } })(); // Getters ---------------------------------------------------------------------------------------------- /** Returns the global audio context. */ function getContext(): AudioContext { return audioContext; } /** * Returns the master gain node. All sounds MUST route through the * master gain node in order for the master volume control to work! * This should be used for sounds that need to BYPASS the global effects bus (such as ambiences). */ function getDestination(): AudioNode { return masterGain; } // Public API ------------------------------------------------------------------------------------------- /** Fades in the global downsampler effect over a given duration. */ function fadeInDownsampler(durationMillis: number): void { if (!globalDownsampler) { console.warn('Downsampler not loaded yet, cannot fade in.'); return; } AudioUtils.applyPerceptualFade(audioContext, downsamplerDryGain.gain, 0, durationMillis); AudioUtils.applyPerceptualFade(audioContext, downsamplerWetGain.gain, 1, durationMillis); } /** Fades out the global downsampler effect over a given duration. */ function fadeOutDownsampler(durationMillis: number): void { if (!globalDownsampler) { console.warn('Downsampler not loaded yet, cannot fade out.'); return; } AudioUtils.applyPerceptualFade(audioContext, downsamplerDryGain.gain, 1, durationMillis); AudioUtils.applyPerceptualFade(audioContext, downsamplerWetGain.gain, 0, durationMillis); } // Sound Playing ------------------------------------------------------------------------------------------ /** Plays the specified audio buffer with the specified options. */ function playAudio( buffer: AudioBuffer | undefined, playOptions: PlaySoundOptions, ): SoundObject | undefined { // Attempt to resume if it was suspended (e.g., due to browser autoplay policy) if (audioContext.state === 'suspended') audioContext.resume(); if (!audioContext) { console.warn(`Can't play sound when audioContext isn't initialized yet. (Still loading)`); return; } if (!buffer) { console.warn(`Can't play sound when buffer isn't loaded yet. (Still loading)`); return; } const { startTime, duration, volume = 1, delay = 0, playbackRate = 1, loop = false, effects = [], bypassDownsampler = false, } = playOptions; // Calculate the desired start time by adding the delay const startAt = audioContext.currentTime + delay; // We need an audio "source" to play our main sound effect. Several of these can exist at once for one audio context. // 1. Create the fundamental source and its master gain node. const mainSource = createBufferSource(buffer, volume, playbackRate); mainSource.loop = loop; // Set the loop property on the audio source itself. // 2. Build the effects chain by asking the factory to create the nodes. const effectNodes = effects.map((effectConfig) => createEffectNode(audioContext, effectConfig)); // 3. Connect the nodes in order: Source -> Gain -> Effect1 -> Effect2 -> Effects Bus -> Master Gain -> Limiter -> Destination connectNodeChain(mainSource.gainNode, effectNodes, bypassDownsampler); // The SoundObject is now much simpler! const soundObject: SoundObject = { source: mainSource, looping: loop, stop: (): void => { soundObject.source.stop(); }, fadeOut: (durationMillis): void => { const fadeOutDurationSecs = durationMillis / 1000; const fadeOutEndTime = audioContext.currentTime + fadeOutDurationSecs; // Fade the source to silent fadeOut(soundObject.source, fadeOutEndTime); // For non-looping sounds, schedule them to stop completely after the fade. if (!soundObject.looping) setTimeout(() => soundObject.stop(), durationMillis); }, fadeIn: (targetVolume, durationMillis): void => { const fadeInDurationSecs = durationMillis / 1000; const fadeInEndTime = audioContext.currentTime + fadeInDurationSecs; // Fade the main source to the target volume fadeIn(soundObject.source, targetVolume, fadeInEndTime); }, }; // Start the playback soundObject.source.start(startAt, startTime, duration); scheduleDisconnection(mainSource, buffer, loop, delay, effects, duration, startTime); return soundObject; } /** * Schedules disconnection of the audio nodes after the sound and its effects have finished playing. * * Patches a bug on chrome, where when audio sources are played * that have a reverb (or any other tail) effect, the audio nodes * are garbage collected too early, cutting off the tail effect. */ function scheduleDisconnection( source: AudioBufferSourceNode, buffer: AudioBuffer, loop: boolean, delay: number, effects: EffectConfig[], duration?: number, startTime?: number, ): void { if (loop) return; const sourceDurationSecs = duration ?? buffer.duration - (startTime ?? 0); // Find the longest tail duration among all applied effects. const maxTailSecs = effects.reduce((max, effect) => { if (effect.type === 'reverb') return Math.max(max, effect.durationSecs); // Future effects with tails (e.g., delay) could be accounted for here. else throw Error( `Sound effect type "${effect.type}" not accounted for in tail duration calculation.`, ); }, 0); const totalLifetimeMillis = (sourceDurationSecs + maxTailSecs + delay) * 1000; // Keep a reference to the source for the entire lifetime of the sound + effects. setTimeout(() => source.disconnect(), totalLifetimeMillis); } // Audio Nodes ------------------------------------------------------------------------------------------ /** * Creates a new buffer source and its master gain node. * It does NOT connect it to the destination, allowing an effects chain to be inserted later. * @param buffer - The audio buffer to play. * @param volume - The initial volume of the sound (0-1). * @param playbackRate - The playback rate of the sound. 1 = normal speed & pitch. * @returns The created AudioBufferSourceNode with its attached GainNode as `gainNode` property. */ function createBufferSource( buffer: AudioBuffer, volume: number, playbackRate: number = 1, ): AudioBufferWithGainNode { const source = audioContext.createBufferSource(); source.buffer = buffer; source.playbackRate.value = playbackRate; const gainNode = generateGainNode(audioContext, volume); source.connect(gainNode); // Connect source to its own master gain node // @ts-ignore source.gainNode = gainNode; // Attach for fading controls return source as AudioBufferWithGainNode; } /** Generates a gain node for affecting the volume of sounds. */ function generateGainNode(audioContext: AudioContext, volume: number): GainNode { if (volume > VOLUME_DANGER_THRESHOLD) { console.error(`Gain was DANGEROUSLY set to ${volume}!!!! Resetting to 1.`); volume = 1; } const gainNode = audioContext.createGain(); gainNode.gain.value = volume; // Set the volume level (0 to 1) return gainNode; } /** * Connects a starting node through a list of effect wrappers, ending at * either the global effects bus or directly at the master gain. * @param startNode - The first node in the chain (usually a source's gain node). * @param wrapperList - The list of effects to connect in series. * @param bypassDownsampler - If true, the chain will connect to masterGain, otherwise effectsBus. */ function connectNodeChain( startNode: AudioNode, wrapperList: NodeChain[], bypassDownsampler: boolean, ): void { let currentNode: AudioNode = startNode; for (const effectWrapper of wrapperList) { currentNode.connect(effectWrapper.input); currentNode = effectWrapper.output; // The output of this effect is the input to the next one. } // Connect the very last node in the chain to either the effects bus or directly to master gain. const destinationNode = bypassDownsampler ? masterGain : effectsBus; currentNode.connect(destinationNode); } /** * Initiates a fade-in for an audio source's gain node. This is interruptible. * @param source - The audio source node to fade in, WITH ITS `gainNode` property attached. * @param targetVolume - The final volume level. * @param endTime - The audioContext time at which the fade should complete. */ function fadeIn(source: AudioBufferWithGainNode, targetVolume: number, endTime: number): void { const now = audioContext.currentTime; // First, cancel any pending volume changes to make this interruptible. source.gainNode.gain.cancelScheduledValues(now); // Set the starting point for the ramp at the current volume. source.gainNode.gain.setValueAtTime(source.gainNode.gain.value, now); // Schedule the linear ramp to the target volume. source.gainNode.gain.linearRampToValueAtTime(targetVolume, endTime); } /** * Initiates a fade-out for an audio source's gain node. This is interruptible. * @param source - The audio source node to fade out, WITH ITS `gainNode` property attached. * @param endTime - The audioContext time at which the fade should complete. */ function fadeOut(source: AudioBufferWithGainNode, endTime: number): void { const now = audioContext.currentTime; // First, cancel any pending volume changes to make this interruptible. source.gainNode.gain.cancelScheduledValues(now); // Set the starting point for the ramp at the current volume. source.gainNode.gain.setValueAtTime(source.gainNode.gain.value, now); // Schedule the linear ramp down to zero. source.gainNode.gain.linearRampToValueAtTime(0, endTime); } // Utility ---------------------------------------------------------------------------------- /** Decodes audio data from an ArrayBuffer from a fetch request into an AudioBuffer. */ function decodeAudioData(buffer: ArrayBuffer): Promise { return new Promise((resolve, reject) => { if (!audioContext) { reject('Audio context not initialized.'); return; } audioContext.decodeAudioData( buffer, (decodedData) => resolve(decodedData), (error) => reject(error), ); }); } // Exports ---------------------------------------------------------------------- export type { SoundObject }; export default { // Getters getContext, getDestination, // Public API fadeInDownsampler, fadeOutDownsampler, // Sound Playing playAudio, // Utility decodeAudioData, }; ================================================ FILE: src/client/scripts/esm/audio/AudioUtils.ts ================================================ // src/client/scripts/esm/audio/AudioUtils.ts /** * This module provides generic, reusable utility functions for working with the Web Audio API. */ // Constants -------------------------------------------------------------------------------- /** The number of points to use when generating fade curves. Higher = smoother, but more CPU. */ const FADE_CURVE_RESOLUTION = 100; /** * Higher = Exponential ramp gets more weight at beginning, Linear ramp gets more weight at end. * Range: * 0.0 (perfect 50% blend of linear and exponential throughout time t) * to 0.5 (100% exponential ramp at start, 100% linear ramp at end) */ const FADE_RAMP_CURVATURE = 0.4; // Utility ----------------------------------------------------------------------------------- /** * Applies a perceptually-blended fade with a dynamic blending curve to any AudioParam. * This can be tuned between linear and exponential ramps, providing a more natural-sounding fade. * @param audioContext The active AudioContext. * @param gainParam The gain AudioParam to be modified. * @param targetVolume The final volume (amplitude) for the fade. * @param durationMillis The duration of the fade in milliseconds. */ function applyPerceptualFade( audioContext: AudioContext, gainParam: AudioParam, targetVolume: number, durationMillis: number, ): void { const now: number = audioContext.currentTime; const durationSeconds = durationMillis / 1000; const startVolume: number = gainParam.value; // In Firefox, this DOESN'T CANCEL value curves currently active! Use linear ramps instead! gainParam.cancelScheduledValues(now); // Anchor the start point to prevent popping gainParam.setValueAtTime(startVolume, now); const MIN_GAIN = 0.00001; const effectiveStart = Math.max(startVolume, MIN_GAIN); const effectiveTarget = Math.max(targetVolume, MIN_GAIN); const easeFunction = (t: number): number => FADE_RAMP_CURVATURE * Math.sin(Math.PI * t + 0.5 * Math.PI) + 0.5; // Generate segments and schedule them as linear ramps // We start from i=1 because i=0 is our starting anchor set above at 'now' for (let i = 1; i <= FADE_CURVE_RESOLUTION; i++) { const progress = i / FADE_CURVE_RESOLUTION; // 0.0 to 1.0 // Calculate the specific time for this segment const timeOffset = progress * durationSeconds; const scheduledTime = now + timeOffset; // Calculate the volume value for this segment const isFadeOut = targetVolume < startVolume; const blendProgress = isFadeOut ? 1 - progress : progress; const currentRatio = easeFunction(blendProgress); const linearPoint = startVolume + (targetVolume - startVolume) * progress; const exponentialPoint = effectiveStart * Math.pow(effectiveTarget / effectiveStart, progress); const value = linearPoint * (1 - currentRatio) + exponentialPoint * currentRatio; // 6. Schedule the ramp segment gainParam.linearRampToValueAtTime(value, scheduledTime); } } // Exports ---------------------------------------------------------------------------------- export default { applyPerceptualFade, }; ================================================ FILE: src/client/scripts/esm/audio/LFOFactory.ts ================================================ // src/client/scripts/esm/audio/LFOFactory.ts /** * A factory for creating Low-Frequency Oscillator (LFO) units for modulating audio parameters. */ import PerlinNoise from '../util/PerlinNoise'; /** Configuration for a low-frequency oscillator (LFO) modulating a parameter. */ export interface LFOConfig { wave: 'sine' | 'square' | 'sawtooth' | 'triangle' | 'perlin'; rate: number; depth: number; } /** A container for an LFO's audio nodes. */ interface LFOUnit { source: AudioNode; gain: GainNode; } /** A shared AudioBuffer for Perlin noise LFOs to use. */ let perlinNoiseBuffer: AudioBuffer | null = null; /** * A factory for creating LFO (Low-Frequency Oscillator) units. * @param context The global AudioContext. * @param config The configuration for the LFO. * @returns An LFOUnit containing the necessary source and gain nodes. */ export function createLFO(context: AudioContext, config: LFOConfig): LFOUnit { const lfoGain = context.createGain(); lfoGain.gain.value = config.depth; let lfoSource: AudioNode; if (config.wave === 'perlin') { lfoSource = createPerlinLFO(context, config.rate); } else { const osc = context.createOscillator(); osc.type = config.wave; osc.frequency.value = config.rate; lfoSource = osc; } return { source: lfoSource, gain: lfoGain }; } /** Creates a looping AudioBufferSourceNode that outputs Perlin noise. */ function createPerlinLFO(context: AudioContext, rate: number): AudioBufferSourceNode { if (!perlinNoiseBuffer) { // Create the perlin noise buffer only once const duration = 30; // 30 seconds long buffer const sampleCount = context.sampleRate * duration; // The "zoom" level for the noise. Higher values = smoother/slower noise. const noiseZoom = 50000; const noisePeriod = Math.ceil(sampleCount / noiseZoom); // console.log("noisePeriod: ", noisePeriod); // We get about 1 second of looping per 1 noise period at 1.0 rate. const noiseGenerator = PerlinNoise.create1DNoiseGenerator(noisePeriod); perlinNoiseBuffer = context.createBuffer(1, sampleCount, context.sampleRate); const data = perlinNoiseBuffer.getChannelData(0); for (let i = 0; i < sampleCount; i++) { data[i] = noiseGenerator(i / noiseZoom); } } const lfoSource = context.createBufferSource(); lfoSource.buffer = perlinNoiseBuffer; lfoSource.loop = true; lfoSource.playbackRate.value = rate; return lfoSource; } ================================================ FILE: src/client/scripts/esm/audio/SoundLayer.ts ================================================ // src/client/scripts/esm/audio/SoundLayer.ts /** * This module implements the audio graph for individual sound layers within a soundscape. * * A sound layer could either be: * - A noise source (e.g. white noise) with filters applied. * - An oscillator source (e.g. sine wave) with filters applied. * * Each layer can have its own volume control, and each parameter can be modulated by an LFO. */ import { createLFO, LFOConfig } from './LFOFactory'; // Types ----------------------------------------------------------------------------- /** A single sound layer within a soundscape. */ export interface LayerConfig { volume: ModulatedParamConfig; source: SourceConfig; filters: FilterConfig[]; } /** The configuration for the audio source of a layer. */ type SourceConfig = NoiseSourceConfig | OscillatorSourceConfig; /** Configuration for a noise source. */ interface NoiseSourceConfig { type: 'noise'; } /** Configuration for an oscillator source with optional LFO modulation. */ interface OscillatorSourceConfig { type: 'oscillator'; wave: 'sine' | 'square' | 'sawtooth' | 'triangle'; freq: ModulatedParamConfig; detune: ModulatedParamConfig; } /** Configuration for a BiquadFilterNode with optional LFO modulation. */ interface FilterConfig { /** The type of BiquadFilter to create. */ type: BiquadFilterType; /** Where on the frequency spectrum the filter should work. */ frequency: ModulatedParamConfig; /** * The Q factor (resonance) of the filter. Optional. * Range: 0.0001 to 1000. Default: 1. */ Q: ModulatedParamConfig; /** * The gain of the filter, in dB. Optional. * Only used for certain filter types: peaking, lowshelf, highshelf. */ gain: ModulatedParamConfig; } /** Configuration for a parameter that can be modulated by an LFO. */ interface ModulatedParamConfig { base: number; lfo?: LFOConfig; } // SoundLayer Class ---------------------------------------------------------------- /** * Represents the complete audio graph for a single layer in a soundscape. */ export class SoundLayer { private readonly outputGain: GainNode; /** All unique oscillators and LFOs that need to be started and stopped for this layer. */ private readonly allNodesToStart: (AudioBufferSourceNode | OscillatorNode)[] = []; constructor( context: AudioContext, config: LayerConfig, sharedNoiseSource: AudioBufferSourceNode, ) { this.outputGain = context.createGain(); this.outputGain.gain.value = config.volume.base; if (config.volume.lfo) { // The volume for this layer is modulated by an LFO const lfo = createLFO(context, config.volume.lfo); lfo.source.connect(lfo.gain).connect(this.outputGain.gain); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } let currentNode: AudioNode; if (config.source.type === 'noise') { currentNode = sharedNoiseSource; // The shared noise source is managed by the player, so we don't add it to our start/stop list. } else { // type === 'oscillator' const oscConfig: OscillatorSourceConfig = config.source; const osc = context.createOscillator(); osc.type = oscConfig.wave; osc.frequency.value = oscConfig.freq.base; osc.detune.value = oscConfig.detune.base; if (oscConfig.freq.lfo) { const lfo = createLFO(context, oscConfig.freq.lfo); lfo.source.connect(lfo.gain).connect(osc.frequency); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } if (oscConfig.detune.lfo) { const lfo = createLFO(context, oscConfig.detune.lfo); lfo.source.connect(lfo.gain).connect(osc.detune); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } currentNode = osc; this.allNodesToStart.push(osc); } config.filters.forEach((filterConfig) => { const filterNode = context.createBiquadFilter(); filterNode.type = filterConfig.type; filterNode.frequency.value = filterConfig.frequency.base; filterNode.Q.value = filterConfig.Q.base; filterNode.gain.value = filterConfig.gain.base; if (filterConfig.frequency.lfo) { const lfo = createLFO(context, filterConfig.frequency.lfo); lfo.source.connect(lfo.gain).connect(filterNode.frequency); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } if (filterConfig.Q.lfo) { const lfo = createLFO(context, filterConfig.Q.lfo); lfo.source.connect(lfo.gain).connect(filterNode.Q); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } if (filterConfig.gain.lfo) { const lfo = createLFO(context, filterConfig.gain.lfo); lfo.source.connect(lfo.gain).connect(filterNode.gain); this.allNodesToStart.push(lfo.source as OscillatorNode | AudioBufferSourceNode); } currentNode.connect(filterNode); currentNode = filterNode; }); currentNode.connect(this.outputGain); } /** Connects this layer's output to a destination node. */ public connect(destination: AudioNode): void { this.outputGain.connect(destination); } /** Starts all unique oscillators and LFOs for this layer. */ public start(): void { // FUTURE: Potentially upgrade to start perlin noise buffers at random // offsets so they don't sound identical every refresh. this.allNodesToStart.forEach((node) => node.start(0)); } /** Stops all unique oscillators and LFOs for this layer. */ public stop(): void { this.allNodesToStart.forEach((node) => node.stop(0)); } } ================================================ FILE: src/client/scripts/esm/audio/SoundscapePlayer.ts ================================================ // src/client/scripts/esm/audio/SoundscapePlayer.ts /** * This module implements a soundscape player that can play complex, layered ambient sounds. * * For creating of soundscape configs, use the Interactive Soundscape Generator tool: * dev-utils/sounds/SoundscapeGenerator.html */ import AudioUtils from './AudioUtils'; import AudioManager from './AudioManager'; import { LayerConfig, SoundLayer } from './SoundLayer'; // Types ----------------------------------------------------------------------------- /** The complete configuration for a soundscape. */ export interface SoundscapeConfig { masterVolume: number; layers: LayerConfig[]; } // Constants -------------------------------------------------------------------------------- /** * The length of the shared noise buffer for this soundscape's layers, in seconds. * Longer = less repetition, but more memory use and cpu initialization time. */ const NOISE_DURATION_SECS = 10; // SoundscapePlayer Class -------------------------------------------------------------------------------- /** The control interface for a soundscape player. */ export class SoundscapePlayer { private readonly config: SoundscapeConfig; private audioContext: AudioContext; /** The master gain node controlling overall volume of the soundscape. */ private masterGain: GainNode; /** All the individual sound layers in this soundscape. */ private layers: SoundLayer[] = []; /** A shared noise source for all layers to use. Reduces CPU and memory usage. */ private sharedNoiseSource: AudioBufferSourceNode | null = null; /** * Whether the player has been initialized and is ready to play. * We only initialize when playing is actually needed, as it's expensive. */ private playerReady: boolean = false; constructor(config: SoundscapeConfig) { this.config = config; this.audioContext = AudioManager.getContext(); this.masterGain = this.audioContext.createGain(); } /** * Initializes the audio graph, creates all nodes, and starts sources. * This is called only once. This is the expensive part of the process. */ private initializeAndPlay(): void { this.masterGain.gain.value = 0.0; // Always start silent this.masterGain.connect(AudioManager.getDestination()); // Connect to the global master gain // Create the shared raw noise buffer data source const bufferSize = NOISE_DURATION_SECS * this.audioContext.sampleRate; const sharedNoiseBuffer = this.audioContext.createBuffer( 2, bufferSize, this.audioContext.sampleRate, ); // 2 channels for stereo sound (unique noise in each ear) for (let c = 0; c < 2; c++) { const channelData = sharedNoiseBuffer.getChannelData(c); for (let i = 0; i < bufferSize; i++) { channelData[i] = Math.random() * 2 - 1; } } this.sharedNoiseSource = this.audioContext.createBufferSource(); this.sharedNoiseSource.buffer = sharedNoiseBuffer; this.sharedNoiseSource.loop = true; // Build each layer this.config.layers.forEach((layerConfig) => { const layer = new SoundLayer(this.audioContext!, layerConfig, this.sharedNoiseSource!); layer.connect(this.masterGain!); this.layers.push(layer); }); // Start all sources (at volume 0) this.sharedNoiseSource.start(0); this.layers.forEach((layer) => layer.start()); this.playerReady = true; } /** * Immediately stops all audio, disconnects nodes, and resets the player to a clean state. * The player can be started again with fadeIn(). */ public stop(): void { if (!this.playerReady) return; // Not even initialized, nothing to do. this.sharedNoiseSource!.stop(0); this.layers.forEach((layer) => layer.stop()); // Disconnect everything to be garbage collected this.masterGain.disconnect(); this.sharedNoiseSource?.disconnect(); // Reset state this.playerReady = false; // Allow re-initialization on next fadeIn this.layers = []; } /** Fades in the soundscape to a specified target volume, initializing it if necessary. */ public fadeIn(durationMillis: number): void { // Initialize now if not already done. // Saves compute until the soundscape is actually NEEDED, // as the initialization is expensive. if (!this.playerReady) this.initializeAndPlay(); AudioUtils.applyPerceptualFade( this.audioContext, this.masterGain.gain, this.config.masterVolume, durationMillis, ); } /** Fades out the ambience to silence. The player remains active at zero volume. */ public fadeOut(durationMillis: number): void { if (!this.playerReady) return; // Hasn't initialized, nothing to fade out. AudioUtils.applyPerceptualFade( this.audioContext, this.masterGain.gain, 0.0, durationMillis, ); } } ================================================ FILE: src/client/scripts/esm/audio/processors/downsampler/DownsamplerNode.ts ================================================ // src/client/scripts/esm/audio/processors/downsampler/DownsamplerNode.ts export class DownsamplerNode extends AudioWorkletNode { constructor(context: AudioContext) { super(context, 'downsampler-processor'); } /** * Factory method to asynchronously create and initialize a DownsamplerNode. * @param context The AudioContext to create the node in. * @returns A promise that resolves with a fully initialized DownsamplerNode instance. */ public static async create(context: AudioContext): Promise { try { // Load the worklet processor from the specified URL await context.audioWorklet.addModule( 'scripts/esm/audio/processors/downsampler/DownsamplerProcessor.js', ); // Once loaded, create an instance of the node return new DownsamplerNode(context); } catch (e) { console.error('Failed to load downsampler audio worklet', e); throw e; } } /** * The factor by which to reduce the sample rate. * A value of 1 means no downsampling. * Range: 1 to 40. */ get downsampling(): AudioParam | undefined { return this.parameters.get('downsampling'); } } ================================================ FILE: src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts ================================================ // src/client/scripts/esm/audio/processors/downsampler/DownsamplerProcessor.ts import type { AudioParamDescriptor } from '../worklet-types'; /* * These need to be declared in every audio worklet processor file, * because apparently our typescript setup doesn't have the * AudioWorkletGlobalScope, and nothing I do will add it. */ declare abstract class AudioWorkletProcessor { static get parameterDescriptors(): AudioParamDescriptor[]; constructor(options?: any); abstract process( inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record, ): boolean; } declare function registerProcessor(name: string, processorCtor: typeof AudioWorkletProcessor): void; /** Parameters for the DownsamplerProcessor. */ interface DownsamplerParameters extends Record { downsampling: Float32Array; } /** An AudioWorkletProcessor that applies a downsampling (sample-and-hold) effect to audio. */ class DownsamplerProcessor extends AudioWorkletProcessor { static override get parameterDescriptors(): AudioParamDescriptor[] { return [ { name: 'downsampling', defaultValue: 1, minValue: 1, maxValue: 40, automationRate: 'k-rate', }, ]; } private phase = 0; private lastSampleValue = 0; process( inputs: Float32Array[][], outputs: Float32Array[][], parameters: DownsamplerParameters, ): boolean { const input = inputs[0]; const output = outputs[0]; if (!input || !output) return true; // Nothing to process const downsampling = parameters['downsampling']; for (let channel = 0; channel < input.length; ++channel) { const inputChannel = input[channel]; const outputChannel = output[channel]; if (!inputChannel || !outputChannel) continue; for (let i = 0; i < inputChannel.length; ++i) { const downsamplingValue = downsampling.length > 1 ? downsampling[i]! : downsampling[0]!; // Downsampling: Hold the last sample value for 'downsamplingValue' samples. if (this.phase % downsamplingValue < 1) this.lastSampleValue = inputChannel[i]!; // Output the held sample. outputChannel[i] = this.lastSampleValue; this.phase++; } } return true; } } registerProcessor('downsampler-processor', DownsamplerProcessor); ================================================ FILE: src/client/scripts/esm/audio/processors/worklet-types.ts ================================================ // src/client/scripts/esm/audio/processors/worklet-types.ts /** * Stores missing audio worklet typescript types that apparently * aren't present in the @types/audioworklet package. */ /** Describes a parameter for an AudioWorkletProcessor. */ export interface AudioParamDescriptor { name: string; defaultValue?: number; minValue?: number; maxValue?: number; automationRate?: 'a-rate' | 'k-rate'; } ================================================ FILE: src/client/scripts/esm/chess/rendering/checkerboardgenerator.ts ================================================ // src/client/scripts/esm/chess/rendering/checkerboardgenerator.ts /** * This script can create a 2x2 checkerboard texture of any color for * light and dark tiles, and of any width. */ /** * Creates a checkerboard pattern image of a given size with custom colors. * @param lightColor - The color for the light squares (CSS color format). * @param darkColor - The color for the dark squares (CSS color format). * @param imageSize - The size of the image (width and height). The final image will be imageSize x imageSize, split into 4 squares. * @returns A promise that resolves to the checkerboard image. */ function createCheckerboardIMG( lightColor: string, darkColor: string, imageSize: number = 2, ): Promise { const canvas = document.createElement('canvas'); canvas.width = imageSize; canvas.height = imageSize; const ctx = canvas.getContext('2d')!; // Define the size of each square const squareSize: number = imageSize / 2; // Top-left (light square) ctx.fillStyle = lightColor; ctx.fillRect(0, 0, squareSize, squareSize); // Top-right (dark square) ctx.fillStyle = darkColor; ctx.fillRect(squareSize, 0, squareSize, squareSize); // Bottom-left (dark square) ctx.fillStyle = darkColor; ctx.fillRect(0, squareSize, squareSize, squareSize); // Bottom-right (light square) ctx.fillStyle = lightColor; ctx.fillRect(squareSize, squareSize, squareSize, squareSize); // Convert to an image element const img = new Image(); img.src = canvas.toDataURL(); // Return a promise that resolves when the image is loaded return new Promise((resolve, reject): void => { img.onload = (): void => resolve(img); img.onerror = (): void => { const errorMessage = 'Error loading the checkerboard texture'; console.error(errorMessage, img); reject(new Error(errorMessage)); }; }); } export default { createCheckerboardIMG, }; ================================================ FILE: src/client/scripts/esm/chess/rendering/imagecache.ts ================================================ // src/client/scripts/esm/chess/rendering/imagecache.ts /** * This script caches the HTMLImageElement objects for the pieces * required by the currently loaded game. * * It assumes that `initImagesForGame` is called before any * attempt to retrieve an image using `getPieceImage`. * * If no game is loaded, the cache should be empty. */ import type { Board } from '../../../../../shared/chess/logic/gamefile.js'; import type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import svgcache from '../../chess/rendering/svgcache.js'; import { GameBus } from '../../game/GameBus.js'; import svgtoimageconverter from '../../util/svgtoimageconverter.js'; // Variables --------------------------------------------------------------------------- /** * The cache storing HTMLImageElement objects for each piece type * required by the current game. Keys are the numeric piece types. */ let cachedImages: TypeGroup = {}; // Events --------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { deleteImageCache(); }); // Functions --------------------------------------------------------------------------- /** * Initializes the image cache for the provided gamefile. * Fetches necessary SVGs (using svgcache), converts them to images, * normalizes them, and stores them in the cache. */ async function initImagesForGame(boardsim: Board): Promise { if (Object.keys(cachedImages).length > 0) throw Error( 'Image cache already initialized. Call deleteImageCache() when unloading games.', ); // console.log("Initializing image cache for game..."); // 1. Determine required piece types (excluding SVG-less ones) const types = boardsim.existingTypes.filter( (t: number) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(t)), ); if (types.length === 0) return console.log( 'No piece types with SVGs found for this game. Image cache remains empty.', ); // console.log("Required piece types for image cache:", types); try { // 2. Get SVG elements using the existing svgcache // No width/height needed here as normalization will handle sizing later const svgElements = await svgcache.getSVGElements(types); // console.log(`Retrieved ${svgElements.length} SVG elements.`); // 3. Convert SVGs to initial Image elements const initialImages = await svgtoimageconverter.convertSVGsToImages(svgElements); // console.log(`Converted ${initialImages.length} SVGs to initial images.`); // 4. Normalize images and populate the cache // Patches firefox bug that darkens the image (when it is partially transparent) caused by double-multiplying the RGB channels by the alpha channel const newCache: { [type: string]: HTMLImageElement } = {}; // 'pawn-white' => HTMLImageElement const normalizationPromises: Promise[] = []; for (const img of initialImages) { // Ensure the image has an ID which corresponds to the piece type if (!img.id) throw Error('Image is missing ID after conversion from SVG.'); // Start normalization process for each image const promise = svgtoimageconverter .normalizeImagePixelData(img) .then((normalizedImg) => { newCache[img.id] = normalizedImg; // Optional: Log successful caching of a specific type // console.log(`Cached normalized image for type ${typeutil.debugType(Number(img.id))}`); }) .catch((error) => { console.error( `Failed to normalize or cache image for type ${typeutil.debugType(Number(img.id))}:`, error, ); // Decide how to handle normalization failures - potentially throw? }); normalizationPromises.push(promise); } // Wait for all normalizations to complete await Promise.all(normalizationPromises); // Replace the old cache with the newly populated one cachedImages = newCache; // console.log(`Image cache initialization complete. Cached ${Object.keys(cachedImages).length} images.`); } catch (error) { console.error('Error during image cache initialization:', error); // Clear cache on failure to avoid partial state cachedImages = {}; // Re-throw the error so the caller knows initialization failed throw error; } } /** * Retrieves a cached HTMLImageElement for the given piece type. * Throws an error if the image for the type is not found in the cache. * Assumes `initImagesForGame` has been successfully called beforehand. */ function getPieceImage(type: number): HTMLImageElement { const image = cachedImages[type]; if (!image) throw new Error( `Image for piece type ${typeutil.debugType(type)} not found in cache. Was initImagesForGame() called?`, ); // Optional: Return a clone to prevent external modification of the cached element? // For simple display, returning the direct reference is usually fine and more performant. // If you plan to modify the image attributes (like style) elsewhere, cloning might be safer: // return image.cloneNode(true) as HTMLImageElement; return image; } /** * Clears the image cache. Call this when the game unloads. */ function deleteImageCache(): void { // console.log("Deleting image cache."); cachedImages = {}; } // Exports ------------------------------------------------------------------- export default { initImagesForGame, getPieceImage, deleteImageCache, }; ================================================ FILE: src/client/scripts/esm/chess/rendering/svgcache.ts ================================================ // src/client/scripts/esm/chess/rendering/svgcache.ts /** * This module handles fetching and caching of chess piece SVGs. * It won't request the same SVG twice. */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { RawType, Player } from '../../../../../shared/chess/util/typeutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import pieceThemes from '../../../../../shared/components/header/pieceThemes.js'; import preferences from '../../components/header/preferences.js'; // Variables ----------------------------------------------------------------- /** Stores fetched SVG elements, keyed by their unique svg id (e.g., 'pawn-white'). These ids are on the svg elements themselves. */ const cachedPieceSVGs: { [pieceType: string]: SVGElement } = {}; /** Tracks promises for ongoing SVG file fetch requests, using the file URL as the key, to prevent duplicates. */ const processingCache: { [key: string]: Promise } = {}; // Initialization: Cache classical pieces on load. EVERY SINGLE GAME USES THESE. fetchLocation('classical'); // Core functionality -------------------------------------------------------- /** * Fetches required SVG files if not cached, then returns the SVG elements for the requested piece types. * This is the main public function for retrieving piece SVGs. */ async function getSVGElements( ids: number[], width?: number, height?: number, ): Promise { const locations = getNeededSVGLocations(ids); if (locations.size > 0) await fetchMissingTypes(locations); // At this point, all needed SVGs should be in the cache! return getSVGIDs(ids, width, height); } /** * Initiates fetch requests for all specified SVG file locations concurrently, preventing duplicate requests. * @param locations - A set of unique SVG location names (e.g., "classical", "fairy/rose") to fetch. */ async function fetchMissingTypes(locations: Set): Promise { await Promise.all([...locations].map(async (location) => fetchLocation(location))); } /** * Fetches an SVG file from a specific location, parses it, and caches the individual SVG elements found within. * It prevents duplicate fetch requests for the same URL while a request is already in progress. * @param location - The SVG file location on the server (e.g., "classical", "fairy/rose") relative to `svg/pieces/`. * @returns A promise that resolves when the fetch and caching are complete. */ async function fetchLocation(location: string): Promise { const url = `svg/pieces/${location}.svg`; if (!processingCache[url]) { processingCache[url] = (async (): Promise => { try { const response = await fetch(url); if (!response.ok) throw new Error( `HTTP error when fetching piece svgs from location "${location}"! status: ${response.status}`, ); const svgText = await response.text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); Array.from(doc.getElementsByTagName('svg')).forEach((svg) => { cachedPieceSVGs[svg.id] = svg; // console.log(`Fetched piece svg at location ${location}`); }); } catch (error) { // Remove the failed promise from the cache to allow retrying delete processingCache[url]; throw error; } })(); } else { // console.log(`Already fetching piece svg at location ${location}. Not sending duplicate request. Waiting..`); } await processingCache[url]; } /** * Tints an SVG element by applying a multiplication filter using the specified color. * The tint is applied by multiplying the original colors with the provided [r, g, b, a] values. * For example, white (1,1,1) becomes the tint color and black (0,0,0) remains black. * @param svgElement * @param color */ function tintSVG(svgElement: SVGElement, color: Color): SVGElement { // Ensure a element exists in the SVG const defs = svgElement.querySelector('defs') ?? svgElement.insertBefore( document.createElementNS('http://www.w3.org/2000/svg', 'defs'), svgElement.firstChild, ); // Create a unique filter const filterId = `tint-${crypto.randomUUID()}`; const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter'); filter.id = filterId; // Create feColorMatrix with the tinting effect to multiply color channels. const feColorMatrix = document.createElementNS('http://www.w3.org/2000/svg', 'feColorMatrix'); feColorMatrix.setAttribute('type', 'matrix'); // Construct the matrix values string, and multiply each color channel by them. // prettier-ignore const matrixValues = [ color[0], 0, 0, 0, 0, 0, color[1], 0, 0, 0, 0, 0, color[2], 0, 0, 0, 0, 0, color[3], 0 ].join(' '); feColorMatrix.setAttribute('values', matrixValues); // Append filter and apply it to the SVG filter.appendChild(feColorMatrix); defs.appendChild(filter); // Apply the filter to the SVG element. // svgElement.setAttribute('filter', `url(#${filterId})`); { // FIREFOX PATCH. Without this block, in firefox when converting the svg to an image, the filter is not applied. // Create a element to wrap all children (except ) const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('filter', `url(#${filterId})`); // Move all children (except ) into the element const children = Array.from(svgElement.childNodes); for (const child of children) { if (child !== defs) { group.appendChild(child); } } // Append the element to the SVG svgElement.appendChild(group); } return svgElement; } // Helper functions --------------------------------------------------------- /** * Determines the priority of what player color gets what color of svg, depending on what's available. * For example, if player neutral needs a pawn svg, it will first look for a neutral svg, * but when it doesn't exist it will fallback to the white svg. * @param color - The player color code (0, 1, or 2). * @returns An array of SVG color variant suffixes, ordered by lookup priority. */ function getSVGColorPriority(color: Player): string[] { switch (color) { case 0: // Neutral: prioritize neutral svg over white return ['-neutral', '-white']; case 1: // White: prioritize white svg over black return ['-white', '-neutral']; case 2: // Black: prioritize black svg over neutral return ['-black', '-neutral']; // All higher player numbers are treated as tinted white pieces... case 3: // Red: prioritize white svg over neutral return ['-white', '-neutral']; case 4: // Blue: prioritize white svg over neutral return ['-white', '-neutral']; case 5: // Yellow: prioritize white svg over neutral return ['-white', '-neutral']; case 6: // Green: prioritize white svg over neutral return ['-white', '-neutral']; default: throw new Error(`Invalid color code: ${color}`); } } /** * Identifies the unique SVG file locations (e.g., "classical", "fairy/rose") that need to be fetched. * It checks the cache first and only returns locations for types whose SVG variants are not yet cached. * @param types - An array of piece type numbers (combining raw type and color). * @returns A set of unique SVG file location names required for the given types. */ function getNeededSVGLocations(types: number[]): Set { const locations: Set = new Set(); typeloop: for (const type of types) { const [raw, c] = typeutil.splitType(type); const baseId = `${typeutil.getRawTypeStr(raw)}`; const checks: string[] = getSVGColorPriority(c); for (const c of checks) { const id = baseId + c; if (id in cachedPieceSVGs) continue typeloop; } locations.add(raw); } return pieceThemes.getLocationsForTypes(locations); } /** * Retrieves and prepares cloned SVG elements for the specified piece types from the cache. * It automatically applies our theme's tint as well. * @param types - An array of piece type numbers to get SVGs for. * @param [width] - Optional width to set on the SVG elements. * @param [height] - Optional height to set on the SVG elements. * @returns An array of cloned and prepared SVG elements. */ function getSVGIDs(types: number[], width?: number, height?: number): SVGElement[] { let failed: boolean = false; const svgs: SVGElement[] = []; l: for (const type of types) { const tint = preferences.getTintColorOfType(type); const [raw, c] = typeutil.splitType(type); const baseId = `${typeutil.getRawTypeStr(raw)}`; const checks: string[] = getSVGColorPriority(c); for (const c of checks) { const id = baseId + c; if (!(id in cachedPieceSVGs)) continue; // Clone the SVG element const cloned = cachedPieceSVGs[id]!.cloneNode(true) as SVGElement; cloned.id = String(type); // Set width and height if specified if (width !== undefined) cloned.setAttribute('width', width.toString()); if (height !== undefined) cloned.setAttribute('height', height.toString()); // Tint if non-white if (tint.some((channel) => channel !== 1)) tintSVG(cloned, tint); svgs.push(cloned); continue l; } console.error( `SVG at path "${pieceThemes.getLocationForType(raw)}" does not contain an svg with extensions ${checks} for ${baseId}`, ); failed = true; } if (failed) throw Error('SVG theme is missing ids for pieces'); return svgs; } /** * Appends all cached SVG elements directly to the document body for debugging purposes. * This allows visual inspection of the SVGs currently held in the cache. */ function showCache(): void { for (const svg of Object.values(cachedPieceSVGs)) { document.body.appendChild(svg); } } // Exports ------------------------------------------------------------------- export default { getSVGElements, showCache, }; ================================================ FILE: src/client/scripts/esm/chess/rendering/texturecache.ts ================================================ // src/client/scripts/esm/chess/rendering/texturecache.ts /** * This module handles the caching of WebGL textures of the pieces in our game. * It prevents redundant texture creation and data uploads to the GPU by caching * textures based on their source type. All textures are created with mipmaps enabled. */ import type { Board } from '../../../../../shared/chess/logic/gamefile.js'; import type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import imagecache from './imagecache.js'; import TextureLoader from '../../webgl/TextureLoader.js'; // Texture Cache Implementation ---------------------------------------------------------- /** Internal cache storing WebGLTexture objects, keyed by piece type. */ const textureCache: TypeGroup = {}; /** * Initializes the texture cache for the provided gamefile. * Retrieves necessary images from `imagecache`, creates WebGL textures * (with mipmaps enabled) for each, and stores them in the cache. * MUST be called after {@link imagecache.initImagesForGame}` has successfully completed. * @param gl - The WebGL2 rendering context. * @param boardsim - The board containing the list of piece types used. */ async function initTexturesForGame(gl: WebGL2RenderingContext, boardsim: Board): Promise { // Clear existing cache before initializing for a new game // if (Object.keys(textureCache).length > 0) throw Error("TextureCache: Cache already initialized. Call deleteTextureCache() when unloading games."); // console.log("Initializing texture cache for game..."); // 1. Determine required piece types (mirroring imagecache logic, filter SVG-less) const types = boardsim.existingTypes.filter( (t: number) => !typeutil.SVGLESS_TYPES.has(typeutil.getRawType(t)), ); if (types.length === 0) return console.log( 'TextureCache: No piece types with SVGs found for this game. Texture cache remains empty.', ); // console.log("Required piece types for texture cache:", types); // 2. Iterate and create textures for (const type of types) { // Retrieve the pre-cached loaded image const img = imagecache.getPieceImage(type); textureCache[type] = TextureLoader.loadTexture(gl, img, { mipmaps: true }); // console.log(`TextureCache: Cached texture for type ${typeutil.debugType(type)}`); } // console.log(`TextureCache: Initialization complete. Cached ${Object.keys(textureCache).length} textures.`); } /** * Retrieves a WebGLTexture from the cache. * ASSUMES `initTexturesForGame` has been called successfully for the current game. * @param type - The piece type. * @returns The cached WebGLTexture. */ function getTexture(type: number): WebGLTexture { // 1. Check cache using type directly as the key const cachedTexture = textureCache[type]; if (cachedTexture) return cachedTexture; // If not found, it implies initTexturesForGame wasn't called or failed for this type. else throw new Error( `TextureCache: Texture for type ${typeutil.debugType(type)} not found in cache. Was initTexturesForGame() called?`, ); } // /** // * Deletes all textures currently stored in the cache from the GPU memory // * and clears the internal cache object. // * // * **Important:** This requires the same WebGL context that was used to create the textures. // * Call this when the WebGL context is being destroyed or the cached textures are no longer needed // * to prevent GPU memory leaks. // */ // function deleteTextureCache(gl: WebGL2RenderingContext): void { // console.log("TextureCache: Deleting all cached textures..."); // for (const key in textureCache) gl.deleteTexture(textureCache[key]!); // textureCache = {}; // Clear the cache object // console.log(`TextureCache: Deleted textures from GPU and cleared cache.`); // } // Exports -------------------------------------------------------------------- export default { initTexturesForGame, // Add the init function to exports getTexture, }; ================================================ FILE: src/client/scripts/esm/components/header/currpage-greyer.ts ================================================ // src/client/scripts/esm/components/header/currpage-greyer.ts // Greys the background color of the header navigation link of the page we are currently on import docutil from '../../util/docutil.js'; import validatorama from '../../util/validatorama.js'; const loginLink = document.getElementById('login-link')!; (function init() { greyBackgroundOfCurrPage(); initListeners(); })(); /** Greys the background color of the header navigation link of the page we are currently on */ function greyBackgroundOfCurrPage(): void { document.querySelectorAll('nav a').forEach((link) => { const hrefPathname = docutil.getPathnameFromHref(link.getAttribute('href')!); if (hrefPathname === window.location.pathname) { // e.g. "/news" link.classList.add('currPage'); } else { link.classList.remove('currPage'); } }); updateColorOfProfileButton(); } // Greys the background color of the profile button if it is ours function updateColorOfProfileButton(): void { if (!window.location.pathname.startsWith('/member')) return; // Not on a members profile loginLink.classList.remove('currPage'); // Reset const username = validatorama.getOurUsername(); if (!username) return; // Not signed in, this isn't our profile if (docutil.getLastSegmentOfURL() === username.toLowerCase()) loginLink.classList.add('currPage'); } function initListeners(): void { document.addEventListener('login', updateColorOfProfileButton); // Custom-event listener. Fired when the validator script receives a response from the server with either our access token or new browser-id cookie. window.addEventListener('pageshow', greyBackgroundOfCurrPage); // Fired on initial page load AND when hitting the back button to return. } export default {}; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/appearancedropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/appearancedropdown.ts import themes from '../../../../../../shared/components/header/themes.js'; import style from '../../../game/gui/style.js'; import preferences from '../preferences.js'; import checkerboardgenerator from '../../../chess/rendering/checkerboardgenerator.js'; // Document Elements ------------------------------------------------------------------------- const appearanceDropdownTitle = document.querySelector('.appearance-dropdown .dropdown-title')!; const appearanceDropdown = document.querySelector('.appearance-dropdown')!; const themeList = document.querySelector('.theme-list')!; // Get the theme list div const coordinatesCheckbox = document.querySelector( '.boolean-option.coordinates input', )!; const starfieldCheckbox = document.querySelector( '.boolean-option.starfield input', )!; const advancedEffectsCheckbox = document.querySelector( '.boolean-option.advanced-effects input', )!; // Functions --------------------------------------------------------------------------------- (function init() { showCheckmarkOnSelectedOptions(); addThemesToThemesDropdown(); })(); function showCheckmarkOnSelectedOptions(): void { coordinatesCheckbox.checked = preferences.getCoordinatesEnabled(); starfieldCheckbox.checked = preferences.getStarfieldMode(); advancedEffectsCheckbox.checked = preferences.getAdvancedEffectsMode(); } async function addThemesToThemesDropdown(): Promise { const themeDictionary = themes.themes; // Loop through each theme in the dictionary for (const themeName in themeDictionary) { const theme = themeDictionary[themeName]!; const lightTiles = theme.lightTiles; const darkTiles = theme.darkTiles; // Create the checkerboard image for the theme const checkerboardImage = await checkerboardgenerator.createCheckerboardIMG( style.arrayToCssColor(lightTiles), // Convert to CSS color format style.arrayToCssColor(darkTiles), // Convert to CSS color format 2, // Width ); checkerboardImage.setAttribute('theme', themeName); checkerboardImage.setAttribute('draggable', 'false'); // Append the image to the theme list div themeList.appendChild(checkerboardImage); } updateThemeSelectedStyling(); } function open(): void { appearanceDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden initListeners(); } function close(): void { appearanceDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden closeListeners(); } function initListeners(): void { appearanceDropdownTitle.addEventListener('click', close); initThemeChangeListeners(); // Coordinates toggle coordinatesCheckbox.addEventListener('click', toggleCoordinates); // Starfield toggle starfieldCheckbox.addEventListener('click', toggleStarfield); // Advanced Effects toggle advancedEffectsCheckbox.addEventListener('click', toggleAdvancedEffects); } function closeListeners(): void { appearanceDropdownTitle.removeEventListener('click', close); closeThemeChangeListeners(); // Coordinates toggle coordinatesCheckbox.removeEventListener('click', toggleCoordinates); // Starfield toggle starfieldCheckbox.removeEventListener('click', toggleStarfield); // Advanced Effects toggle advancedEffectsCheckbox.removeEventListener('click', toggleAdvancedEffects); } function initThemeChangeListeners(): void { for (let i = 0; i < themeList.children.length; i++) { const theme = themeList.children[i]!; theme.addEventListener('click', selectTheme); } } function closeThemeChangeListeners(): void { for (let i = 0; i < themeList.children.length; i++) { const theme = themeList.children[i]!; theme.removeEventListener('click', selectTheme); } } function selectTheme(event: Event): void { const selectedTheme = (event.currentTarget as HTMLElement).getAttribute('theme')!; // Saves it to browser storage preferences.setTheme(selectedTheme); updateThemeSelectedStyling(); // Dispatch a custom event for theme change so that any game code present can pick it up. document.dispatchEvent(new Event('theme-change')); } /** Outlines in black the current theme selection */ function updateThemeSelectedStyling(): void { const selectedTheme = preferences.getTheme(); for (let i = 0; i < themeList.children.length; i++) { const theme = themeList.children[i]!; if (theme.getAttribute('theme') === selectedTheme) theme.classList.add('selected'); else theme.classList.remove('selected'); } } function toggleCoordinates(): void { preferences.setCoordinatesEnabled(coordinatesCheckbox.checked); } function toggleStarfield(): void { preferences.setStarfieldMode(starfieldCheckbox.checked); } function toggleAdvancedEffects(): void { preferences.setAdvancedEffectsMode(advancedEffectsCheckbox.checked); } export default { open, close, }; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/gameplaydropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/gameplaydropdown.ts // This script allows us to enable or disable premoves and dragging pieces import preferences from '../preferences.js'; // Document Elements ------------------------------------------------------------------------- const settingsDropdown = document.querySelector('.settings-dropdown')!; const gameplayDropdown = document.querySelector('.gameplay-dropdown')!; const gameplayDropdownTitle = document.querySelector('.gameplay-dropdown .dropdown-title')!; const dragCheckbox = document.querySelector('.boolean-option.drag input') as HTMLInputElement; const premoveCheckbox = document.querySelector('.boolean-option.premove input') as HTMLInputElement; const animationsCheckbox = document.querySelector( '.boolean-option.animations input', ) as HTMLInputElement; const fastTransitionsCheckbox = document.querySelector( '.boolean-option.fast-transitions input', ) as HTMLInputElement; const lingeringAnnotationsCheckbox = document.querySelector( '.boolean-option.lingering-annotations input', ) as HTMLInputElement; // Functions --------------------------------------------------------------------------------- (function init() { showCheckmarkOnSelectedOptions(); })(); function showCheckmarkOnSelectedOptions(): void { dragCheckbox.checked = preferences.getDragEnabled(); premoveCheckbox.checked = preferences.getPremoveEnabled(); animationsCheckbox.checked = preferences.getAnimationsMode(); fastTransitionsCheckbox.checked = preferences.getFastTransitionsMode(); lingeringAnnotationsCheckbox.checked = preferences.getLingeringAnnotationsMode(); } function open(): void { gameplayDropdown.classList.remove('visibility-hidden'); initListeners(); settingsDropdown.classList.add('transparent'); } function close(): void { gameplayDropdown.classList.add('visibility-hidden'); closeListeners(); settingsDropdown.classList.remove('transparent'); } function initListeners(): void { gameplayDropdownTitle.addEventListener('click', close); dragCheckbox.addEventListener('click', toggleDrag); premoveCheckbox.addEventListener('click', togglePremove); animationsCheckbox.addEventListener('click', toggleAnimations); fastTransitionsCheckbox.addEventListener('click', toggleFastTransitions); lingeringAnnotationsCheckbox.addEventListener('click', toggleLingeringAnnotations); } function closeListeners(): void { gameplayDropdownTitle.removeEventListener('click', close); dragCheckbox.removeEventListener('click', toggleDrag); premoveCheckbox.removeEventListener('click', togglePremove); animationsCheckbox.removeEventListener('click', toggleAnimations); fastTransitionsCheckbox.removeEventListener('click', toggleFastTransitions); lingeringAnnotationsCheckbox.removeEventListener('click', toggleLingeringAnnotations); } function toggleDrag(): void { preferences.setDragEnabled(dragCheckbox.checked); } function togglePremove(): void { preferences.setPremoveMode(premoveCheckbox.checked); } function toggleAnimations(): void { preferences.setAnimationsMode(animationsCheckbox.checked); } function toggleFastTransitions(): void { preferences.setFastTransitionsMode(fastTransitionsCheckbox.checked); } function toggleLingeringAnnotations(): void { preferences.setLingeringAnnotationsMode(lingeringAnnotationsCheckbox.checked); } export default { initListeners, closeListeners, close, open, }; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/languagedropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/languagedropdown.ts // This script selects new languages when we click a language in the language dropdown. // It also appends the lng query param to all header navigation links. // And it removes the lng query param from the url after loading. import docutil from '../../../util/docutil.js'; // Document Elements ------------------------------------------------------------------------- const settingsDropdown = document.querySelector('.settings-dropdown')!; const languageDropdown = document.querySelector('.language-dropdown')!; const dropdownItems = document.querySelectorAll('.language-dropdown-item'); const languageDropdownTitle = document.querySelector('.language-dropdown .dropdown-title')!; // Functions --------------------------------------------------------------------------------- (function init() { // Request cookie if it doesn't exist if (!docutil.getCookieValue('i18next')) { fetch('/setlanguage', { method: 'POST', credentials: 'same-origin', headers: { 'is-fetch-request': 'true', // Custom header }, }); } removeLngQueryParam(); })(); /** * Modifies the provided URL to include the "lng" query parameter based on the i18next cookie. * @param href - The original URL. * @returns The modified URL with the "lng" query parameter. */ function addLngQueryParamToLink(href: string): string { // Get the value of the i18next cookie const lng = docutil.getCookieValue('i18next'); if (!lng) return href; // Create a URL object from the given href const url = new URL(href, window.location.origin); // Add or update the "lng" query parameter url.searchParams.set('lng', lng); // Return the modified URL as a string return url.toString(); } /** This block auto-removes the "lng" query parameter from the url, visually, without refreshing */ function removeLngQueryParam(): void { // Create a URL object from the current window location const url = new URL(window.location.href); // Remove the "lng" query parameter url.searchParams.delete('lng'); // Update the browser's URL without refreshing the page window.history.replaceState({}, '', url); } function open(): void { languageDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden initListeners(); settingsDropdown.classList.add('transparent'); } function close(): void { languageDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden closeListeners(); settingsDropdown.classList.remove('transparent'); } function initListeners(): void { languageDropdownTitle.addEventListener('click', close); dropdownItems.forEach((item) => { item.addEventListener('click', onLanguageClicked); }); } function closeListeners(): void { languageDropdownTitle.removeEventListener('click', close); dropdownItems.forEach((item) => { item.removeEventListener('click', onLanguageClicked); }); } function onLanguageClicked(event: Event): void { const item = event.currentTarget as HTMLElement; const selectedLanguage = item.getAttribute('value')!; // Get the selected language code docutil.updateCookie('i18next', selectedLanguage, 365); // Modify the URL to include the "lng" query parameter const url = new URL(window.location.href); url.searchParams.set('lng', selectedLanguage); // Update the browser's URL without reloading the page window.history.replaceState({}, '', url); // Reload the page location.reload(); } export default { initListeners, closeListeners, addLngQueryParamToLink, open, close, }; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/legalmovedropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/legalmovedropdown.ts // This script selects new languages when we click a language in the language dropdown. // It also appends the lng query param to all header navigation links. // And it removes the lng query param from the url after loading. import preferences from '../preferences.js'; // Document Elements ------------------------------------------------------------------------- const settingsDropdown = document.querySelector('.settings-dropdown')!; const legalmoveDropdown = document.querySelector('.legalmove-dropdown')!; // const dropdownItems = document.querySelectorAll(".legalmove-option"); const legalmoveDropdownTitle = document.querySelector('.legalmove-dropdown .dropdown-title')!; const squaresOption = document.querySelector('.legalmove-option.squares')!; const dotsOption = document.querySelector('.legalmove-option.dots')!; // Functions --------------------------------------------------------------------------------- (function init() { showCheckmarkOnSelectedOption(); })(); function showCheckmarkOnSelectedOption(): void { const selectedLegalMovesOption = preferences.getLegalMovesShape(); // squares/dots const targetCheckmark = document.querySelector( `.legalmove-option.${selectedLegalMovesOption} .checkmark`, )!; targetCheckmark.classList.remove('visibility-hidden'); } function open(): void { legalmoveDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden initListeners(); settingsDropdown.classList.add('transparent'); } function close(): void { legalmoveDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden closeListeners(); settingsDropdown.classList.remove('transparent'); } function initListeners(): void { legalmoveDropdownTitle.addEventListener('click', close); squaresOption.addEventListener('click', toggleSquares); dotsOption.addEventListener('click', toggleDots); } function closeListeners(): void { legalmoveDropdownTitle.removeEventListener('click', close); squaresOption.removeEventListener('click', toggleSquares); dotsOption.removeEventListener('click', toggleDots); } function toggleSquares(): void { // console.log("Clicked squares"); preferences.setLegalMovesShape('squares'); hideAllCheckmarks(); const checkmark = document.querySelector('.legalmove-option.squares .checkmark')!; checkmark.classList.remove('visibility-hidden'); dispatchLegalMoveChangeEvent(); } function toggleDots(): void { // console.log("Clicked dots"); preferences.setLegalMovesShape('dots'); hideAllCheckmarks(); const checkmark = document.querySelector('.legalmove-option.dots .checkmark')!; checkmark.classList.remove('visibility-hidden'); dispatchLegalMoveChangeEvent(); } function hideAllCheckmarks(): void { document.querySelectorAll('.legalmove-option .checkmark').forEach((checkmark) => { checkmark.classList.add('visibility-hidden'); }); } function dispatchLegalMoveChangeEvent(): void { // Dispatch a custom event for theme change so that any game code present can pick it up. const themeChangeEvent = new CustomEvent('legalmove-shape-change'); document.dispatchEvent(themeChangeEvent); } export default { initListeners, closeListeners, close, open, }; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/perspectivedropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/perspectivedropdown.ts // This script allows us to adjust the mouse sensitivity in perspective mode import docutil from '../../../util/docutil.js'; import preferences from '../preferences.js'; // Document Elements ------------------------------------------------------------------------- const settingsDropdown = document.querySelector('.settings-dropdown')!; // The option in the main settings menu const perspectiveSettingsDropdownItem = document.getElementById( 'perspective-settings-dropdown-item', )!; const perspectiveDropdown = document.querySelector('.perspective-dropdown')!; const perspectiveDropdownTitle = document.querySelector('.perspective-dropdown .dropdown-title')!; const mouseSensitivitySlider = document.querySelector( '.perspective-options .mouse-sensitivity .slider', )!; /** The text that displays the value */ const mouseSensitivityOutput = document.querySelector( '.perspective-options .mouse-sensitivity .value', )!; const fovSlider = document.querySelector('.perspective-options .fov .slider')!; /** The text that displays the value */ const fovOutput = document.querySelector('.perspective-dropdown .fov .value')!; const fovResetDefaultContainer = document.querySelector( '.perspective-dropdown .fov .reset-default-container', )!; const fovResetDefault = document.querySelector('.perspective-dropdown .fov .reset-default')!; // Functions --------------------------------------------------------------------------------- (function init() { if (docutil.isMouseSupported()) perspectiveSettingsDropdownItem.classList.remove('hidden'); // Enable (perspective mode can't be used on mobile) else return; setInitialValues(); })(); /** Update the sliders according to the already existing preferences */ function setInitialValues(): void { mouseSensitivitySlider.value = String(preferences.getPerspectiveSensitivity()); updateMouseSensitivityOutput(); fovSlider.value = String(preferences.getPerspectiveFOV()); updateFOVOutput(); } function open(): void { perspectiveDropdown.classList.remove('visibility-hidden'); initListeners(); settingsDropdown.classList.add('transparent'); } function close(): void { perspectiveDropdown.classList.add('visibility-hidden'); closeListeners(); settingsDropdown.classList.remove('transparent'); } function initListeners(): void { perspectiveDropdownTitle.addEventListener('click', close); mouseSensitivitySlider.addEventListener('input', onMouseSensitivityChange); fovSlider.addEventListener('input', onFOVChange); fovResetDefault.addEventListener('click', resetFOVDefault); } function closeListeners(): void { perspectiveDropdownTitle.removeEventListener('click', close); mouseSensitivitySlider.removeEventListener('input', onMouseSensitivityChange); fovSlider.removeEventListener('input', onFOVChange); fovResetDefault.removeEventListener('click', resetFOVDefault); } function onMouseSensitivityChange(event: Event): void { const value = Number((event.currentTarget as HTMLInputElement).value); // console.log(`Mouse sensitivity changed: ${value}`); setMouseSensitivity(value); } function onFOVChange(event: Event): void { const value = Number((event.currentTarget as HTMLInputElement).value); // console.log(`FOV changed: ${value}`); setFOV(value); } function setMouseSensitivity(value: number): void { preferences.setPerspectiveSensitivity(value); updateMouseSensitivityOutput(); } function setFOV(value: number): void { preferences.setPerspectiveFOV(value); updateFOVOutput(); } function updateMouseSensitivityOutput(): void { const value = Number(mouseSensitivitySlider.value); mouseSensitivityOutput.textContent = value + '%'; } function updateFOVOutput(): void { const value = Number(fovSlider.value); fovOutput.textContent = String(value); updateFOVResetDefaultButton(value); } function updateFOVResetDefaultButton(value: number): void { if (value === preferences.getDefaultPerspectiveFOV()) fovResetDefaultContainer.classList.add('hidden'); else fovResetDefaultContainer.classList.remove('hidden'); } function resetFOVDefault(): void { const defaultFOV = preferences.getDefaultPerspectiveFOV(); fovSlider.value = String(defaultFOV); setFOV(defaultFOV); } export default { initListeners, closeListeners, close, open, }; ================================================ FILE: src/client/scripts/esm/components/header/dropdowns/sounddropdown.ts ================================================ // src/client/scripts/esm/components/header/dropdowns/sounddropdown.ts // This script manages the sound settings dropdown import preferences from '../preferences.js'; // Document Elements ------------------------------------------------------------------------- const settingsDropdown = document.querySelector('.settings-dropdown') as HTMLElement; const soundDropdown = document.querySelector('.sound-dropdown') as HTMLElement; const soundDropdownTitle = document.querySelector('.sound-dropdown .dropdown-title') as HTMLElement; const masterVolumeSlider = document.querySelector( '.sound-options .master-volume .slider', ) as HTMLInputElement; /** The text that displays the value */ const masterVolumeOutput = document.querySelector( '.sound-options .master-volume .value', ) as HTMLElement; const ambienceCheckbox = document.querySelector( '.boolean-option.ambience input', ) as HTMLInputElement; // Functions --------------------------------------------------------------------------------- (function init(): void { setInitialValues(); })(); /** Update the sliders and checkboxes according to the already existing preferences */ function setInitialValues(): void { masterVolumeSlider.value = String(preferences.getMasterVolume() * 100); // Preferences stores a value from 0 to 1 updateMasterVolumeOutput(); ambienceCheckbox.checked = preferences.getAmbienceEnabled(); } function open(): void { soundDropdown.classList.remove('visibility-hidden'); initListeners(); settingsDropdown.classList.add('transparent'); } function close(): void { soundDropdown.classList.add('visibility-hidden'); closeListeners(); settingsDropdown.classList.remove('transparent'); } function initListeners(): void { soundDropdownTitle.addEventListener('click', close); masterVolumeSlider.addEventListener('input', onMasterVolumeChange); ambienceCheckbox.addEventListener('click', toggleAmbience); } function closeListeners(): void { soundDropdownTitle.removeEventListener('click', close); masterVolumeSlider.removeEventListener('input', onMasterVolumeChange); ambienceCheckbox.removeEventListener('click', toggleAmbience); } function onMasterVolumeChange(event: Event): void { const value = Number((event.currentTarget as HTMLInputElement).value); preferences.setMasterVolume(value / 100); // Preferences expects a value from 0 to 1 updateMasterVolumeOutput(); } function toggleAmbience(): void { preferences.setAmbienceEnabled(ambienceCheckbox.checked); } function updateMasterVolumeOutput(): void { const value = Number(masterVolumeSlider.value); masterVolumeOutput.textContent = value + '%'; } export default { initListeners, closeListeners, close, open, }; ================================================ FILE: src/client/scripts/esm/components/header/faviconselector.ts ================================================ // src/client/scripts/esm/components/header/faviconselector.ts // This script auto detects device theme and adjusts the browser icon accordingly const element_favicon = document.getElementById('favicon') as HTMLLinkElement; /** Switches the browser icon to match the given theme. */ function switchFavicon(theme: 'dark' | 'light'): void { if (theme === 'dark') element_favicon.href = '/img/favicon/favicon-dark.png'; else element_favicon.href = '/img/favicon/favicon-light.png'; } // Don't create a theme-change event listener if matchMedia isn't supported. if (window.matchMedia) { // Initial theme detection const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches; switchFavicon(prefersDarkScheme ? 'dark' : 'light'); // Listen for theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => { const newTheme = event.matches ? 'dark' : 'light'; console.log(`Toggled ${newTheme} icon`); switchFavicon(newTheme); }); } export default {}; ================================================ FILE: src/client/scripts/esm/components/header/header.ts ================================================ // src/client/scripts/esm/components/header/header.ts // This script contains the code related to the // header that runs on every single page import validatorama from '../../util/validatorama.js'; import languagedropdown from './dropdowns/languagedropdown.js'; import './spacing.js'; import './settings.js'; import './faviconselector.js'; import './currpage-greyer.js'; import './news-notification.js'; // Handles unread news badge import '../../util/tooltips.js'; // This should be imported on EVERY page! // -------------------------------------------------------------------------------------- const loginLink = document.getElementById('login-link') as HTMLAnchorElement; const loginText = document.getElementById('login')!; const loginSVG = document.getElementById('svg-login')!; const profileText = document.getElementById('profile')!; const profileSVG = document.getElementById('svg-profile')!; const createaccountLink = document.getElementById('createaccount-link') as HTMLAnchorElement; const createaccountText = document.getElementById('createaccount')!; const createaccountSVG = document.getElementById('svg-createaccount')!; const logoutText = document.getElementById('logout')!; const logoutSVG = document.getElementById('svg-logout')!; (function init() { initListeners(); updateNavigationLinks(); // Do this once initially })(); function initListeners(): void { window.addEventListener('pageshow', updateNavigationLinks); // Fired on initial page load AND when hitting the back button to return. document.addEventListener('login', updateNavigationLinks); // Custom-event listener. Fired when the validator script receives a response from the server with either our access token or new browser-id cookie. document.addEventListener('logout', updateNavigationLinks); // Custom-event listener. Often fired when a web socket connection closes due to us logging out. } /** * Changes the navigation links, depending on if we're logged in, to * go to our Profile or the Log Out route, or the Log In / Create Account pages. */ function updateNavigationLinks(): void { const username = validatorama.getOurUsername(); if (username) { // Logged in loginText.classList.add('hidden'); loginSVG.classList.add('hidden'); createaccountText.classList.add('hidden'); createaccountSVG.classList.add('hidden'); profileText.classList.remove('hidden'); profileSVG.classList.remove('hidden'); logoutText.classList.remove('hidden'); logoutSVG.classList.remove('hidden'); loginLink.href = languagedropdown.addLngQueryParamToLink( `/member/${username.toLowerCase()}`, ); createaccountLink.href = languagedropdown.addLngQueryParamToLink('/logout'); } else { // Not logged in profileText.classList.add('hidden'); profileSVG.classList.add('hidden'); logoutSVG.classList.add('hidden'); logoutText.classList.add('hidden'); loginText.classList.remove('hidden'); loginSVG.classList.remove('hidden'); createaccountText.classList.remove('hidden'); createaccountSVG.classList.remove('hidden'); loginLink.href = languagedropdown.addLngQueryParamToLink('/login'); createaccountLink.href = languagedropdown.addLngQueryParamToLink('/createaccount'); } // Manually dispatch a window resize event so that our javascript knows to // recalc the spacing/compactness of the header, as the items have changed their content. document.dispatchEvent(new CustomEvent('resize')); } // For every '.badge img' in the document, prevent long-press context menu // Specify so TS knows these are HTMLElements (which have 'contextmenu') document.querySelectorAll('.badge img').forEach((img) => { // The native definition of contextmenu is MouseEvent. img.addEventListener('contextmenu', (event: MouseEvent) => { if (!(event instanceof PointerEvent)) return; // Only prevent default if the context menu is triggered by touch or pen if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return; console.log('Preventing context menu for badge image.'); event.preventDefault(); }); }); // OVERRIDE the viewport height variable in header.css based on how // much screen space the home button bar takes up on mobile devices! // Just using 100vh is incorrect as the home button bar doesn't affect that. updateViewportHeight(); window.addEventListener('resize', () => updateViewportHeight()); function updateViewportHeight(): void { document.documentElement.style.setProperty('--vh', `${window.innerHeight}px`); } ================================================ FILE: src/client/scripts/esm/components/header/news-notification.ts ================================================ // src/client/scripts/esm/components/header/news-notification.ts /** * This script handles the unread news notification badge in the header. * It fetches the count of unread news posts and displays a red circle badge * next to the News link when there are unread posts. */ import validatorama from '../../util/validatorama.js'; const newsLink = document.querySelector('a[href*="/news"]'); let notificationBadge: HTMLSpanElement | null = null; /** * Creates and returns the notification badge element * @param count - The number of unread news posts */ function createNotificationBadge(count: number): HTMLSpanElement { const badge = document.createElement('span'); badge.className = 'news-notification-badge'; // Display count as "9+" for 10 or more, otherwise show the number const displayText = count >= 10 ? '9+' : count.toString(); badge.textContent = displayText; badge.style.cssText = ` position: absolute; top: 2px; right: 4px; background-color: #ff4444; color: white; border-radius: 50%; width: 16px; height: 16px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; line-height: 1; box-shadow: 0 2px 4px rgba(0,0,0,0.3); pointer-events: none; `; return badge; } /** * Fetches the unread news count from the server */ async function fetchUnreadNewsCount(): Promise { try { const response = await fetch('/api/news/unread-count', { headers: { 'is-fetch-request': 'true', }, }); if (!response.ok) { console.error('Failed to fetch unread news count'); return 0; } const data = (await response.json()) as { count: number }; return data.count || 0; } catch (error) { console.error('Error fetching unread news count:', error); return 0; } } /** * Updates the notification badge display */ async function updateNotificationBadge(): Promise { // Only show badge if user is logged in const username = validatorama.getOurUsername(); if (!username) { removeNotificationBadge(); return; } const count = await fetchUnreadNewsCount(); if (count > 0) { showNotificationBadge(count); } else { removeNotificationBadge(); } } /** * Shows the notification badge with the given count * @param count - The number of unread news posts */ function showNotificationBadge(count: number): void { if (!newsLink) { return; } if (!notificationBadge) { notificationBadge = createNotificationBadge(count); newsLink.appendChild(notificationBadge); } else { // Update existing badge text const displayText = count >= 10 ? '9+' : count.toString(); notificationBadge.textContent = displayText; } } /** * Removes the notification badge */ function removeNotificationBadge(): void { if (notificationBadge && notificationBadge.parentNode) { notificationBadge.remove(); notificationBadge = null; } } /** * Initializes the news notification feature */ function init(): void { if (!newsLink) { console.warn('News link not found in header'); return; } // Update on page load updateNotificationBadge(); // Update when login state changes document.addEventListener('login', updateNotificationBadge); document.addEventListener('logout', () => removeNotificationBadge()); // Listen for custom event when news is marked as read document.addEventListener('news-marked-read', () => { updateNotificationBadge(); }); } init(); export default { updateNotificationBadge, removeNotificationBadge, }; ================================================ FILE: src/client/scripts/esm/components/header/pingmeter.ts ================================================ // src/client/scripts/esm/components/header/pingmeter.ts /** * This script manages the display and updates of the ping meter. */ // Document Elements ------------------------------------------------------------------------- const pingMeter = document.querySelector('.ping-meter')!; const pingBars = document.querySelector('.ping-bars')!; const pingValue = document.querySelector('.ping-value')!; const loadingAnim = document.querySelector('.ping-meter .svg-pawn')!; // Spinning-pawn loading animation // Variables --------------------------------------------------------------------------------- // Functions --------------------------------------------------------------------------------- (function init() { initEventListeners(); })(); function initEventListeners(): void { document.addEventListener('ping', updatePing); // Custom event. When we receive this event, we know we are connected document.addEventListener('socket-opening', openMeterAndDisplayLoading); // Custom event that is dispatched whenever we start trying to open a new socket upgrade connection request. document.addEventListener('connection-lost', openMeterAndDisplayLoading); // Custom event document.addEventListener('socket-closed', socketClosed); // Custom event } function updatePing(event: CustomEvent): void { showPing_hideLoadingAnim(); const newPing = event.detail; // console.log(`New ping! ${newPing}`); pingValue.textContent = newPing; updateBarCount(newPing); } function updateBarCount(ping: number): void { removeAllColor(); const newBarCount = getBarCount(ping); const color = newBarCount >= 3 ? 'green' : newBarCount === 2 ? 'yellow' : 'red'; for (let i = 1; i <= newBarCount; i++) { const thisPingBar = pingBars.children[i - 1]!; thisPingBar.classList.add(color); } } function removeAllColor(): void { for (let i = 1; i <= pingBars.children.length; i++) { const thisPingBar = pingBars.children[i - 1]!; thisPingBar.classList.remove('green'); thisPingBar.classList.remove('yellow'); thisPingBar.classList.remove('red'); } } /** * Returns the number of Bars that should be lit up according to the given ping. * This can be customized. */ function getBarCount(ping: number): number { if (ping <= 150) return 4; else if (ping <= 300) return 3; else if (ping <= 550) return 2; else return 1; } function showPing_hideLoadingAnim(): void { pingMeter.classList.remove('hidden'); pingBars.classList.remove('hidden'); loadingAnim.classList.add('hidden'); } /** Open meter. Hide the green bars, show the spinning-pawn loading animation, set the ping to ω */ function openMeterAndDisplayLoading(): void { pingMeter.classList.remove('hidden'); // Reveals ping meter loadingAnim.classList.remove('hidden'); pingBars.classList.add('hidden'); pingValue.textContent = 'ω'; } /** * A callback function that is executed when we receive the custom socket closed event. * 1. If the soccer was close by choice, we close the ping meter. * 2. If the socket was closed by bad network, we display the loading animation */ function socketClosed(event: CustomEvent): void { const notByChoise = event.detail; // This will be true if the user didn't intend to close the connection, they could have bad network. if (notByChoise) openMeterAndDisplayLoading(); // Hide the green bars, show the spinning-pawn loading animation else closeMeter(); // By choice. Just close the ping meter, we are no longer connected } /** Hides the ping meter from the settings dropdown document element */ function closeMeter(): void { pingMeter.classList.add('hidden'); loadingAnim.classList.remove('hidden'); pingValue.textContent = '-'; } export default {}; ================================================ FILE: src/client/scripts/esm/components/header/preferences.ts ================================================ // src/client/scripts/esm/components/header/preferences.ts import type { Color } from '../../../../../shared/util/math/math.js'; import themes from '../../../../../shared/components/header/themes.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import timeutil from '../../../../../shared/util/timeutil.js'; import pieceThemes, { PieceColorGroup, } from '../../../../../shared/components/header/pieceThemes.js'; import docutil from '../../util/docutil.js'; import LocalStorage from '../../util/LocalStorage.js'; import validatorama from '../../util/validatorama.js'; /** Prefs that do NOT get saved on the server side */ const clientSidePrefs: string[] = [ 'perspective_sensitivity', 'perspective_fov', 'drag_enabled', 'premove_enabled', 'fast_transitions_enabled', 'coordinates_enabled', 'starfield_enabled', 'advanced_effects_enabled', 'master_volume', 'ambience_enabled', ]; interface ClientSidePreferences { perspective_sensitivity: number; perspective_fov: number; drag_enabled: boolean; premove_enabled: boolean; fast_transitions_enabled: boolean; coordinates_enabled: boolean; starfield_enabled: boolean; advanced_effects_enabled: boolean; /** Master volume level from 0 (silent) to 1 (full volume) */ master_volume: number; ambience_enabled: boolean; [key: string]: any; } interface ServerSidePreferences { theme: string; legal_moves: 'dots' | 'squares'; animations: boolean; lingering_annotations: boolean; } /** Both client and server side preferences */ type Preferences = ServerSidePreferences & ClientSidePreferences; // Variables ------------------------------------------------------------ /** All our preferences. */ let preferences: Preferences; // The legal moves shape preference const default_legal_moves: 'dots' | 'squares' = 'squares'; // dots/squares const default_drag_enabled: boolean = true; const default_premove_enabled: boolean = true; const default_fast_transitions_enabled: boolean = false; /** When false, animations are instant, only playing the sound. (same as dropping dragged pieces) */ const default_animations: boolean = true; const default_perspective_sensitivity: number = 100; const default_perspective_fov: number = 90; const default_lingering_annotations: boolean = false; const default_coordinates_enabled: boolean = false; const default_starfield_enabled: boolean = true; const default_advanced_effects_enabled: boolean = true; const default_master_volume: number = 1; const default_ambience_enabled: boolean = true; /** * Whether a change was made to the preferences since the last time we sent them over to the server. * We only change this to true if we change a preference that isn't only client side. */ let changeWasMade: boolean = false; // Functions ----------------------------------------------------------------------- (function init(): void { loadPreferences(); })(); function loadPreferences(): void { const browserStoragePrefs: Preferences = LocalStorage.loadItem('preferences') || { theme: themes.defaultTheme, legal_moves: default_legal_moves, perspective_sensitivity: default_perspective_sensitivity, perspective_fov: default_perspective_fov, drag_enabled: default_drag_enabled, premove_enabled: default_premove_enabled, fast_transitions_enabled: default_fast_transitions_enabled, animations: default_animations, lingering_annotations: default_lingering_annotations, coordinates_enabled: default_coordinates_enabled, starfield_enabled: default_starfield_enabled, advanced_effects_enabled: default_advanced_effects_enabled, master_volume: default_master_volume, ambience_enabled: default_ambience_enabled, }; preferences = browserStoragePrefs; const cookiePrefs: string | undefined = docutil.getCookieValue('preferences'); if (cookiePrefs) { // console.log("Preferences cookie was present!"); preferences = JSON.parse(decodeURIComponent(cookiePrefs)); // console.log(jsutil.deepCopyObject(preferences)); clientSidePrefs.forEach((pref) => (preferences![pref] = browserStoragePrefs[pref])); } } function savePreferences(): void { const oneYearInMillis: number = timeutil.getTotalMilliseconds({ years: 1 }); LocalStorage.saveItem('preferences', preferences, oneYearInMillis); // After a delay, also send a post request to the server to update our preferences. // Auto send it if the window is closing } function onChangeMade(): void { changeWasMade = true; validatorama.getAccessToken(); // Preload the access token so that we are ready to quickly save our preferences on the server if the page is unloaded } async function sendPrefsToServer(): Promise { if (!validatorama.areWeLoggedIn()) return; // Ensure user is logged in if (!changeWasMade) return; // Only send if preferences were changed changeWasMade = false; // Reset the flag after sending console.log('Sending preferences to the server!'); const preparedPrefs: ServerSidePreferences = preparePrefs(); // Prepare the preferences to send POSTPrefs(preparedPrefs); } async function POSTPrefs(preparedPrefs: ServerSidePreferences): Promise { // Configure the POST request const config = { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header } as Record, body: JSON.stringify({ preferences: preparedPrefs }), // Send the preferences as JSON }; // Get the access token and add it to the Authorization header const token: string | undefined = await validatorama.getAccessToken(); if (token) config.headers['Authorization'] = `Bearer ${token}`; // If you use tokens for authentication try { const response: Response = await fetch('/api/set-preferences', config); // Check if the response status code indicates success (e.g., 200-299 range) if (response.ok) { console.log('Preferences updated successfully on the server.'); } else { // Handle unsuccessful response const errorData: any = await response.json(); console.error( 'Failed to update preferences on the server:', errorData.message || errorData, ); } } catch (error) { console.error('Error sending preferences to the server:', error); } } function preparePrefs(): ServerSidePreferences { const prefsCopy: Preferences = jsutil.deepCopyObject(preferences); Object.keys(prefsCopy).forEach((prefName) => { if (clientSidePrefs.includes(prefName)) delete prefsCopy[prefName]; }); // console.log(`Original preferences: ${JSON.stringify(preferences)}`); // console.log(`Prepared preferences: ${JSON.stringify(prefsCopy)}`); return prefsCopy; } function getTheme(): string { return preferences.theme || themes.defaultTheme; } function setTheme(theme: string): void { preferences.theme = theme; console.log('Set theme'); onChangeMade(); savePreferences(); } function getCoordinatesEnabled(): boolean { return preferences.coordinates_enabled ?? default_coordinates_enabled; } function setCoordinatesEnabled(value: boolean): void { preferences.coordinates_enabled = value; savePreferences(); } function getStarfieldMode(): boolean { return preferences.starfield_enabled ?? default_starfield_enabled; } function setStarfieldMode(value: boolean): void { preferences.starfield_enabled = value; savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('starfield-toggle', { detail: value })); } function getLegalMovesShape(): 'dots' | 'squares' { return preferences.legal_moves || default_legal_moves; } function setLegalMovesShape(legal_moves: 'dots' | 'squares'): void { if (typeof legal_moves !== 'string') throw new Error('Cannot set preference legal_moves when it is not a string.'); preferences.legal_moves = legal_moves; onChangeMade(); savePreferences(); } function getDragEnabled(): boolean { return preferences.drag_enabled ?? default_drag_enabled; } function setDragEnabled(drag_enabled: boolean): void { if (typeof drag_enabled !== 'boolean') throw new Error('Cannot set preference drag_enabled when it is not a boolean.'); preferences.drag_enabled = drag_enabled; savePreferences(); } function getPremoveEnabled(): boolean { return preferences.premove_enabled ?? default_premove_enabled; } function setPremoveMode(value: boolean): void { preferences.premove_enabled = value; savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('premoves-toggle', { detail: value })); } function getFastTransitionsMode(): boolean { return preferences.fast_transitions_enabled ?? default_fast_transitions_enabled; } function setFastTransitionsMode(value: boolean): void { preferences.fast_transitions_enabled = value; savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('fast-transitions-toggle', { detail: value })); } function getAnimationsMode(): boolean { return preferences.animations ?? default_animations; } function setAnimationsMode(animations_enabled: boolean): void { preferences.animations = animations_enabled; onChangeMade(); savePreferences(); } function getPerspectiveSensitivity(): number { return preferences.perspective_sensitivity || default_perspective_sensitivity; } function setPerspectiveSensitivity(perspective_sensitivity: number): void { if (typeof perspective_sensitivity !== 'number') throw new Error('Cannot set preference perspective_sensitivity when it is not a number.'); preferences.perspective_sensitivity = perspective_sensitivity; savePreferences(); } function getPerspectiveFOV(): number { return preferences.perspective_fov || default_perspective_fov; } function getDefaultPerspectiveFOV(): number { return default_perspective_fov; } function setPerspectiveFOV(perspective_fov: number): void { if (typeof perspective_fov !== 'number') throw new Error('Cannot set preference perspective_fov when it is not a number.'); preferences.perspective_fov = perspective_fov; savePreferences(); document.dispatchEvent(new CustomEvent('fov-change')); } function getLingeringAnnotationsMode(): boolean { return preferences.lingering_annotations ?? default_lingering_annotations; } function setLingeringAnnotationsMode(value: boolean): void { preferences.lingering_annotations = value; onChangeMade(); savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('lingering-annotations-toggle', { detail: value })); } /** Whether the user has enabled "Advanced Effects" in the settings. */ function getAdvancedEffectsMode(): boolean { return preferences.advanced_effects_enabled ?? default_advanced_effects_enabled; } function setAdvancedEffectsMode(value: boolean): void { preferences.advanced_effects_enabled = value; savePreferences(); } function getMasterVolume(): number { return preferences.master_volume ?? default_master_volume; } function setMasterVolume(master_volume: number): void { if (typeof master_volume !== 'number') throw new Error('Cannot set preference master_volume when it is not a number.'); if (master_volume > 1) throw new Error('Cannot set master_volume > 1!'); preferences.master_volume = master_volume; savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('master-volume-change', { detail: master_volume })); } /** Whether the user has enabled "Ambience" in the settings. */ function getAmbienceEnabled(): boolean { return preferences.ambience_enabled ?? default_ambience_enabled; } function setAmbienceEnabled(ambience_enabled: boolean): void { if (typeof ambience_enabled !== 'boolean') throw new Error('Cannot set preference ambience_enabled when it is not a boolean.'); preferences.ambience_enabled = ambience_enabled; savePreferences(); // Dispatch an event so that the game code can detect it, if present. document.dispatchEvent(new CustomEvent('ambience-toggle', { detail: ambience_enabled })); } // Getters for our current theme properties -------------------------------------------------------- function getColorOfLightTiles(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'lightTiles'); } function getColorOfDarkTiles(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'darkTiles'); } function getLegalMoveHighlightColor({ isOpponentPiece, isPremove, }: { isOpponentPiece: boolean; isPremove: boolean; }): Color { const themeName: string = getTheme(); if (isOpponentPiece) return themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Opponent'); else if (isPremove) return themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Premove'); else return themes.getPropertyOfTheme(themeName, 'legalMovesHighlightColor_Friendly'); } function getLastMoveHighlightColor(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'lastMoveHighlightColor'); } function getCheckHighlightColor(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'checkHighlightColor'); } function getBoxOutlineColor(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'boxOutlineColor'); } function getAnnoteSquareColor(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'annoteSquareColor'); } function getAnnoteArrowColor(): Color { const themeName: string = getTheme(); return themes.getPropertyOfTheme(themeName, 'annoteArrowColor'); } /** Returns the tint color for a piece of the given type, according to our current theme. */ function getTintColorOfType(type: number): Color { const [r, p] = typeutil.splitType(type); const baseColor: Color = pieceThemes.getBaseColorForType(r, p); const themeName: string = getTheme(); const themePieceColors: Partial = themes.getPropertyOfTheme( themeName, 'pieceTheme', ); const tint: Color = themePieceColors[p] ?? [1, 1, 1, 1]; // Multiply the colors together to get the final color return [ baseColor[0] * tint[0], baseColor[1] * tint[1], baseColor[2] * tint[2], baseColor[3] * tint[3], ]; } // /** // * Determines the theme based on the current date. // * @returns {string} The theme for the current date ('halloween', 'christmas', or 'default'). // */ // function getHollidayTheme() { // if (timeutil.isCurrentDateWithinRange(10, 25, 10, 31)) return 'halloween'; // Halloween week (October 25 to 31) // // if (timeutil.isCurrentDateWithinRange(11, 23, 11, 29)) return 'thanksgiving'; // Thanksgiving week (November 23 to 29) // if (timeutil.isCurrentDateWithinRange(12, 19, 12, 25)) return 'christmas'; // Christmas week (December 19 to 25) // return themes.defaultTheme; // Default theme if not in a holiday week // } /* * The commented stuff below is ONLY used for fast * modifying of theme players using the keyboard keys! */ // import { listener_document } from "../../game/chess/game.js"; // const allProperties = Object.keys(themes.themes[themes.defaultTheme]!); // let currPropertyIndex = 0; // let currProperty = allProperties[currPropertyIndex]!; // function update() { // const themeProperties = themes.themes[preferences.theme]!; // if (listener_document.isKeyDown('KeyU')) { // currPropertyIndex--; // if (currPropertyIndex < 0) currPropertyIndex = allProperties.length - 1; // currProperty = allProperties[currPropertyIndex]!; // console.log(`Selected property: ${currProperty}`); // } // if (listener_document.isKeyDown('KeyI')) { // currPropertyIndex++; // if (currPropertyIndex > allProperties.length - 1) currPropertyIndex = 0; // currProperty = allProperties[currPropertyIndex]!; // console.log(`Selected property: ${currProperty}`); // } // const amount = 0.02; // if (listener_document.isKeyDown('KeyJ')) { // const dig = 0; // // @ts-ignore // themeProperties[currProperty][dig] += amount; // // @ts-ignore // if (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('KeyM')) { // const dig = 0; // // @ts-ignore // themeProperties[currProperty][dig] -= amount; // // @ts-ignore // if (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('KeyK')) { // const dig = 1; // // @ts-ignore // themeProperties[currProperty][dig] += amount; // // @ts-ignore // if (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('Comma')) { // const dig = 1; // // @ts-ignore // themeProperties[currProperty][dig] -= amount; // // @ts-ignore // if (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('KeyL')) { // const dig = 2; // // @ts-ignore // themeProperties[currProperty][dig] += amount; // // @ts-ignore // if (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('Period')) { // const dig = 2; // // @ts-ignore // themeProperties[currProperty][dig] -= amount; // // @ts-ignore // if (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('Semicolon')) { // const dig = 3; // // @ts-ignore // themeProperties[currProperty][dig] += amount; // // @ts-ignore // if (themeProperties[currProperty][dig] > 1) themeProperties[currProperty][dig] = 1; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('Slash')) { // const dig = 3; // // @ts-ignore // themeProperties[currProperty][dig] -= amount; // // @ts-ignore // if (themeProperties[currProperty][dig] < 0) themeProperties[currProperty][dig] = 0; // // @ts-ignore // console.log(themeProperties[currProperty]); // } // if (listener_document.isKeyDown('Backslash')) { // console.log(JSON.stringify(themes.themes[preferences.theme])); // } // } // function dispatchThemeChangeEvent() { // document.dispatchEvent(new Event('theme-change')); // } // setInterval(dispatchThemeChangeEvent, 1000); // Exports ----------------------------------------------------------------------------------------- export default { getTheme, setTheme, getCoordinatesEnabled, setCoordinatesEnabled, getStarfieldMode, setStarfieldMode, getLegalMovesShape, setLegalMovesShape, getDragEnabled, setDragEnabled, getPremoveEnabled, setPremoveMode, getFastTransitionsMode, setFastTransitionsMode, getAnimationsMode, setAnimationsMode, getPerspectiveSensitivity, setPerspectiveSensitivity, getPerspectiveFOV, getDefaultPerspectiveFOV, setPerspectiveFOV, getLingeringAnnotationsMode, setLingeringAnnotationsMode, getAdvancedEffectsMode, setAdvancedEffectsMode, getMasterVolume, setMasterVolume, getAmbienceEnabled, setAmbienceEnabled, sendPrefsToServer, getColorOfLightTiles, getColorOfDarkTiles, getLegalMoveHighlightColor, getLastMoveHighlightColor, getCheckHighlightColor, getBoxOutlineColor, getAnnoteSquareColor, getAnnoteArrowColor, getTintColorOfType, // Only used for temporarily micro adjusting theme properties & colors // update, }; ================================================ FILE: src/client/scripts/esm/components/header/settings.ts ================================================ // src/client/scripts/esm/components/header/settings.ts // This script opens and closes our settings drop-down menu when it is clicked. import math from '../../../../../shared/util/math/math.js'; import themes from '../../../../../shared/components/header/themes.js'; import style from '../../game/gui/style.js'; import preferences from './preferences.js'; import sounddropdown from './dropdowns/sounddropdown.js'; import languagedropdown from './dropdowns/languagedropdown.js'; import gameplaydropdown from './dropdowns/gameplaydropdown.js'; import legalmovedropdown from './dropdowns/legalmovedropdown.js'; import appearancedropdown from './dropdowns/appearancedropdown.js'; import perspectivedropdown from './dropdowns/perspectivedropdown.js'; import './pingmeter.js'; // Only imported so its code runs // Document Elements ------------------------------------------------------------------------- // Main settings dropdown const settings = document.getElementById('settings')!; const settingsDropdown = document.querySelector('.settings-dropdown')!; // All buttons to open nested dropdowns const languageDropdownSelection = document.getElementById('language-settings-dropdown-item')!; const appearanceDropdownSelection = document.getElementById('appearance-settings-dropdown-item')!; const legalmoveDropdownSelection = document.getElementById('legalmove-settings-dropdown-item')!; const mouseDropdownSelection = document.getElementById('perspective-settings-dropdown-item')!; const gameplayDropdownSelection = document.getElementById('gameplay-settings-dropdown-item')!; const soundDropdownSelection = document.getElementById('sound-settings-dropdown-item')!; // All nested dropdowns const languageDropdown = document.querySelector('.language-dropdown')!; const appearanceDropdown = document.querySelector('.appearance-dropdown')!; const legalmoveDropdown = document.querySelector('.legalmove-dropdown')!; const perspectiveDropdown = document.querySelector('.perspective-dropdown')!; const gameplayDropdown = document.querySelector('.gameplay-dropdown')!; const soundDropdown = document.querySelector('.sound-dropdown')!; const allSettingsDropdownsExceptMainOne = [ languageDropdown, appearanceDropdown, legalmoveDropdown, perspectiveDropdown, gameplayDropdown, soundDropdown, ]; // Variables --------------------------------------------------------------------------------- const allSettingsDropdowns = [...allSettingsDropdownsExceptMainOne, settingsDropdown]; let settingsIsOpen = settings.classList.contains('open'); // Functions --------------------------------------------------------------------------------- (function init() { settings.addEventListener('click', (event) => { if (didEventClickAnyDropdown(event)) return; // We clicked any dropdown, don't toggle it off toggleSettingsDropdown(); }); // Close the dropdown if clicking outside of it document.addEventListener('click', closeSettingsDropdownIfClickedAway); document.addEventListener('touchstart', closeSettingsDropdownIfClickedAway); updateBackgroundColor(); document.addEventListener('theme-change', updateBackgroundColor); // [DEBUGGING] Instantly open the settings dropdown on page refresh // openSettingsDropdown(); })(); function toggleSettingsDropdown(): void { if (settingsIsOpen) closeAllSettingsDropdowns(); else openSettingsDropdown(); } function openSettingsDropdown(): void { // Opens the initial settings dropdown settings.classList.add('open'); settingsDropdown.classList.remove('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden initSettingsListeners(); settingsIsOpen = true; } function closeAllSettingsDropdowns(): void { // Closes all dropdowns that may be open settings.classList.remove('open'); closeMainSettingsDropdown(); closeAllSettingsDropdownsExceptMainOne(); settingsIsOpen = false; } function closeMainSettingsDropdown(): void { settingsDropdown.classList.add('visibility-hidden'); // The stylesheet adds a short delay animation to when it becomes hidden closeSettingsListeners(); preferences.sendPrefsToServer(); } function closeAllSettingsDropdownsExceptMainOne(): void { languagedropdown.close(); appearancedropdown.close(); legalmovedropdown.close(); gameplaydropdown.close(); perspectivedropdown.close(); sounddropdown.close(); } function initSettingsListeners(): void { languageDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); languageDropdownSelection.addEventListener('click', languagedropdown.open); appearanceDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); appearanceDropdownSelection.addEventListener('click', appearancedropdown.open); legalmoveDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); legalmoveDropdownSelection.addEventListener('click', legalmovedropdown.open); mouseDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); mouseDropdownSelection.addEventListener('click', perspectivedropdown.open); gameplayDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); gameplayDropdownSelection.addEventListener('click', gameplaydropdown.open); soundDropdownSelection.addEventListener('click', closeAllSettingsDropdownsExceptMainOne); soundDropdownSelection.addEventListener('click', sounddropdown.open); } function closeSettingsListeners(): void { languageDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne); languageDropdownSelection.removeEventListener('click', languagedropdown.open); appearanceDropdownSelection.removeEventListener( 'click', closeAllSettingsDropdownsExceptMainOne, ); appearanceDropdownSelection.removeEventListener('click', appearancedropdown.open); legalmoveDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne); legalmoveDropdownSelection.removeEventListener('click', legalmovedropdown.open); mouseDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne); mouseDropdownSelection.removeEventListener('click', perspectivedropdown.open); gameplayDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne); gameplayDropdownSelection.removeEventListener('click', gameplaydropdown.open); soundDropdownSelection.removeEventListener('click', closeAllSettingsDropdownsExceptMainOne); soundDropdownSelection.removeEventListener('click', sounddropdown.open); } function closeSettingsDropdownIfClickedAway(event: MouseEvent | TouchEvent): void { // Check if it is actually a Node before using .contains if ( event.target instanceof Node && !settings.contains(event.target) && !didEventClickAnyDropdown(event) ) { closeAllSettingsDropdowns(); } } function didEventClickAnyDropdown(event: MouseEvent | TouchEvent): boolean { // Check if the click was outside the dropdown let clickedDropdown = false; allSettingsDropdowns.forEach((dropdown) => { if (event.target instanceof Node && dropdown.contains(event.target)) clickedDropdown = true; }); return clickedDropdown; } /** Updates the stylesheet colors --background-theme-color and --switch-on-color based on the current theme. */ function updateBackgroundColor(): void { const theme = preferences.getTheme(); const lightTiles = themes.getPropertyOfTheme(theme, 'lightTiles'); const darkTiles = themes.getPropertyOfTheme(theme, 'darkTiles'); const AvgR = (lightTiles[0] + darkTiles[0]) / 2; const AvgG = (lightTiles[1] + darkTiles[1]) / 2; const AvgB = (lightTiles[2] + darkTiles[2]) / 2; const switchR = AvgR * 255; const switchG = AvgG * 255; const switchB = AvgB * 255; const cssSwitch = style.rgbToCssString(switchR, switchG, switchB); // Also set the --background-theme-color property, which is just a slightly brightened version! // The board editor uses this for the background of selected tools. // Convert to HSL Color const backgroundHSL = style.rgbToHsl(switchR, switchG, switchB); // Brighten by 5% backgroundHSL.l += 0.05; // Min lightness of 0.6 (Prevent dark themes from making accent colors too dark) backgroundHSL.l = math.clamp(backgroundHSL.l, 0.6, 1); // Create CSS string const cssBackground = style.hslToCssString(backgroundHSL); // Set CSS properties const root = document.documentElement; root.style.setProperty('--switch-on-color', cssSwitch); root.style.setProperty('--background-theme-color', cssBackground); } export default {}; ================================================ FILE: src/client/scripts/esm/components/header/spacing.ts ================================================ // src/client/scripts/esm/components/header/spacing.ts // Spacing: This script handles the spacing of our header elements at various screen widths const header = document.querySelector('header')!; const home = document.querySelector('.home')!; // "Infinite Chess" text const nav = document.querySelector('nav')!; const links = document.querySelectorAll('nav a'); // Paddings allowed between each of our header links (right of logo & left of gear) const maxPadding = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--header-link-max-padding'), ); const minPadding = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--header-link-min-padding'), ); // const gear = document.querySelector('.settings'); // These things are hidden in our stylesheet off the bat to give our javascript // here time to calculate the spacing of everything before rendering for (const child of header.children) child.classList.remove('visibility-hidden'); let compactnessLevel = 0; updateSpacing(); // Initial spacing on page load window.addEventListener('resize', updateSpacing); // Continuous spacing on page-resizing function updateSpacing(): void { // Reset to least compact, so that we can measure if each stage fits. // If it doesn't, we go down to the next compact stage compactnessLevel = 0; updateMode(); updatePadding(); let spaceBetween = getSpaceBetweenHeaderFlexElements(); while (spaceBetween === 0 && compactnessLevel < 4) { compactnessLevel++; updateMode(); updatePadding(); spaceBetween = getSpaceBetweenHeaderFlexElements(); // Recalculate space after adjusting compactness and padding } } /** * Updates the left-right padding of the navigation links (right of logo and left of gear) * according to how much space is available. */ function updatePadding(): void { const spaceBetween = getSpaceBetweenHeaderFlexElements(); // If the space is less than 100px, reduce padding gradually if (spaceBetween >= 100) { // Reset to max padding when space is larger than 100px links.forEach((link) => { if (!(link instanceof HTMLElement)) return; link.style.paddingLeft = `${maxPadding}px`; link.style.paddingRight = `${maxPadding}px`; }); } else { const newPadding = Math.max(minPadding, maxPadding * (spaceBetween / 100)); links.forEach((link) => { if (!(link instanceof HTMLElement)) return; link.style.paddingLeft = `${newPadding}px`; link.style.paddingRight = `${newPadding}px`; }); } } function updateMode(): void { if (compactnessLevel === 0) { home.classList.remove('compact-1'); // Show the "Infinite Chess" text nav.classList.remove('compact-2'); // Show the navigation SVGs nav.classList.remove('compact-3'); // Show the navigation TEXT } else if (compactnessLevel === 1) { home.classList.add('compact-1'); // Hide the "Infinite Chess" text nav.classList.remove('compact-2'); // Show the navigation SVGs nav.classList.remove('compact-3'); // Show the navigation TEXT } else if (compactnessLevel === 2) { home.classList.add('compact-1'); // Hide the "Infinite Chess" text nav.classList.add('compact-2'); // Hide the navigation SVGs nav.classList.remove('compact-3'); // Show the navigation TEXT } else if (compactnessLevel === 3) { home.classList.add('compact-1'); // Hide the "Infinite Chess" text nav.classList.remove('compact-2'); // Show the navigation SVGs nav.classList.add('compact-3'); // Hide the navigation TEXT } } function getSpaceBetweenHeaderFlexElements(): number { const homeRight = home.getBoundingClientRect().right; const navLeft = nav.getBoundingClientRect().left; return navLeft - homeRight; } export default {}; ================================================ FILE: src/client/scripts/esm/game/GameBus.ts ================================================ // src/client/scripts/esm/game/GameBus.ts import type { Piece } from '../../../../shared/chess/util/boardutil'; import type { LegalMoves } from '../../../../shared/chess/logic/legalmoves'; import { EventBus } from '../../../../shared/util/EventBus'; interface GameBusEvents { // =========== Logical Events ============ 'game-loaded': void; 'game-unloaded': void; /** Dispatched when games end, and the termination is shown on screen. */ 'game-concluded': void; 'piece-selected': { piece: Piece; legalMoves: LegalMoves }; 'piece-unselected': void; // /** Dispatched immediately before legal move generation. */ // 'pre-move-gen': { // gamefile: FullGame; // piece: Piece; // /** Mod scripts should define this if they would like to totally override normal legal move gen. */ // moveOverrides: LegalMoves | undefined; // }; // /** Dispatched immediately after legal move gen. Mods may add additional legal moves. */ // 'post-move-gen': { gamefile: FullGame; piece: Piece; legalMoves: LegalMoves }; /** Dispatched when a physical (not premove or simulated) move is made by us, NOT our opponent. */ 'user-move-played': void; /** Dispatched when a physical move is made on the board by any player, even our own premoves, or making a board editor edit. */ 'physical-move': void; // =========== Graphical Events =========== 'render-below-pieces': void; 'render-above-pieces': void; } export const GameBus: EventBus = new EventBus(); ================================================ FILE: src/client/scripts/esm/game/boardeditor/actions/eactions.ts ================================================ // src/client/scripts/esm/game/boardeditor/actions/eactions.ts /** * Editor Actions * * Contains handlers for the one-time action buttons on the Board Editor UI, such as: * * * Reset position * * Clear position * * Saved positions * * Copy notation * * Paste notation * * Game rules * * Start local game from position */ import type { Edit } from '../../../../../../shared/chess/logic/movepiece'; import type { VariantOptions } from '../../../../../../shared/chess/logic/initvariant'; import type { EngineUIConfig } from '../../gui/boardeditor/actions/guistartenginegame'; import type { EditorSaveState } from '../editortypes'; import type { MetaData, MovePacket } from '../../../../../../shared/types.js'; import type { EnPassant, GlobalGameState } from '../../../../../../shared/chess/logic/state'; import type { ActivePosition, StorageType } from '../boardeditor'; import bimath from '../../../../../../shared/util/math/bimath'; import variant from '../../../../../../shared/chess/variants/variant'; import typeutil from '../../../../../../shared/chess/util/typeutil'; import movepiece from '../../../../../../shared/chess/logic/movepiece'; import checkdetection from '../../../../../../shared/chess/logic/checkdetection'; import boardutil, { Piece } from '../../../../../../shared/chess/util/boardutil'; import coordutil, { Coords, CoordsKey } from '../../../../../../shared/chess/util/coordutil'; import organizedpieces, { OrganizedPieces, } from '../../../../../../shared/chess/logic/organizedpieces'; import gamefile, { Additional, Board, FullGame, } from '../../../../../../shared/chess/logic/gamefile'; import icnconverter, { MoveParsed, LongFormatIn, LongFormatOut, } from '../../../../../../shared/chess/logic/icn/icnconverter'; import toast from '../../gui/toast'; import docutil from '../../../util/docutil'; import gameslot from '../../chess/gameslot'; import pastegame from '../../chess/pastegame'; import gameloader from '../../chess/gameloader'; import egamerules from '../egamerules'; import annotations from '../../rendering/highlights/annotations/annotations'; import boardeditor from '../boardeditor'; import edithistory from '../edithistory'; import validatorama from '../../../util/validatorama'; import guinavigation from '../../gui/guinavigation'; import selectiontool from '../tools/selection/selectiontool'; import hydrochess_card from '../../chess/engines/enginecards/hydrochess_card'; import clientmetadatautil from '../../chess/clientmetadatautil'; import { engineDictionary } from '../../chess/engines/engine'; import gamecompressor, { SimplifiedGameState } from '../../chess/gamecompressor'; // Constants ---------------------------------------------------------------------- /** * If a position with less pieces than this is pasted, the position dependent * game rules (pawnDoublePush, castling) are accurately updated, * else they are set to undetermined. */ const PIECE_LIMIT_KEEP_TRACK_OF_GLOBAL_SPECIAL_RIGHTS = 2_000_000; // Actions ---------------------------------------------------------------------- /** Resets the board editor position to the Classical position. */ async function reset(): Promise { if (!boardeditor.areInBoardEditor()) return; // Unload logical and rendering parts of current position gameloader.unloadLogicalAndRendering(); // Load default board editor position boardeditor.clearActivePosition(); await gameloader.startBoardEditor(); } /** Clears the entire board editor position. */ async function clearAll(): Promise { if (!boardeditor.areInBoardEditor()) return; // Unload logical and rendering parts of current position gameloader.unloadLogicalAndRendering(); // Initialize board editor with empty position and bare minimum game rules const gameRules = variant.getBareMinimumGameRules(); const position: Map = new Map(); const specialRights: Set = new Set(); const state_global: GlobalGameState = { specialRights }; const variantOptions: VariantOptions = { fullMove: 1, gameRules, position, state_global, }; boardeditor.clearActivePosition(); await gameloader.startBoardEditorFromCustomPosition( { additional: { variantOptions, }, }, true, // Dirty position (unsaved changes) false, ); } /** Loads a position from a savestate. */ async function load(editorSaveState: EditorSaveState, storage_type: StorageType): Promise { if (!boardeditor.areInBoardEditor()) return; // Unload logical and rendering parts of current position gameloader.unloadLogicalAndRendering(); // prettier-ignore const new_active_position: ActivePosition = storage_type === 'cloud' ? { name: editorSaveState.position_name, storage_type: 'cloud', owner: validatorama.getOurUsername()! } : { name: editorSaveState.position_name, storage_type: 'local' }; boardeditor.setActivePosition(new_active_position); await gameloader.startBoardEditorFromCustomPosition( { additional: { variantOptions: editorSaveState.variantOptions, }, }, false, // Clean position (no unsaved changes) since we're loading one that was already saved editorSaveState.pawnDoublePush, editorSaveState.castling, ); toast.show(translations.editor.position_loaded); } /** * copygame uses the move list instead of the position * which doesn't work for the board editor. * This function uses the position of pieces on the board. */ function copy(): void { if (!boardeditor.areInBoardEditor()) return; const variantOptions = getCurrentPositionInformation(false); const LongFormatIn: LongFormatIn = { metadata: {} as MetaData /** Empty metadata, in order to make copied codes easier to share */, ...variantOptions, }; const shortFormatOut = icnconverter.LongToShort_Format(LongFormatIn, { skipPosition: false, compact: true, spaces: false, comments: false, make_new_lines: false, move_numbers: false, }); docutil.copyToClipboard(shortFormatOut); toast.show(translations.copypaste.copied_position); } /** Loads the position from the clipboard. */ async function paste(): Promise { if (!boardeditor.areInBoardEditor()) return; let longformOut: LongFormatOut; // Do we have clipboard permission? let clipboard: string; try { clipboard = await navigator.clipboard.readText(); } catch (error) { const message: string = translations.copypaste.clipboard_denied; toast.show(message + '\n' + error, { error: true }); return; } // Convert clipboard text to longformat try { longformOut = icnconverter.ShortToLong_Format(clipboard); } catch (e) { console.error(e); toast.show(translations.copypaste.clipboard_invalid, { error: true }); return; } loadFromLongformat(longformOut); selectiontool.resetState(); // Clear current selection toast.show(translations.copypaste.loaded_position_from_clipboard); } /** Starts a local game from the current board editor position, to test play. */ function startLocalGame(): void { if (!boardeditor.areInBoardEditor()) return; const variantOptions = getCurrentPositionInformation(true); if (isPositionIllegal(variantOptions)) { toast.show(translations.editor.illegal_position_king_capture, { error: true }); return; } if (variantOptions.position.size === 0) { toast.show(translations.editor.cannot_start_local_empty, { error: true }); return; } gameloader.unloadGame(); gameloader.startCustomLocalGame({ additional: { variantOptions, }, }); } function startEngineGame(engineUIConfig: EngineUIConfig): void { if (!boardeditor.areInBoardEditor()) return; const currentEngine = 'hydrochess'; // Get current position const variantOptions = getCurrentPositionInformation(true); if (isPositionIllegal(variantOptions)) { toast.show(translations.editor.illegal_position_king_capture, { error: true }); return; } // Determine whether it's not supported... if (variantOptions.position.size === 0) { toast.show(translations.editor.cannot_start_engine_empty, { error: true }); return; } // Set world border automatically, if wished if (engineUIConfig.setDefaultWorldBorder) { // Calculate minimum bounding box of all pieces const bb = boardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces)!; // Guaranteed defined since above we check if there's > 0 pieces /* * Priority: * 1. Default distance * 2. Capped at engine's cap */ const worldBorderProperty = engineDictionary[currentEngine].worldBorder; const cap = hydrochess_card.BORDER_CAP; // How far can we extend in each direction before hitting ±limit? const availableLeft = bb.left + cap; const availableRight = cap - bb.right; const availableBottom = bb.bottom + cap; const availableTop = cap - bb.top; // Calculate separate limiting distances for horizontal and vertical axes const availableHorz = bimath.min(availableLeft, availableRight); const availableVert = bimath.min(availableBottom, availableTop); // Use the minimum between the default and the capped const distHorz = bimath.min(worldBorderProperty, availableHorz); const distVert = bimath.min(worldBorderProperty, availableVert); variantOptions.gameRules.worldBorder = { left: bb.left - distHorz, right: bb.right + distHorz, bottom: bb.bottom - distVert, top: bb.top + distVert, }; } // Does the engine support the position and settings? const supported_result = hydrochess_card.isPositionSupported(variantOptions); if (!supported_result.supported) { toast.show(`${translations.editor.position_not_supported} ${supported_result.reason}`, { error: true, }); return; } gameloader.unloadGame(); gameloader.startCustomEngineGame({ timeControl: engineUIConfig.timeControl, additional: { variantOptions, }, youAreColor: engineUIConfig.youAreColor, currentEngine, engineConfig: { engineTimeLimitPerMoveMillis: engineDictionary[currentEngine].defaultTimeLimitPerMoveMillis, strengthLevel: engineUIConfig.strengthLevel, }, }); } // Helpers ---------------------------------------------------------------- /** * Returns true if the current editor position is illegal to start a checkmate game from, * because the 2nd player to move is already in check on turn 1 — meaning the 1st player * could immediately capture their royal piece, which can only happen in illegal positions. */ function isPositionIllegal(variantOptions: VariantOptions): boolean { // Only applicable when checkmate is used by any player const checkmateUsed = Object.values(variantOptions.gameRules.winConditions).some((conds) => conds.includes('checkmate'), ); if (!checkmateUsed) return false; // King capture legal in non-checkmate variants // The 2nd player to move is the one whose royal could be captured on the 1st move const secondPlayer = variantOptions.gameRules.turnOrder[1]; if (secondPlayer === undefined) return false; // Umm why did this happen? const result = checkdetection.detectCheck(gameslot.getGamefile()!, secondPlayer); return result.check; // Illegal position (allows king capture) } /** Queues the removal of all pieces from the position. */ function queueRemovalOfAllPieces(gamefile: FullGame, edit: Edit, pieces: OrganizedPieces): void { for (const idx of pieces.coords.values()) { const pieceToDelete: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!; edithistory.queueRemovePiece(gamefile, edit, pieceToDelete); } } /** * Reconstructs the current VariantOptions object (including position, gameRules and state_global) from the current board editor position * @param revokeRedundantRights - If true, special rights of pieces that no longer have a valid castling partner are revoked. */ function getCurrentPositionInformation(revokeRedundantRights: boolean): VariantOptions { // Get current game rules and state const { gameRules, moveRuleState, enpassantcoords } = egamerules.getCurrentGamerulesAndState(); // Construct position const gamefile = gameslot.getGamefile()!; const position = organizedpieces.generatePositionFromPieces(gamefile.boardsim.pieces); // Construct state_global const specialRights = new Set(gamefile.boardsim.state.global.specialRights); // Makes a copy so we don't modify the original belonging to the current gamefile if (revokeRedundantRights) revokeRedundantSpecialRights(gamefile.boardsim, specialRights); let enpassant: EnPassant | undefined; if (enpassantcoords !== undefined) { const playerToMove = egamerules.getPlayerToMove(); // prettier-ignore const pawn: Coords = playerToMove === 'white' ? [enpassantcoords[0], enpassantcoords[1] - 1n] : playerToMove === 'black' ? [enpassantcoords[0], enpassantcoords[1] + 1n] : (() => { throw new Error("Invalid player to move"); })(); // Future protection enpassant = { square: enpassantcoords, pawn }; } const state_global: GlobalGameState = { specialRights, moveRuleState, enpassant, }; // Construct VariantOptions const variantOptions: VariantOptions = { fullMove: 1, gameRules, position, state_global, }; return variantOptions; } /** * Revokes special rights from pieces that no longer have a valid castling partner. * MUTATES the input specialRights set. * @param boardsim * @param specialRights - MUST be a copy of the gamefile's specialRights set! This will be mutated, NOT the gamefile's internal one. */ function revokeRedundantSpecialRights(boardsim: Board, specialRights: Set): void { // Iterate through each piece with special rights, and remove them if they don't have a valid castling partner for (const coordsKey of specialRights) { const candidate = boardutil.getPieceFromCoordsKey(boardsim.pieces, coordsKey)!; // Guaranteed defined because it wouldn't be in specialRights otherwise const rawType = typeutil.getRawType(candidate.type); if (egamerules.pawnDoublePushTypes.includes(rawType)) continue; // Pawns can't castle const hasValidCastlingPartner = movepiece.hasCastlingPartner(boardsim, candidate); if (!hasValidCastlingPartner) specialRights.delete(coordsKey); } } /** * pastegame loads in a new position by creating a new gamefile and loading it * which doesn't work for the board editor. * This function simply applies an edit to the position of the pieces on the board. * @param longformat - If this optional parameter is defined, it is used as the position to load instead of getting the position from the clipboard */ async function loadFromLongformat(longformOut: LongFormatIn): Promise { // Resolve variant code from the ICN metadata, normalizing it to the English display name. const resolvedVariantCode = variant.resolveAndNormalizeVariantInMetadata(longformOut.metadata); const timestamp = clientmetadatautil.resolveTimestampFromMetadata( longformOut.metadata.UTCDate, longformOut.metadata.UTCTime, ); let { position, specialRights } = pastegame.getPositionAndSpecialRightsFromLongFormat( longformOut, resolvedVariantCode, timestamp, ); let stateGlobal = longformOut.state_global; // If longformat contains moves, then we construct a FullGame object and use it to fast forward to the final position // If it contains no moves, then we skip all that, thus saving time if (longformOut.moves && longformOut.moves.length !== 0) { const state_global = { ...longformOut.state_global, specialRights }; const variantOptions: VariantOptions = { position, state_global, fullMove: longformOut.fullMove, gameRules: longformOut.gameRules, }; const additional: Additional = { variantOptions, moves: longformOut.moves.map((m: MoveParsed) => { const move: MovePacket = { token: m.token }; return move; }), }; const loadedGamefile = gamefile.initFullGame( longformOut.metadata, timestamp, resolvedVariantCode, additional, ); const gamestate: SimplifiedGameState = { position, state_global, fullMove: longformOut.fullMove, turnOrder: longformOut.gameRules.turnOrder, }; const new_gamestate = gamecompressor.GameToPosition( gamestate, loadedGamefile.boardsim.moves, loadedGamefile.boardsim.moves.length, ); position = new_gamestate.position; specialRights = new_gamestate.state_global.specialRights!; stateGlobal = new_gamestate.state_global; } const thisGamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const pieces = thisGamefile.boardsim.pieces; const edit: Edit = { changes: [], state: { local: [], global: [] } }; // Remove all current pieces from position queueRemovalOfAllPieces(thisGamefile, edit, pieces); const keepTrackOfGlobalSpecialRights = position.size < PIECE_LIMIT_KEEP_TRACK_OF_GLOBAL_SPECIAL_RIGHTS; let pawnDoublePush: boolean | undefined = undefined; let castling: boolean | undefined = undefined; // Add all new pieces as dictated by the pasted position let all_pawns_have_double_push = true; let at_least_one_pawn_has_double_push = false; let all_pieces_obey_normal_castling = true; let at_least_one_piece_obeys_normal_castling = false; for (const [coordKey, pieceType] of position.entries()) { const coords = coordutil.getCoordsFromKey(coordKey); const hasSpecialRights = specialRights.has(coordKey); edithistory.queueAddPiece(thisGamefile, edit, coords, pieceType, hasSpecialRights); if (!keepTrackOfGlobalSpecialRights) continue; // One if statement cost is very tiny per iteration const rawtype = typeutil.getRawType(pieceType); if (egamerules.pawnDoublePushTypes.includes(rawtype)) { if (hasSpecialRights) at_least_one_pawn_has_double_push = true; else all_pawns_have_double_push = false; } else if (egamerules.castlingTypes.includes(rawtype)) { if (hasSpecialRights) at_least_one_piece_obeys_normal_castling = true; else all_pieces_obey_normal_castling = false; } else if (hasSpecialRights) { at_least_one_piece_obeys_normal_castling = true; all_pieces_obey_normal_castling = false; } } if (keepTrackOfGlobalSpecialRights) { // prettier-ignore pawnDoublePush = at_least_one_pawn_has_double_push ? (all_pawns_have_double_push ? true : undefined) : false; // prettier-ignore castling = at_least_one_piece_obeys_normal_castling ? (all_pieces_obey_normal_castling ? true : undefined) : false; } egamerules.setGamerulesGUIinfo(longformOut.gameRules, stateGlobal, pawnDoublePush, castling); // Set gamerules object according to pasted game edithistory.runEdit(thisGamefile, mesh, edit, true); edithistory.addEditToHistory(edit); annotations.resetState(); // Clear all annotations guinavigation.callback_Expand(); // Virtually press the "Expand to fit all" button after position is loaded } // Exports -------------------------------------------------------------------- export default { reset, clearAll, load, copy, paste, startLocalGame, startEngineGame, getCurrentPositionInformation, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/actions/eautosave.ts ================================================ // src/client/scripts/esm/game/boardeditor/actions/eautosave.ts /** * This script handles autosaving the board editor position * It autosaves periodically, but only if the position is dirty, aka if it has changed since last time. */ import type { EditorAutosaveState } from '../editortypes'; import eactions from './eactions'; import IndexedDB from '../../../util/IndexedDB'; import egamerules from '../egamerules'; import boardeditor from '../boardeditor'; import editortypes from '../editortypes'; import validatorama from '../../../util/validatorama'; // Constants ------------------------------------------------------------- /** Name of editor autosave in local storage */ const EDITOR_AUTOSAVE_NAME = 'editor-autosave'; // Variables -------------------------------------------------------------- /** Number of milliseconds for period of position autosave */ const positionAutosaveIntervalMillis = 10000; /** Interval object for position autosave */ let positionAutosaveTimer: number | undefined; /** Prevent overlapping IndexedDB writes (single-flight): is autosave ongoing */ let positionAutosaveInFlight = false; /** Prevent overlapping IndexedDB writes (single-flight): is autosave pending */ let positionAutosavePending = false; /** Track whether anything changed since last save */ let positionDirty = true; // Functions -------------------------------------------------------------- /** * Mark position as needing save. * This is called when the position or the game rules change. */ function markPositionDirty(): void { positionDirty = true; } /** Auto saves the board editor position once. */ async function autosaveCurrentPositionOnce(): Promise { // Track dirtiness: skip unnecessary writes that don't change anything if (!positionDirty) return; // Coalesce: if a save is already running, request another and return. if (positionAutosaveInFlight) { positionAutosavePending = true; return; } positionAutosaveInFlight = true; positionAutosavePending = false; try { const variantOptions = eactions.getCurrentPositionInformation(false); const { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules(); await IndexedDB.saveItem(EDITOR_AUTOSAVE_NAME, { active_position: boardeditor.getActivePosition(), dirty: boardeditor.isPositionDirty(), timestamp: Date.now(), piece_count: variantOptions.position.size, variantOptions, pawnDoublePush, castling, } satisfies EditorAutosaveState); positionDirty = false; } catch (err) { // Don't crash the editor over failed autosave console.error('Failed to autosave board editor position:', err); } finally { positionAutosaveInFlight = false; // If something changed while saving, immediately save again (latest wins). if (positionAutosavePending) { positionAutosavePending = false; // Mark dirty because we want to flush latest state. positionDirty = true; // Fire and forget; caller doesn't need to await. void autosaveCurrentPositionOnce(); } } } /** Initialize new autosave interval */ function startPositionAutosave(): void { stopPositionAutosave(); // Stop existing interval if we opened a new save // Do an initial save after init (for safety) positionDirty = true; void autosaveCurrentPositionOnce(); positionAutosaveTimer = window.setInterval(() => { // Don't save if editor is closed mid-tick if (!boardeditor.areInBoardEditor()) return; void autosaveCurrentPositionOnce(); }, positionAutosaveIntervalMillis); } /** Kill running autosave interval */ function stopPositionAutosave(): void { if (positionAutosaveTimer !== undefined) { clearInterval(positionAutosaveTimer); positionAutosaveTimer = undefined; } } function clearAutosave(): void { IndexedDB.deleteItem(EDITOR_AUTOSAVE_NAME).catch((err) => { console.error('Failed to clear board editor autosave:', err); }); } /** * Reads and validates the autosave from IndexedDB. * Returns undefined if no autosave exists. * Clears and returns undefined if the data is corrupted or if the active * position is a cloud save owned by a different user (e.g. after logout or * account switch). */ async function loadAutosave(): Promise { const raw = await IndexedDB.loadItem(EDITOR_AUTOSAVE_NAME); if (raw === undefined) return undefined; const parsed = editortypes.AutosaveStateSchema.safeParse(raw); if (!parsed.success) { console.error('Corrupted board editor autosave data found, clearing autosave.'); clearAutosave(); return undefined; } // If the autosave belongs to a cloud save owned by a different user, discard it. // Prevents accidentally trying to save the posiiton to a user that isn't logged in. const ap = parsed.data.active_position; if (ap?.storage_type === 'cloud' && ap.owner !== validatorama.getOurUsername()) { console.log('Clearing editor auto save from a different user.'); clearAutosave(); return undefined; } return parsed.data; } export default { markPositionDirty, startPositionAutosave, autosaveCurrentPositionOnce, stopPositionAutosave, clearAutosave, loadAutosave, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/actions/ecloud.ts ================================================ // src/client/scripts/esm/game/boardeditor/actions/ecloud.ts /** * Handles cloud (server) save/load operations for the board editor. * Mirrors esave.ts for cloud storage. */ import type { MetaData } from '../../../../../../shared/types'; import type { LongFormatIn } from '../../../../../../shared/chess/logic/icn/icnconverter'; import type { VariantOptions } from '../../../../../../shared/chess/logic/initvariant'; import type { EditorSaveState } from '../editortypes'; import type { CloudPositionRecord, CloudSaveListRecord } from './editorSavesAPI'; import editorutil from '../../../../../../shared/util/editorutil'; import icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter'; import toast from '../../gui/toast'; import esave from './esave'; import eactions from './eactions'; import eautosave from './eautosave'; import egamerules from '../egamerules'; import compression from '../../../util/compression'; import boardeditor from '../boardeditor'; import validatorama from '../../../util/validatorama'; import editorSavesAPI from './editorSavesAPI'; // Actions ---------------------------------------------------------------------- /** * Parses a CloudPositionRecord into an EditorSaveState, decompressing the ICN * if necessary. * @returns An EditorSaveState on success, undefined on failure (errors are toasted internally). */ async function parseCloudPosition( position_name: string, cloudPosition: CloudPositionRecord, ): Promise { let icn: string; try { icn = await compression.decompressString(cloudPosition.icn, cloudPosition.compression); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); console.error('Failed to decompress cloud position ICN:', err); toast.show(`${translations.editor.failed_to_load} ${errMsg}`, { error: true }); return undefined; } let longFormOut; try { longFormOut = icnconverter.ShortToLong_Format(icn); } catch (err) { console.error('Failed to parse cloud position ICN:', err); toast.show(translations.editor.position_corrupted, { error: true }); return; } const variantOptions: VariantOptions = { position: longFormOut.position ?? new Map(), gameRules: longFormOut.gameRules, state_global: { ...longFormOut.state_global, specialRights: longFormOut.state_global.specialRights ?? new Set(), }, fullMove: longFormOut.fullMove, }; return { position_name, timestamp: cloudPosition.timestamp, piece_count: variantOptions.position.size, variantOptions, pawnDoublePush: cloudPosition.pawn_double_push, castling: cloudPosition.castling, }; } /** * Converts an EditorSaveState to ICN and uploads it to the cloud. * Does NOT modify local storage or the active position state. * @returns `{ success: true, saves }` on success, `{ success: false }` on failure (errors are toasted internally). */ async function saveCloudState( editorSaveState: EditorSaveState, ): Promise<{ success: true; saves: CloudSaveListRecord[] } | { success: false }> { // Convert variantOptions to ICN const longFormatIn: LongFormatIn = { metadata: {} as MetaData, // Empty metadata object required by ICN converter position: editorSaveState.variantOptions.position, gameRules: editorSaveState.variantOptions.gameRules, state_global: editorSaveState.variantOptions.state_global, fullMove: editorSaveState.variantOptions.fullMove ?? 1, }; let icn: string; try { icn = icnconverter.LongToShort_Format(longFormatIn, { skipPosition: false, compact: true, spaces: false, comments: false, make_new_lines: false, move_numbers: false, }); } catch (err) { console.error('Failed to convert position to ICN:', err); toast.show(translations.editor.failed_to_convert_icn, { error: true }); return { success: false }; } // Compress ICN first const { data: compressedICN, compression: compressionMode } = await compression.compressString(icn); if (compressedICN.length > editorutil.MAX_ICN_LENGTH) { toast.show(translations.editor.too_large_for_cloud, { error: true }); return { success: false }; } let saves: CloudSaveListRecord[]; try { saves = await editorSavesAPI.savePosition( editorSaveState.position_name, editorSaveState.piece_count, editorSaveState.timestamp, compressedICN, compressionMode, editorSaveState.pawnDoublePush, editorSaveState.castling, ); } catch (err) { console.error('Failed to upload position to cloud:', err); const errMsg = err instanceof Error ? err.message : String(err); toast.show(translations.editor.failed_to_upload + ' ' + errMsg, { error: true }); return { success: false }; } toast.show(translations.editor.saved_to_cloud); return { success: true, saves }; } /** * Uploads the currently loaded editor position to the cloud, * saving over whatever is already there. * Reads live game state instead of local storage. */ async function saveCloud(position_name: string): Promise { if (!boardeditor.isPositionDirty()) { toast.show(translations.editor.no_changes); return; } const variantOptions = eactions.getCurrentPositionInformation(false); const { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules(); const timestamp = Date.now(); const piece_count = variantOptions.position.size; const editorSaveState: EditorSaveState = { position_name, timestamp, piece_count, variantOptions, pawnDoublePush, castling, }; const result = await saveCloudState(editorSaveState); if (result.success) { boardeditor.markPositionClean(); eautosave.markPositionDirty(); void eautosave.autosaveCurrentPositionOnce(); } } /** * Downloads a position from the server. * @returns An EditorSaveState on success, undefined on failure. */ async function readCloud(position_name: string): Promise { let cloudPosition: CloudPositionRecord; try { cloudPosition = await editorSavesAPI.getPosition(position_name); } catch (err) { console.error('Failed to load cloud position:', err); const errMsg = err instanceof Error ? err.message : String(err); toast.show(translations.editor.failed_to_load_cloud + ' ' + errMsg, { error: true }); return; } return parseCloudPosition(position_name, cloudPosition); } /** * Deletes a position from the server. * @returns The updated cloud saves list on success, undefined on failure. */ async function deleteCloud(position_name: string): Promise { try { return await editorSavesAPI.deletePosition(position_name); } catch (err) { console.error('Failed to delete cloud position:', err); const errMsg = err instanceof Error ? err.message : String(err); toast.show(translations.editor.failed_to_delete_cloud + ' ' + errMsg, { error: true }); return undefined; } } /** * Transfers a local position to the server and removes the local copy. * @returns The updated cloud saves list on success, undefined on failure. */ async function transferPositionToCloud( position_name: string, ): Promise { const editorSaveState = await esave.readLocal(position_name); if (editorSaveState === undefined) return; const result = await saveCloudState(editorSaveState); if (!result.success) return; // Success! Delete local copy now. await esave.deleteLocal(position_name); if (boardeditor.isActivePosition(position_name, 'local')) boardeditor.setActivePosition({ name: position_name, storage_type: 'cloud', owner: validatorama.getOurUsername()!, }); return result.saves; } /** * Downloads a cloud position to local storage and removes it from the server. * @returns The updated cloud saves list on success, undefined on failure. */ async function removePositionFromCloud( position_name: string, ): Promise { // Read first so that we don't lose the position if the delete succeeds but request doesn't return const editorSaveState = await readCloud(position_name); if (editorSaveState === undefined) return; // Delete from server (returns the updated list) let saves: CloudSaveListRecord[]; try { saves = await editorSavesAPI.deletePosition(position_name); } catch (err) { console.error('Failed to delete cloud position after download:', err); const errMsg = err instanceof Error ? err.message : String(err); toast.show(translations.editor.failed_to_remove_cloud + ' ' + errMsg, { error: true }); return; } // Success! Save locally now. await esave.saveState(editorSaveState); if (boardeditor.isActivePosition(position_name, 'cloud')) boardeditor.setActivePosition({ name: position_name, storage_type: 'local' }); toast.show(translations.editor.saved_locally); return saves; } /** * Fetches all cloud saves for the current user. * Mirrors esave.getAllLocalSaveInfos() for cloud storage. * @returns An array of cloud save records, or an empty array on failure. */ async function getAllCloudSaveInfos(): Promise { try { return await editorSavesAPI.getSavedPositions(); } catch (err) { console.error('Failed to fetch cloud saves:', err); const errMsg = err instanceof Error ? err.message : String(err); toast.show(translations.editor.failed_to_fetch_cloud + ' ' + errMsg, { error: true }); return []; } } // Exports -------------------------------------------------------------------- export default { saveCloud, readCloud, deleteCloud, transferPositionToCloud, removePositionFromCloud, getAllCloudSaveInfos, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/actions/editorSavesAPI.ts ================================================ // src/client/scripts/esm/game/boardeditor/actions/editorSavesAPI.ts /** * Client-side wrappers for the editor saves server API endpoints. */ import type { CompressionMode } from '../../../util/compression'; import validatorama from '../../../util/validatorama'; // Types ---------------------------------------------------------------------------- /** Abridged info returned by getSavedPositions */ export interface CloudSaveListRecord { name: string; piece_count: number; timestamp: number; } /** Full position info returned by getPosition */ export interface CloudPositionRecord { timestamp: number; /** The compressed ICN */ icn: string; /** Compression mode used for the ICN */ compression: CompressionMode; /** undefined represents the indeterminate (third) tristate */ pawn_double_push?: boolean; /** undefined represents the indeterminate (third) tristate */ castling?: boolean; } // Helpers -------------------------------------------------------------------------- async function buildAuthHeaders(): Promise> { const headers: Record = { 'Content-Type': 'application/json', 'is-fetch-request': 'true', }; const token = await validatorama.getAccessToken(); if (token) headers['Authorization'] = `Bearer ${token}`; return headers; } // API Wrappers -------------------------------------------------------------------- /** * GET /api/editor-saves * Returns an array of abridged save records for the logged-in user. * @throws If the request fails or the server returns a non-OK response. */ async function getSavedPositions(): Promise { const headers = await buildAuthHeaders(); const response = await fetch('/api/editor-saves', { method: 'GET', headers, }); if (!response.ok) { const errorData = (await response.json()) as { error?: string }; throw new Error(errorData.error || 'Failed to get saved positions'); } const data = (await response.json()) as { saves: CloudSaveListRecord[] }; return data.saves; } /** * POST /api/editor-saves * Saves a position to the server for the logged-in user. * @throws If the request fails or the server returns a non-OK response. */ async function savePosition( name: string, piece_count: number, timestamp: number, icn: string, compression: string, pawn_double_push?: boolean, castling?: boolean, ): Promise { const headers = await buildAuthHeaders(); const response = await fetch('/api/editor-saves', { method: 'POST', headers, body: JSON.stringify({ name, piece_count, timestamp, icn, compression, pawn_double_push, castling, }), }); if (!response.ok) { const errorData = (await response.json()) as { error?: string }; throw new Error(errorData.error || 'Unknown error'); } const data = (await response.json()) as { success: true; saves: CloudSaveListRecord[] }; return data.saves; } /** * GET /api/editor-saves/:position_name * Returns the full ICN and game rules for a saved position. * @throws If the request fails or the server returns a non-OK response. */ async function getPosition(position_name: string): Promise { const headers = await buildAuthHeaders(); const response = await fetch(`/api/editor-saves/${encodeURIComponent(position_name)}`, { method: 'GET', headers, }); if (!response.ok) { const errorData = (await response.json()) as { error?: string }; throw new Error(errorData.error || 'Unknown error'); } return (await response.json()) as CloudPositionRecord; } /** * DELETE /api/editor-saves/:position_name * Deletes a saved position from the server. * Returns the updated list of abridged save records for the user. * @throws If the request fails or the server returns a non-OK response. */ async function deletePosition(position_name: string): Promise { const headers = await buildAuthHeaders(); const response = await fetch(`/api/editor-saves/${encodeURIComponent(position_name)}`, { method: 'DELETE', headers, }); if (!response.ok) { const errorData = (await response.json()) as { error?: string }; throw new Error(errorData.error || 'Failed to delete position'); } const data = (await response.json()) as { success: true; saves: CloudSaveListRecord[] }; return data.saves; } // Exports ------------------------------------------------------------------------- export default { getSavedPositions, savePosition, getPosition, deletePosition, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/actions/esave.ts ================================================ // src/client/scripts/esm/game/boardeditor/actions/esave.ts /** * Handles the saving of positions in boardeditor */ import type { EditorAbridgedSaveState, EditorSaveState } from '../editortypes'; import toast from '../../gui/toast'; import eactions from './eactions'; import IndexedDB from '../../../util/IndexedDB'; import eautosave from './eautosave'; import egamerules from '../egamerules'; import editortypes from '../editortypes'; import boardeditor from '../boardeditor'; // Constants ---------------------------------------------------------------------- /** Prefix for editor saves in local storage */ const EDITOR_SAVE_PREFIX = 'editor-save-' as const; /** Prefix for editor saveinfo in local storage */ const EDITOR_SAVEINFO_PREFIX = 'editor-saveinfo-' as const; // State -------------------------------------------------------------------- /** Prevent overlapping IndexedDB saves (single-flight): is save ongoing */ let positionSaveInFlight = false; /** Prevent overlapping IndexedDB writes (single-flight): is save pending */ let positionSavePending = false; // Helpers ---------------------------------------------------------------------- /** Returns the IndexedDB key for the full save data of a position. */ function saveKey(position_name: string): string { return `${EDITOR_SAVE_PREFIX}${position_name}`; } /** Returns the IndexedDB key for the abridged save info of a position. */ function saveinfoKey(position_name: string): string { return `${EDITOR_SAVEINFO_PREFIX}${position_name}`; } // Actions ---------------------------------------------------------------------- /** Saves current position under "position_name". */ async function saveLocal(position_name: string): Promise { if (!boardeditor.areInBoardEditor()) return; // Coalesce: if a save is already running, request another and return. if (positionSaveInFlight) { positionSavePending = true; return; } positionSaveInFlight = true; positionSavePending = false; try { const variantOptions = eactions.getCurrentPositionInformation(false); const { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules(); const timestamp = Date.now(); const piece_count = variantOptions.position.size; await saveState({ position_name, timestamp, piece_count, variantOptions, pawnDoublePush, castling, }); } catch (err) { // Don't crash the editor over failed save console.error('Failed to save board editor position:', err); } finally { positionSaveInFlight = false; // If something changed while saving, immediately save again (latest wins). if (positionSavePending) { positionSavePending = false; await saveLocal(position_name); } else { boardeditor.markPositionClean(); eautosave.markPositionDirty(); void eautosave.autosaveCurrentPositionOnce(); toast.show(translations.editor.saved_in_browser); } } } /** * Persists a fully constructed SaveState to IndexedDB. * Writes both the full save (for loading) and the abridged save (for display). */ async function saveState(editorSaveState: EditorSaveState): Promise { const { position_name, timestamp, piece_count } = editorSaveState; await Promise.all([ // Save full info for loading purposes IndexedDB.saveItem(saveKey(position_name), editorSaveState), // Save abridged info for display purposes IndexedDB.saveItem(saveinfoKey(position_name), { position_name, timestamp, piece_count, }), ]); } /** Deletes a locally saved position from IndexedDB. */ async function deleteLocal(position_name: string): Promise { await Promise.all([ IndexedDB.deleteItem(saveinfoKey(position_name)), IndexedDB.deleteItem(saveKey(position_name)), ]); } /** Returns true if a local save exists for the given position name. */ async function localSaveExists(position_name: string): Promise { const raw = await IndexedDB.loadItem(saveinfoKey(position_name)); return editortypes.AbridgedSaveStateSchema.safeParse(raw).success; } /** * Returns an array of all abridged save states stored locally. * Deletes and logs any corrupted entries. */ async function getAllLocalSaveInfos(): Promise { const saveinfo_keys = (await IndexedDB.getAllKeys()).filter((key) => key.startsWith(EDITOR_SAVEINFO_PREFIX), ); const results = await Promise.all( saveinfo_keys.map(async (key) => { const raw = await IndexedDB.loadItem(key); const parsed = editortypes.AbridgedSaveStateSchema.safeParse(raw); if (!parsed.success) { const position_name = key.slice(EDITOR_SAVEINFO_PREFIX.length); console.error( `Corrupted local save "${position_name}" found, deleting it. Error: ${parsed.error}`, ); await deleteLocal(position_name); return; } return parsed.data; }), ); return results.filter((x) => x !== undefined); } /** * Reads a locally saved position from IndexedDB. * @returns An EditorSaveState on success, undefined if not found or corrupted. */ async function readLocal(position_name: string): Promise { const editorSaveStateRaw = await IndexedDB.loadItem(saveKey(position_name)); const editorSaveStateParsed = editortypes.SaveStateSchema.safeParse(editorSaveStateRaw); if (!editorSaveStateParsed.success) { console.error( `Corrupted local save "${position_name}" found. Error: ${editorSaveStateParsed.error}`, ); toast.show(translations.editor.position_corrupted, { error: true }); return; } return editorSaveStateParsed.data; } // Exports -------------------------------------------------------------------- export default { saveLocal, saveState, deleteLocal, readLocal, localSaveExists, getAllLocalSaveInfos, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/boardeditor.ts ================================================ // src/client/scripts/esm/game/boardeditor/boardeditor.ts /** * Core manager for the Board Editor. * * Handles the lifecycle (open/close), dirty/clean state, * active position tracking, and the main update/render loop. */ import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import gameslot from '../chess/gameslot.js'; import eautosave from './actions/eautosave.js'; import egamerules from './egamerules.js'; import eclipboard from './eclipboard.js'; import drawingtool from './tools/drawingtool.js'; import editortypes from './editortypes.js'; import edithistory from './edithistory.js'; import etoolmanager from './tools/etoolmanager.js'; import selectiontool from './tools/selection/selectiontool.js'; import stransformations from './tools/selection/stransformations.js'; import guipositionheader from '../gui/boardeditor/guipositionheader.js'; // Types ------------------------------------------------------------------------ /** The active position loaded in the board editor, if any. */ export type ActivePosition = | { name: string; storage_type: 'local' } | { name: string; storage_type: 'cloud'; owner: string }; /** Whether a position is stored locally (IndexedDB) or on the server (cloud) */ export type StorageType = (typeof editortypes)['STORAGE_TYPES'][number]; // State ------------------------------------------------------------------------- /** Whether we are currently using the editor. */ let inBoardEditor = false; /** The active position, if any, as displayed on editor bar and used for "Save" button by default */ let active_position: ActivePosition | undefined = undefined; /** Whether the current board position has unsaved changes. */ let positionDirty = false; // Initialization ------------------------------------------------------------------------ /** * Initializes the board editor. * Should be called AFTER loading the game logically. * May optionally be supplied with custom game rules. */ async function initBoardEditor( /** Whether the position has unsaved changes. */ dirty: boolean, variantOptions?: VariantOptions, pawnDoublePush?: boolean, castling?: boolean, ): Promise { inBoardEditor = true; if (dirty) markPositionDirty(); else markPositionClean(); etoolmanager.setTool('normal'); drawingtool.init(); let initial_pawnDoublePush: boolean | undefined; let initial_castling: boolean | undefined; if (variantOptions === undefined) { const gamefile = gameslot.getGamefile()!; // Set gamerulesGUIinfo object according to loaded Classical variant const gameRules = jsutil.deepCopyObject(gamefile.basegame.gameRules); gameRules.winConditions[p.WHITE] = [icnconverter.default_win_condition]; gameRules.winConditions[p.BLACK] = [icnconverter.default_win_condition]; const globalState = jsutil.deepCopyObject(gamefile.boardsim.state.global); initial_pawnDoublePush = true; initial_castling = true; egamerules.setGamerulesGUIinfo( gameRules, globalState, initial_pawnDoublePush, initial_castling, ); } else { // Set game rules according to provided variantOptions object initial_pawnDoublePush = pawnDoublePush; initial_castling = castling; egamerules.setGamerulesGUIinfo( variantOptions.gameRules, variantOptions.state_global, pawnDoublePush, castling, ); } edithistory.init(initial_pawnDoublePush, initial_castling); // Erase the `inCheck` and `checks` state of the gamefile, which were auto-calculated in the constructor. // Prevents check highlights from rendering when opening the board editor. const gamefile = gameslot.getGamefile()!; gamefile.boardsim.state.local.inCheck = false; gamefile.boardsim.state.local.checks = []; // Also set gameConclusion to undefined. Otherwise, starting from a position that // would have otherwise been checkmate/stalemate will prevent us from selecting pieces. gamefileutility.setConclusion(gamefile.basegame, undefined); eclipboard.addEventListeners(); eautosave.startPositionAutosave(); } /** Closes the board editor and resets all state. */ function closeBoardEditor(): void { // Perform last autosave eautosave.markPositionDirty(); void eautosave.autosaveCurrentPositionOnce(); eautosave.stopPositionAutosave(); // Reset state inBoardEditor = false; edithistory.reset(); etoolmanager.reset(); drawingtool.onCloseEditor(); selectiontool.resetState(); stransformations.resetState(); // Drops reference to clipboard eclipboard.removeEventListeners(); } // Update & Render ------------------------------------------------------------- /** Called every frame while the board editor is open. */ function update(): void { if (!inBoardEditor) return; etoolmanager.testShortcuts(); // Handle starting and ending the drawing state const currentTool = etoolmanager.getTool(); if (drawingtool.isToolADrawingTool(currentTool)) drawingtool.update(currentTool); // Update selection tool, if that is active else if (currentTool === 'selection-tool') selectiontool.update(); } /** Renders any graphics of the active tool, if we are in the board editor. */ function render(): void { if (!inBoardEditor) return; // Render selection-tool graphics, if that is active if (etoolmanager.getTool() === 'selection-tool') selectiontool.render(); } // Utility -------------------------------------------------------------------- /** Returns true if the board editor is currently open. */ function areInBoardEditor(): boolean { return inBoardEditor; } /** Returns true if the current board position has unsaved changes. */ function isPositionDirty(): boolean { return positionDirty; } /** * Marks the current board position as having unsaved changes, * and notifies eautosave to schedule a background autosave. */ function markPositionDirty(): void { // console.error('Position marked dirty'); positionDirty = true; guipositionheader.updateDirtyIndicator(true); eautosave.markPositionDirty(); } /** Marks the current board position as clean (saved). */ function markPositionClean(): void { // console.error('Position marked clean'); positionDirty = false; guipositionheader.updateDirtyIndicator(false); } /** Returns the active position, if any. */ function getActivePosition(): ActivePosition | undefined { return active_position; } /** Returns true if the provided position name and storage type match the current active position. */ function isActivePosition(name: string, storage_type: StorageType): boolean { return ( active_position !== undefined && active_position.name === name && active_position.storage_type === storage_type ); } /** Sets the currently active position and flushes the autosave. */ function setActivePosition(new_position: ActivePosition): void { active_position = new_position; guipositionheader.updateActivePositionElement(new_position.name); flushActivePositionToAutosave(); } /** Clears the active position and marks the position as dirty. */ function clearActivePosition(): void { active_position = undefined; markPositionDirty(); guipositionheader.updateActivePositionElement(undefined); flushActivePositionToAutosave(); } /** * Immediately flushes the autosave so a page refresh immediately * after a save/delete operation reflects the current active position. */ function flushActivePositionToAutosave(): void { if (gameslot.getGamefile() === undefined) return; // Some callers run before the gamefile exists eautosave.markPositionDirty(); void eautosave.autosaveCurrentPositionOnce(); } // Exports -------------------------------------------------------------------- export default { // State areInBoardEditor, // Initialization initBoardEditor, closeBoardEditor, // Update & Render update, render, // Dirty State isPositionDirty, markPositionDirty, markPositionClean, // Active Position getActivePosition, isActivePosition, setActivePosition, clearActivePosition, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/eclipboard.ts ================================================ // src/client/scripts/esm/game/boardeditor/eclipboard.ts /** * Clipboard handlers for the Board Editor. * * Manages copy, cut, and paste operations, delegating to the * selection tool transformations or the game notation actions. */ import toast from '../gui/toast.js'; import gameslot from '../chess/gameslot.js'; import eactions from './actions/eactions.js'; import gameloader from '../chess/gameloader.js'; import etoolmanager from './tools/etoolmanager.js'; import selectiontool from './tools/selection/selectiontool.js'; import stransformations from './tools/selection/stransformations.js'; // Event Listeners ------------------------------------------------------------ /** Registers the copy/cut/paste event listeners on the document. */ function addEventListeners(): void { document.addEventListener('copy', onCopy); document.addEventListener('cut', onCut); document.addEventListener('paste', onPaste); document.addEventListener('copy-game', onCopyGame); document.addEventListener('paste-game', onPasteGame); } /** Removes the copy/cut/paste event listeners from the document. */ function removeEventListeners(): void { document.removeEventListener('copy', onCopy); document.removeEventListener('cut', onCut); document.removeEventListener('paste', onPaste); document.removeEventListener('copy-game', onCopyGame); document.removeEventListener('paste-game', onPasteGame); } // Handlers ------------------------------------------------------------------- /** Custom Board Editor handler for the Copy event. */ function onCopy(): void { if (document.activeElement instanceof HTMLInputElement) return; // Don't copy if the user is typing in an input field if (window.getSelection()?.toString()) return; // Don't copy if the user has text selected in the UI if (etoolmanager.getTool() !== 'selection-tool') { // Copy game notation document.dispatchEvent(new Event('copy-game')); } else if (selectiontool.isExistingSelection()) { // Copy current selection const gamefile = gameslot.getGamefile()!; const selectionBox = selectiontool.getSelectionIntBox()!; stransformations.Copy(gamefile, selectionBox); } } /** Board Editor handler for the Cut event. */ function onCut(): void { if (document.activeElement instanceof HTMLInputElement) return; // Don't cut if the user is typing in an input field if (window.getSelection()?.toString()) return; // Don't cut if the user has text selected in the UI if (etoolmanager.getTool() !== 'selection-tool' || !selectiontool.isExistingSelection()) return; // Cut current selection const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox = selectiontool.getSelectionIntBox()!; stransformations.Copy(gamefile, selectionBox); stransformations.Delete(gamefile, mesh, selectionBox); } /** Custom Board Editor handler for the Paste event. */ function onPaste(): void { if (document.activeElement instanceof HTMLInputElement) return; // Don't paste if the user is typing in an input field if (gameloader.areWeLoadingGame()) return toast.showPleaseWaitForTask(); if (etoolmanager.getTool() !== 'selection-tool') { // Paste game notation document.dispatchEvent(new Event('paste-game')); } else if (selectiontool.isExistingSelection()) { // Paste clipboard at current selection const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox = selectiontool.getSelectionIntBox()!; stransformations.Paste(gamefile, mesh, selectionBox); } } /** Board Editor handler for the 'copy-game' custom event. Copies the full position as game notation. */ function onCopyGame(): void { eactions.copy(); } /** Board Editor handler for the 'paste-game' custom event. Pastes game notation from the clipboard. */ function onPasteGame(): void { eactions.paste(); } // Exports -------------------------------------------------------------------- export default { addEventListeners, removeEventListeners, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/edithistory.ts ================================================ // src/client/scripts/esm/game/boardeditor/edithistory.ts /** * Edit History for the Board Editor. * * Manages the undo/redo stack, running edits logically and graphically, * and queuing individual piece/special-rights changes into an Edit object. */ import type { Edit } from '../../../../../shared/chess/logic/movepiece.js'; import type { Mesh } from '../rendering/piecemodels.js'; import type { Piece } from '../../../../../shared/chess/util/boardutil.js'; import type { Coords } from '../../../../../shared/chess/util/coordutil.js'; import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import state from '../../../../../shared/chess/logic/state.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import movepiece from '../../../../../shared/chess/logic/movepiece.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import boardchanges from '../../../../../shared/chess/logic/boardchanges.js'; import arrows from '../rendering/arrows/arrows.js'; import gameslot from '../chess/gameslot.js'; import miniimage from '../rendering/miniimage.js'; import selection from '../chess/selection.js'; import egamerules from './egamerules.js'; import drawingtool from './tools/drawingtool.js'; import { GameBus } from '../GameBus.js'; import boardeditor from './boardeditor.js'; import movesequence from '../chess/movesequence.js'; import guinavigation from '../gui/guinavigation.js'; // Types ---------------------------------------------------------------------- /** * An edit that also keeps track of the state of certain position-dependent game rules AFTER the edit is made. * Used exclusively for game history purposes. */ interface EditWithRules extends Edit { /** The state of the pawn double push gamerules checkbox AFTER this edit was made. */ pawnDoublePush?: boolean; /** The state of the castling gamerules checkbox AFTER this edit was made. */ castling?: boolean; } // Constants ------------------------------------------------------------------ /** * The maximum allowed summed changes in the edit history before oldest edits are pruned. * This is to prevent excessive memory usage crashing the browser. * * Naviary's machine got to 26 million changes before slowing, then crashing. * The tab was using roughly 5 GB of memory at that point. * I guess maybe a max of 8 million could be safe on most machines? */ const EDIT_HISTORY_MAX_CHANGES = 8_000_000; // State ---------------------------------------------------------------------- /** The list of all edits the user has made. */ let edits: Array | undefined; let indexOfThisEdit: number | undefined; /** The value of the pawnDoublePush game rule in the initial zeroth edit */ let initial_pawnDoublePush: boolean | undefined = true; /** The value of the castling game rule in the initial zeroth edit */ let initial_castling: boolean | undefined = true; // Initialization ------------------------------------------------------------- /** Initializes the edit history state when the board editor is opened. */ function init(pawnDoublePush: boolean | undefined, castling: boolean | undefined): void { edits = []; indexOfThisEdit = 0; initial_pawnDoublePush = pawnDoublePush; initial_castling = castling; guinavigation.update_EditButtons(); } /** Resets the edit history state when the board editor is closed. */ function reset(): void { edits = undefined; indexOfThisEdit = undefined; } // Running Edits -------------------------------------------------------------- /** Runs both logical and graphical changes. */ function runEdit(gamefile: FullGame, mesh: Mesh, edit: Edit, forward: boolean = true): void { // Pieces must be unselected before they are modified selection.unselectPiece(); // Run logical changes movepiece.applyEdit(gamefile, edit, forward, true); GameBus.dispatch('physical-move'); // Run graphical changes movesequence.runMeshChanges(gamefile.boardsim, mesh, edit, forward); // If the piece count is now high enough, disable icons and arrows. const pieceCount = boardutil.getPieceCountOfGame(gamefile.boardsim.pieces); if (pieceCount > miniimage.pieceCountToDisableMiniImages || pieceCount > arrows.MAX_PIECES) { miniimage.disable(); arrows.setMode(0); } // Prune the oldest edits in the history if we exceed the cap, to help prevent memory crashes. const totalChanges: number = edits!.reduce((sum, edit) => sum + edit.changes.length, 0); // console.log("Total changes in edit history: " + totalChanges); if (totalChanges > EDIT_HISTORY_MAX_CHANGES) { let changesToRemove = totalChanges - EDIT_HISTORY_MAX_CHANGES; while (changesToRemove > 0 && edits!.length > 0) { const oldestEdit = edits!.shift()!; changesToRemove -= oldestEdit.changes.length; indexOfThisEdit!--; } // console.log("Pruned oldest edits."); } } /** Appends the given edit to the history stack, discarding any future (redo) edits. */ function addEditToHistory(edit: Edit): void { if ( edit.changes.length === 0 && edit.state.local.length === 0 && edit.state.global.length === 0 ) return; edits!.length = indexOfThisEdit!; // Truncate any "redo" edits, that timeline is being erased. const { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules(); const editWithRules: EditWithRules = { ...edit, pawnDoublePush, castling, }; edits!.push(editWithRules); indexOfThisEdit!++; guinavigation.update_EditButtons(); boardeditor.markPositionDirty(); } /** Undoes the most recent edit. */ function undo(): void { if (!boardeditor.areInBoardEditor()) throw Error("Cannot undo edit when we're not using the board editor."); if (drawingtool.isEditInProgress()) return; // Do not allow undoing or redoing while currently making an edit if (indexOfThisEdit! <= 0) return; const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; indexOfThisEdit!--; const thisEdit = edits![indexOfThisEdit!]!; runEdit(gamefile, mesh, thisEdit, false); // Restore position dependent game rules to what they were before this edit if (indexOfThisEdit! !== 0) { const previousEdit = edits![indexOfThisEdit! - 1]!; egamerules.setPositionDependentGameRules({ pawnDoublePush: previousEdit.pawnDoublePush, castling: previousEdit.castling, }); } else { // Reset to initial state egamerules.setPositionDependentGameRules({ pawnDoublePush: initial_pawnDoublePush, castling: initial_castling, }); } guinavigation.update_EditButtons(); boardeditor.markPositionDirty(); } /** Redoes the next edit in the history. */ function redo(): void { if (!boardeditor.areInBoardEditor()) throw Error("Cannot redo edit when we're not using the board editor."); if (drawingtool.isEditInProgress()) return; // Do not allow undoing or redoing while currently making an edit if (indexOfThisEdit! >= edits!.length) return; const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const thisEdit = edits![indexOfThisEdit!]!; runEdit(gamefile, mesh, thisEdit, true); // Update position dependent game rules to what they are after this edit egamerules.setPositionDependentGameRules({ pawnDoublePush: thisEdit.pawnDoublePush, castling: thisEdit.castling, }); indexOfThisEdit!++; guinavigation.update_EditButtons(); boardeditor.markPositionDirty(); } /** Returns true if there is an edit to undo. */ function canUndo(): boolean { // comparing undefined always returns false return indexOfThisEdit !== undefined && indexOfThisEdit > 0; } /** Returns true if there is an edit to redo. */ function canRedo(): boolean { // comparing undefined always returns false return indexOfThisEdit !== undefined && edits !== undefined && indexOfThisEdit < edits.length; } // Queuing Edits -------------------------------------------------------------- /** Queues the deletion of a piece, including its special rights, if present, to the edit changes. */ function queueRemovePiece(gamefile: FullGame, edit: Edit, piece: Piece): void { boardchanges.queueDeletePiece(edit.changes, false, piece); queueSpecialRights(gamefile, edit, piece.coords, false); } /** * Queues the addition of a piece, including its special rights, if specified, to the edit changes. * If specialrights is left undefined, it is set according to the game rules */ function queueAddPiece( gamefile: FullGame, edit: Edit, coords: Coords, type: number, specialright: boolean, ): void { const piece: Piece = { type, coords, index: -1 }; boardchanges.queueAddPiece(edit.changes, piece); if (specialright) queueSpecialRights(gamefile, edit, coords, specialright); } /** Queues the addition/removal of a specialright at the specified coordinates. */ function queueSpecialRights(gamefile: FullGame, edit: Edit, coords: Coords, add: boolean): void { const coordsKey = coordutil.getKeyFromCoords(coords); const current = gamefile.boardsim.state.global.specialRights.has(coordsKey); state.createSpecialRightsState(edit, coordsKey, current, add); } // Exports -------------------------------------------------------------------- export default { // Initialization init, reset, // Running Edits runEdit, addEditToHistory, undo, redo, // Querying canUndo, canRedo, // Queuing Edits queueAddPiece, queueRemovePiece, queueSpecialRights, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/editortypes.ts ================================================ // src/client/scripts/esm/game/boardeditor/editortypes.ts /** * All TypeScript types, constants, and Zod schemas for the board editor save system. * * Centralized here to avoid circular-dependency issues — this file only uses * type-only imports from other modules, so it can never be part of a circular * dependency chain at runtime. */ import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import type { ActivePosition } from './boardeditor.js'; import * as z from 'zod'; // Constants ------------------------------------------------------------------ /** All valid storage locations for a saved editor position */ const STORAGE_TYPES = ['local', 'cloud'] as const; // Types ------------------------------------------------------------------ /** Minimal information about a saved position — used for display in the saved positions list */ export interface EditorAbridgedSaveState { position_name: string; timestamp: number; piece_count: number; } /** Position data shared between normal saves and autosaves */ export interface EditorPositionData { timestamp: number; piece_count: number; variantOptions: VariantOptions; pawnDoublePush?: boolean; castling?: boolean; } /** Complete information about a saved position (local or cloud) */ export interface EditorSaveState extends EditorPositionData { position_name: string; } /** * Complete save state as written by the autosave. * active_position is optional because the user may not have a named/saved position open. */ export interface EditorAutosaveState extends EditorPositionData { active_position?: ActivePosition; /** Whether the position has unsaved changes. */ dirty: boolean; } // Zod Schemas -------------------------------------------------------------------- /** Shared Zod fields for EditorSaveState and EditorAutosaveState */ const positionDataFields = { timestamp: z.number(), piece_count: z.number().int('Piece count must be an integer'), variantOptions: z .object() .loose() .transform((v) => v as unknown as VariantOptions), // Workaround for lack of VariantOptions schema pawnDoublePush: z.boolean().optional(), castling: z.boolean().optional(), }; /** Shared position_name schema */ const positionNameSchema = z.string().min(1, 'Position name is required'); /** Schema for validating an AbridgedSaveState */ const AbridgedSaveStateSchema = z.strictObject({ position_name: positionNameSchema, timestamp: positionDataFields.timestamp, piece_count: positionDataFields.piece_count, }); /** Schema for validating a SaveState */ const SaveStateSchema = z.strictObject({ position_name: positionNameSchema, ...positionDataFields, }); /** Schema for validating an AutosaveState */ const AutosaveStateSchema = z.strictObject({ active_position: z .union([ z.object({ name: z.string(), storage_type: z.literal('local') }), z.object({ name: z.string(), storage_type: z.literal('cloud'), owner: z.string() }), ]) .optional(), dirty: z.boolean(), ...positionDataFields, }); // Exports -------------------------------------------------------------------- export default { STORAGE_TYPES, positionDataFields, AbridgedSaveStateSchema, SaveStateSchema, AutosaveStateSchema, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/egamerules.ts ================================================ // src/client/scripts/esm/game/boardeditor/egamerules.ts /** * Editor Game Rules * * Manages the game rules of the board editor position. */ import type { Edit } from '../../../../../shared/chess/logic/movepiece'; import type { Piece } from '../../../../../shared/chess/util/boardutil'; import type { Coords } from '../../../../../shared/chess/util/coordutil'; import type { GameRules } from '../../../../../shared/chess/util/gamerules'; import type { UnboundedRectangle } from '../../../../../shared/util/math/bounds'; import type { RawType, PlayerGroup } from '../../../../../shared/chess/util/typeutil'; import type { GameruleWinCondition } from '../../../../../shared/chess/util/winconutil'; import boardutil from '../../../../../shared/chess/util/boardutil'; import icnconverter from '../../../../../shared/chess/logic/icn/icnconverter'; import { EnPassant, GlobalGameState } from '../../../../../shared/chess/logic/state'; import typeutil, { players as p, rawTypes as r } from '../../../../../shared/chess/util/typeutil'; import gameslot from '../chess/gameslot'; import boardeditor from './boardeditor'; import edithistory from './edithistory'; import guigamerules from '../gui/boardeditor/actions/guigamerules'; // Types ------------------------------------------------------------------------- /** Type encoding information for the game rules object of the editor position */ interface GameRulesGUIinfo { playerToMove: 'white' | 'black'; enPassant?: { x: bigint; y: bigint; }; moveRule?: { current: number; max: number; }; promotionRanks?: { white?: bigint[]; black?: bigint[]; }; promotionsAllowed?: RawType[]; pawnDoublePush?: boolean; castling?: boolean; winConditions: GameruleWinCondition[]; worldBorder?: UnboundedRectangle; } // Constants ------------------------------------------------------------- // Game rule relevant piece types /** All piece types affected by the pawnDoublePush rule */ const pawnDoublePushTypes: RawType[] = [r.PAWN]; /** All piece types affected by the castling rule. These pieces are the only pieces allowed to castle under the castling rule. */ const castlingTypes: RawType[] = [r.ROOK, r.KING, r.ROYALCENTAUR]; // State ------------------------------------------------------------- /** Virtual game rules object for the position */ let gamerulesGUIinfo: GameRulesGUIinfo = { playerToMove: 'white', winConditions: [icnconverter.default_win_condition], }; // Getting & Setting ------------------------------------------------------------- function getPlayerToMove(): 'white' | 'black' { return gamerulesGUIinfo.playerToMove; } function getCurrentGamerulesAndState(): { gameRules: GameRules; moveRuleState: number | undefined; enpassantcoords: Coords | undefined; } { // Construct gameRules // prettier-ignore const turnOrder = gamerulesGUIinfo.playerToMove === "white" ? [p.WHITE, p.BLACK] : gamerulesGUIinfo.playerToMove === "black" ? [p.BLACK, p.WHITE] : (() => { throw Error("Invalid player to move"); })(); // Future protection const moveRule = gamerulesGUIinfo.moveRule !== undefined ? gamerulesGUIinfo.moveRule.max : undefined; const winConditions = { [p.WHITE]: gamerulesGUIinfo.winConditions, [p.BLACK]: gamerulesGUIinfo.winConditions, }; let promotionRanks: PlayerGroup | undefined = undefined; let promotionsAllowed: PlayerGroup | undefined = undefined; if ( gamerulesGUIinfo.promotionsAllowed !== undefined && gamerulesGUIinfo.promotionRanks !== undefined ) { promotionsAllowed = {}; promotionRanks = {}; if ( gamerulesGUIinfo.promotionRanks.white !== undefined && gamerulesGUIinfo.promotionRanks.white.length !== 0 ) { promotionRanks[p.WHITE] = gamerulesGUIinfo.promotionRanks.white; promotionsAllowed[p.WHITE] = gamerulesGUIinfo.promotionsAllowed; } if ( gamerulesGUIinfo.promotionRanks.black !== undefined && gamerulesGUIinfo.promotionRanks.black.length !== 0 ) { promotionRanks[p.BLACK] = gamerulesGUIinfo.promotionRanks.black; promotionsAllowed[p.BLACK] = gamerulesGUIinfo.promotionsAllowed; } } const gameRules: GameRules = { turnOrder, moveRule, promotionRanks, promotionsAllowed, winConditions, worldBorder: gamerulesGUIinfo.worldBorder, }; const moveRuleState = gamerulesGUIinfo.moveRule !== undefined ? gamerulesGUIinfo.moveRule.current : undefined; // prettier-ignore const enpassantcoords: Coords | undefined = gamerulesGUIinfo.enPassant !== undefined ? [gamerulesGUIinfo.enPassant.x, gamerulesGUIinfo.enPassant.y] : undefined; return { gameRules, moveRuleState, enpassantcoords, }; } /** * Update the game rules object keeping track of all current game rules by using new gameRules and state_global. * Optionally, pawnDoublePush and castling can also be passed into this function, if they should take values other than undefined. * Optionally, an Edit object can be passed to this function if the board state should be updated */ function setGamerulesGUIinfo( gameRules: GameRules, state_global: Partial, pawnDoublePush: boolean | undefined, castling: boolean | undefined, ): void { const firstPlayer = gameRules.turnOrder[0]; // prettier-ignore gamerulesGUIinfo.playerToMove = firstPlayer === p.WHITE ? 'white' : firstPlayer === p.BLACK ? 'black' : (() => { throw new Error('Invalid first player'); })(); // Future protection if (state_global.enpassant !== undefined) { gamerulesGUIinfo.enPassant = { x: state_global.enpassant.square[0], y: state_global.enpassant.square[1], }; } else { gamerulesGUIinfo.enPassant = undefined; } if (gameRules.moveRule !== undefined) { gamerulesGUIinfo.moveRule = { current: state_global.moveRuleState || 0, max: gameRules.moveRule, }; } else { gamerulesGUIinfo.moveRule = undefined; } if (gameRules.promotionRanks !== undefined) { gamerulesGUIinfo.promotionRanks = { white: gameRules.promotionRanks[p.WHITE], black: gameRules.promotionRanks[p.BLACK], }; } else { gamerulesGUIinfo.promotionRanks = undefined; } if (gameRules.promotionsAllowed !== undefined) { gamerulesGUIinfo.promotionsAllowed = [ ...new Set([ ...(gameRules.promotionsAllowed[p.WHITE] || []), ...(gameRules.promotionsAllowed[p.BLACK] || []), ]), ]; if (gamerulesGUIinfo.promotionsAllowed.length === 0) gamerulesGUIinfo.promotionsAllowed = undefined; } else { gamerulesGUIinfo.promotionsAllowed = undefined; } gamerulesGUIinfo.winConditions = [ ...new Set([ ...(gameRules.winConditions[p.WHITE] || [icnconverter.default_win_condition]), ...(gameRules.winConditions[p.BLACK] || [icnconverter.default_win_condition]), ]), ]; // Update pawn double push specialrights of position, if necessary gamerulesGUIinfo.pawnDoublePush = pawnDoublePush; // Update castling with rooks specialrights of position, if necessary gamerulesGUIinfo.castling = castling; // Read World Border from the gamefile gamerulesGUIinfo.worldBorder = gameRules.worldBorder; // Update gamefile properties for rendering purposes and correct legal move calculation // prettier-ignore const enpassantSquare: Coords | undefined = gamerulesGUIinfo.enPassant !== undefined ? [gamerulesGUIinfo.enPassant.x, gamerulesGUIinfo.enPassant.y] : undefined; updateGamefileProperties( enpassantSquare, gamerulesGUIinfo.promotionRanks, gamerulesGUIinfo.playerToMove, gamerulesGUIinfo.worldBorder, ); guigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI } /** * This gets called when undoing or redoing moves, to forget the pawnDoublePush and castling entries of the gamerules * since we do not keep track of the checkbox state between edits. * This also gets called when resetting the position. * @param value - The value to set pawnDoublePush and castling to, or undefined to set them to indeterminate. */ function setPositionDependentGameRules( options: { pawnDoublePush?: boolean | undefined; castling?: boolean | undefined } = {}, ): void { gamerulesGUIinfo.pawnDoublePush = options.pawnDoublePush; gamerulesGUIinfo.castling = options.castling; guigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI } function getPositionDependentGameRules(): { pawnDoublePush: boolean | undefined; castling: boolean | undefined; } { return { pawnDoublePush: gamerulesGUIinfo.pawnDoublePush, castling: gamerulesGUIinfo.castling, }; } /** Update the game rules object keeping track of all current game rules by using changes from guiboardeditor */ function updateGamerulesGUIinfo(new_gamerulesGUIinfo: GameRulesGUIinfo): void { gamerulesGUIinfo = new_gamerulesGUIinfo; } /** * When a special rights change gets queued, this function gets called * to potentially set gamerulesGUIinfo.pawnDoublePush and gamerulesGUIinfo.castling to indeterminate * @param type - The piece type whose special right is being changed * @param future - The future value of the special right being changed */ function updateGamerulesUponQueueToggleSpecialRight(type: number, future: boolean): void { if (gamerulesGUIinfo.pawnDoublePush !== undefined) { const rawtype = typeutil.getRawType(type); if (pawnDoublePushTypes.includes(rawtype) && gamerulesGUIinfo.pawnDoublePush !== future) gamerulesGUIinfo.pawnDoublePush = undefined; } if (gamerulesGUIinfo.castling !== undefined) { const rawtype = typeutil.getRawType(type); if (castlingTypes.includes(rawtype)) { if (gamerulesGUIinfo.castling !== future) gamerulesGUIinfo.castling = undefined; } else if (!pawnDoublePushTypes.includes(rawtype)) { if (future) gamerulesGUIinfo.castling = undefined; } } guigamerules.setGameRules(gamerulesGUIinfo); // Update the game rules GUI } // Updating Special Rights ------------------------------------------------------------- /** Gives or removes all special rights of pawns according to the value of pawnDoublePush. */ function queueToggleGlobalPawnDoublePush(pawnDoublePush: boolean, edit: Edit): void { const gamefile = gameslot.getGamefile()!; const pieces = gamefile.boardsim.pieces; for (const idx of pieces.coords.values()) { const piece: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!; if (pawnDoublePushTypes.includes(typeutil.getRawType(piece.type))) edithistory.queueSpecialRights(gamefile, edit, piece.coords, pawnDoublePush); } } /** Gives or removes all special rights of rooks and jumping royals according to the value of castling. */ function queueToggleGlobalCastlingWithRooks(castling: boolean, edit: Edit): void { if (!boardeditor.areInBoardEditor()) return; const gamefile = gameslot.getGamefile()!; const pieces = gamefile.boardsim.pieces; for (const idx of pieces.coords.values()) { const piece: Piece = boardutil.getDefinedPieceFromIdx(pieces, idx)!; const rawType = typeutil.getRawType(piece.type); if (castlingTypes.includes(rawType)) edithistory.queueSpecialRights(gamefile, edit, piece.coords, castling); else if (!pawnDoublePushTypes.includes(rawType)) edithistory.queueSpecialRights(gamefile, edit, piece.coords, false); } } // Updating Gamefile State ------------------------------------------------------------- /** * Updates the en passant square, promotion lines, and turn order in the current gamefile. * Needed for display purposes and correct legal move calculation. */ function updateGamefileProperties( enpassantCoords: Coords | undefined, promotionRanks: { white?: bigint[]; black?: bigint[] } | undefined, playerToMove: 'white' | 'black', worldBorder: UnboundedRectangle | undefined, ): void { const gamefile = gameslot.getGamefile()!; // Update en passant state for rendering purposes, and correct enpassant legality calculation if (enpassantCoords === undefined) { gamefile.boardsim.state.global.enpassant = undefined; } else { // prettier-ignore const pawn: Coords = playerToMove === 'white' ? [enpassantCoords[0], enpassantCoords[1] - 1n] : playerToMove === 'black' ? [enpassantCoords[0], enpassantCoords[1] + 1n] : (() => { throw new Error("Invalid player to move"); })(); // Future protection const enpassant: EnPassant = { square: enpassantCoords, pawn }; gamefile.boardsim.state.global.enpassant = enpassant; } // Update the promotionlines in the gamefile for rendering purposes if (promotionRanks === undefined) { gamefile.basegame.gameRules.promotionRanks = undefined; } else { gamefile.basegame.gameRules.promotionRanks = {}; gamefile.basegame.gameRules.promotionRanks[p.WHITE] = promotionRanks.white; gamefile.basegame.gameRules.promotionRanks[p.BLACK] = promotionRanks.black; } // Update turn order so in the Normal tool, pawns correctly show enpassant as legal. // prettier-ignore gamefile.basegame.gameRules.turnOrder = playerToMove === 'white' ? [p.WHITE, p.BLACK] : playerToMove === 'black' ? [p.BLACK, p.WHITE] : (() => { throw new Error("Invalid player to move"); })(); // Future protection // Update whosTurn as well gamefile.basegame.whosTurn = gamefile.basegame.gameRules.turnOrder[0]!; // Update World Border gamefile.basegame.gameRules.worldBorder = worldBorder; } // Exports ------------------------------------------------------------- export type { GameRulesGUIinfo }; export default { pawnDoublePushTypes, castlingTypes, // Getting & Setting getPlayerToMove, getCurrentGamerulesAndState, setGamerulesGUIinfo, setPositionDependentGameRules, getPositionDependentGameRules, updateGamerulesGUIinfo, updateGamerulesUponQueueToggleSpecialRight, // Updating Special Rights queueToggleGlobalPawnDoublePush, queueToggleGlobalCastlingWithRooks, // Updating Gamefile State updateGamefileProperties, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/drawingtool.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/drawingtool.ts /** * Editor Drawing Tool * * Manages all drawing tools */ import type { Edit } from '../../../../../../shared/chess/logic/movepiece'; import type { Tool } from './etoolmanager'; import type { FullGame } from '../../../../../../shared/chess/logic/gamefile'; import state from '../../../../../../shared/chess/logic/state'; import bounds from '../../../../../../shared/util/math/bounds'; import boardutil, { Piece } from '../../../../../../shared/chess/util/boardutil'; import coordutil, { Coords } from '../../../../../../shared/chess/util/coordutil'; import typeutil, { Player, players as p, rawTypes as r, } from '../../../../../../shared/chess/util/typeutil'; import mouse from '../../../util/mouse'; import arrows from '../../rendering/arrows/arrows'; import gameslot from '../../chess/gameslot'; import selection from '../../chess/selection'; import { Mouse } from '../../input'; import egamerules from '../egamerules'; import guipalette from '../../gui/boardeditor/guipalette'; import edithistory from '../edithistory'; import specialrighthighlights from '../../rendering/highlights/specialrighthighlights'; // Constants ------------------------------------------------------- /** All tools that support drawing. */ const drawingTools: Tool[] = ['placer', 'eraser', 'specialrights']; // State ----------------------------------------------------------- let currentColor: Player = p.WHITE; let currentPieceType: number = typeutil.buildType(r.PAWN, currentColor); /** * Changes are stored in `thisEdit` until the user releases the button. * Grouping changes together allow the user to undo an entire * brush stroke at once instead of one piece at a time. */ let thisEdit: Edit | undefined; /** The ID of the pointer currently being used for drawing an edit with a DRAWING tool (excludes Selection tool) */ let drawingToolPointerId: string | undefined; /** Whether a drawing stroke is currently ongoing. */ let drawing = false; /** The last coordinate the stroke was over. */ let previousSquare: Coords | undefined; /** Whether special rights are currently being added or removed with the current drawing stroke. Undefined if neither. */ let addingSpecialRights: boolean | undefined; // Initialization --------------------------------------------------------- function init(): void { guipalette.updatePieceColors(currentColor); guipalette.markPiece(currentPieceType); } function onCloseEditor(): void { resetState(); specialrighthighlights.disable(); } function resetState(): void { thisEdit = undefined; drawingToolPointerId = undefined; drawing = false; previousSquare = undefined; addingSpecialRights = undefined; } // Managing the Edit -------------------------------------------- function beginEdit(): void { drawing = true; thisEdit = { changes: [], state: { local: [], global: [] } }; // Pieces must be unselected before they are modified selection.unselectPiece(); } function endEdit(): void { if (!drawing || !thisEdit) return; edithistory.addEditToHistory(thisEdit); resetState(); } /** Cancels the current edit, undoing any changes made during the stroke. */ function cancelEdit(): void { if (!drawing || thisEdit === undefined) return; const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; // Undo the changes made during this edit edithistory.runEdit(gamefile, mesh, thisEdit, false); resetState(); } /** Handle starting and ending the drawing state */ function update(currentTool: Tool): void { if (!drawingTools.includes(currentTool)) return; // Not using a drawing tool if (mouse.isMouseDown(Mouse.LEFT) && !drawing && !arrows.areHoveringAtleastOneArrow()) { mouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it mouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it drawingToolPointerId = mouse.getMouseId(Mouse.LEFT)!; beginEdit(); } else if (!mouse.isMouseHeld(Mouse.LEFT) && drawing) return endEdit(); if (!drawing || !thisEdit) return; // If not currently drawing, nothing more to do const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const mouseCoords = mouse.getTileMouseOver_Integer(); if (mouseCoords === undefined) return; if (previousSquare !== undefined && coordutil.areCoordsEqual(mouseCoords, previousSquare)) return; // We've already drawn on this square previousSquare = mouseCoords; // Make sure we don't paint outside the world border if ( gamefile.basegame.gameRules.worldBorder && !bounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder, mouseCoords) ) return; const pieceHovered = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, mouseCoords); const edit: Edit = { changes: [], state: { local: [], global: [] } }; switch (currentTool) { case 'placer': { // Replace piece logic. If we need this in more than one place, we can then make a queueReplacePiece() method. if (pieceHovered?.type === currentPieceType) break; // Equal to the new piece => don't replace if (pieceHovered) edithistory.queueRemovePiece(gamefile, edit, pieceHovered); // Delete existing piece first // Determine if special right should be given to the new piece, depending on gamerule checkboxes. const { pawnDoublePush, castling } = egamerules.getPositionDependentGameRules(); // prettier-ignore const specialright: boolean = ( (!!pawnDoublePush && egamerules.pawnDoublePushTypes.includes(typeutil.getRawType(currentPieceType))) || (!!castling && egamerules.castlingTypes.includes(typeutil.getRawType(currentPieceType))) ); edithistory.queueAddPiece(gamefile, edit, mouseCoords, currentPieceType, specialright); break; } case 'eraser': if (pieceHovered) edithistory.queueRemovePiece(gamefile, edit, pieceHovered); break; case 'specialrights': queueToggleSpecialRight(gamefile, edit, pieceHovered); break; default: throw Error('Tried to draw with a non-drawing tool.'); } if ( edit.changes.length === 0 && edit.state.local.length === 0 && edit.state.global.length === 0 ) return; edithistory.runEdit(gamefile, mesh, edit, true); thisEdit.changes.push(...edit.changes); thisEdit.state.local.push(...edit.state.local); thisEdit.state.global.push(...edit.state.global); } /** Queues a specialrights state addition/deletion on the specified piece. */ function queueToggleSpecialRight( gamefile: FullGame, edit: Edit, pieceHovered: Piece | undefined, ): void { if (pieceHovered === undefined) return; const coordsKey = coordutil.getKeyFromCoords(pieceHovered.coords); const current = gamefile.boardsim.state.global.specialRights.has(coordsKey); const future = !current; if (addingSpecialRights === undefined) addingSpecialRights = future; else if (addingSpecialRights !== future) return; state.createSpecialRightsState(edit, coordsKey, current, future); egamerules.updateGamerulesUponQueueToggleSpecialRight(pieceHovered.type, future); } // API --------------------------------------------------------- function onToolChange(tool: Tool): void { endEdit(); if (tool === 'specialrights') specialrighthighlights.enable(); else specialrighthighlights.disable(); if (tool !== 'placer') guipalette.markPiece(null); else guipalette.markPiece(currentPieceType); } function isEditInProgress(): boolean { return drawing; } function isToolADrawingTool(tool: Tool): boolean { return drawingTools.includes(tool); } function stealPointer(pointerIdToSteal: string): void { if (drawingToolPointerId !== pointerIdToSteal) return; // Not the pointer drawing the edit, don't stop using it. cancelEdit(); } /** Set the piece type to be added to the board */ function setPiece(pieceType: number): void { currentPieceType = pieceType; } function getPiece(): number { return currentPieceType; } function setColor(color: Player): void { currentColor = color; } function getColor(): Player { return currentColor; } // Exports -------------------------------------------------------------------- export default { // Initialization init, onCloseEditor, update, // API onToolChange, isEditInProgress, isToolADrawingTool, stealPointer, setPiece, getPiece, setColor, getColor, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/etoolmanager.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/etoolmanager.ts /** * Tool Manager for the Board Editor. * * Tracks the currently selected tool, handles tool switching, * keyboard shortcuts, and pointer reservation. */ import selection from '../../chess/selection.js'; import guitoolbar from '../../gui/boardeditor/guitoolbar.js'; import drawingtool from './drawingtool.js'; import perspective from '../../rendering/perspective.js'; import boardeditor from '../boardeditor.js'; import edithistory from '../edithistory.js'; import selectiontool from './selection/selectiontool.js'; import { listener_document } from '../../chess/game.js'; // Types ---------------------------------------------------------------------- export type Tool = (typeof validTools)[number]; // Constants ------------------------------------------------------------------ /** All tools that can be used in the board editor. */ const validTools = ['normal', 'placer', 'eraser', 'specialrights', 'selection-tool'] as const; // State ---------------------------------------------------------------------- /** The tool currently selected. */ let currentTool: Tool = 'normal'; // Initialization ------------------------------------------------------------- /** Resets the tool state when the board editor is closed. */ function reset(): void { currentTool = 'normal'; guitoolbar.markTool(currentTool); // Effectively resets classes state } // Tool Management ------------------------------------------------------------ /** Returns the currently active tool. */ function getTool(): Tool { return currentTool; } /** Changes the active tool. */ function setTool(tool: string): void { if (!validTools.includes(tool as Tool)) return console.error('Invalid tool: ' + tool); currentTool = tool as Tool; drawingtool.onToolChange(currentTool); // Prevents you from being able to draw while a piece is selected. // Should this not always unselect when moving off the "normal" tool? // Buttons that perform one-time actions like "clear" or "reset" should not be treated as tools. selection.unselectPiece(); guitoolbar.markTool(currentTool); // Reset selection tool state when switching to another tool selectiontool.resetState(); } /** Whether any of the editor tools are actively using the left mouse button. */ function isLeftMouseReserved(): boolean { if (!boardeditor.areInBoardEditor()) return false; return drawingtool.isToolADrawingTool(currentTool) || currentTool === 'selection-tool'; } /** If the given pointer is currently being used by a drawing tool for an edit, this stops using it. */ function stealPointer(pointerIdToSteal: string): void { if (!boardeditor.areInBoardEditor()) return; if (currentTool === 'selection-tool') return; // Don't steal (selection tool isn't capable of reverting to previous selection before starting a new one) else if (drawingtool.isToolADrawingTool(currentTool)) drawingtool.stealPointer(pointerIdToSteal); } // Shortcuts ------------------------------------------------------------------ /** Tests for keyboard shortcuts in the board editor. */ function testShortcuts(): void { if (perspective.getEnabled()) return; // Disable shortcuts while in perspective mode, WASD is reserved for camera movement // Select all if (listener_document.isKeyDown('KeyA', true)) selectiontool.selectAll(); // Undo/Redo if (listener_document.isKeyDown('KeyY', true)) edithistory.redo(); if (listener_document.isKeyDown('KeyZ', true, true)) edithistory.redo(); // Also requires shift key else if (listener_document.isKeyDown('KeyZ', true)) edithistory.undo(); // Tools if (listener_document.isKeyDown('KeyF')) setTool('normal'); else if (listener_document.isKeyDown('KeyG')) setTool('eraser'); else if (listener_document.isKeyDown('KeyH')) setTool('selection-tool'); else if (listener_document.isKeyDown('KeyJ')) setTool('specialrights'); else if (listener_document.isKeyDown('KeyK')) setTool('placer'); } // Exports -------------------------------------------------------------------- export default { // Initialization reset, // Tool Management getTool, setTool, isLeftMouseReserved, stealPointer, // Shortcuts testShortcuts, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/normaltool.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/normaltool.ts /** * Normal Tool for the Board Editor * * This tool can drag pieces around. */ import type { Mesh } from '../../rendering/piecemodels'; import type { Edit } from '../../../../../../shared/chess/logic/movepiece'; import type { MoveCoords } from '../../../../../../shared/chess/logic/icn/icnconverter'; import type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile'; import state from '../../../../../../shared/chess/logic/state'; import movepiece from '../../../../../../shared/chess/logic/movepiece'; import boardutil from '../../../../../../shared/chess/util/boardutil'; import coordutil from '../../../../../../shared/chess/util/coordutil'; import edithistory from '../edithistory'; import { GameBus } from '../../GameBus'; import movesequence from '../../chess/movesequence'; // Making Move Edits in the Game --------------------------------------------- /** * Similar to {@link movesequence.makeMove}, but doesn't push the move to the game's * moves list, nor update gui, clocks, or do game over checks, nor the moveIndex property updated. */ function makeMoveEdit(gamefile: FullGame, mesh: Mesh | undefined, moveCoords: MoveCoords): Edit { const edit = generateMoveEdit(gamefile.boardsim, moveCoords); movepiece.applyEdit(gamefile, edit, true, true); // forward & global are always true GameBus.dispatch('physical-move'); if (mesh) movesequence.runMeshChanges(gamefile.boardsim, mesh, edit, true); edithistory.addEditToHistory(edit); return edit; } /** * Similar to {@link movepiece.generateMove}, but specifically for editor moves, * which don't execute special moves, nor are appeneded to the game's moves list, * nor the gamefile's moveIndex property updated. */ function generateMoveEdit(boardsim: Board, moveCoords: MoveCoords): Edit { const piece = boardutil.getPieceFromCoords(boardsim.pieces, moveCoords.startCoords); if (!piece) throw Error( `Cannot generate move edit because no piece exists at coords ${JSON.stringify(moveCoords.startCoords)}.`, ); // Initialize the state, and change list, as empty for now. const edit: Edit = { changes: [], state: { local: [], global: [] }, }; movepiece.calcMovesChanges(boardsim, piece, moveCoords, edit); // Move piece regularly (no specials) // Queue the state change transfer of this edit's special right to its new destination. const startCoordsKey = coordutil.getKeyFromCoords(moveCoords.startCoords); const endCoordsKey = coordutil.getKeyFromCoords(moveCoords.endCoords); const hasSpecialRight = boardsim.state.global.specialRights.has(startCoordsKey); const destinationHasSpecialRight = boardsim.state.global.specialRights.has(endCoordsKey); state.createSpecialRightsState(edit, startCoordsKey, hasSpecialRight, false); // Delete the special right from the startCoords, if it exists state.createSpecialRightsState(edit, endCoordsKey, destinationHasSpecialRight, hasSpecialRight); // Transfer the special right to the endCoords, if it exists return edit; } // Exports -------------------------------------------------------------------- export default { makeMoveEdit, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/scursor.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/scursor.ts /** * Selection Tool Cursor Style * * Handles changing the current cursor style of the canvas overlay * when hovering over the selection area's edges or fill handle. */ import game from '../../../chess/game'; // Types ---------------------------------------------------- type Cursor = 'grab' | 'grabbing' | 'crosshair'; // Constants ------------------------------------------------ /** If multiple cursor styles are enabled, only the one with most priority is actually applied. */ const priority: Cursor[] = ['crosshair', 'grabbing', 'grab']; // State ---------------------------------------------------- /** A list of all active cursor styles. */ const current: Set = new Set(); // Methods -------------------------------------------------- /** Adds a cursor style, immediately applying it if it has the highest priority. */ function addCursor(cursor: Cursor): void { current.add(cursor); updateCursor(); } /** Removes a cursor style, updating the current style to the next highest priority if needed. */ function removeCursor(cursor: Cursor): void { current.delete(cursor); updateCursor(); } /** Updates the current cursor style, if needed, to the highest priority active style. */ function updateCursor(): void { const overlay = game.getOverlay(); // Set cursor to default if no cursor styles are active if (current.size === 0) { overlay.style.cursor = 'default'; return; } // Determine highest priority cursor style let highestPrio: string; for (const prioCursor of priority) { if (current.has(prioCursor)) { highestPrio = prioCursor; break; } } if (overlay.style.cursor === highestPrio!) return; // No change needed overlay.style.cursor = highestPrio!; // Apply new cursor style } // Exports --------------------------------------------------- export default { addCursor, removeCursor, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/sdrag.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/sdrag.ts /** * Selection Tool Drag * * This handles when the current selection has been grabbed on the edge, * and handles moving the selection. */ import bimath from '../../../../../../../shared/util/math/bimath'; import coordutil, { Coords } from '../../../../../../../shared/chess/util/coordutil'; import bounds, { BoundingBox, DoubleBoundingBox, } from '../../../../../../../shared/util/math/bounds'; import mouse from '../../../../util/mouse'; import space from '../../../misc/space'; import arrows from '../../../rendering/arrows/arrows'; import docutil from '../../../../util/docutil.js'; import scursor from './scursor'; import gameslot from '../../../chess/gameslot'; import { Mouse } from '../../../input'; import selectiontool from './selectiontool'; import stoolgraphics from './stoolgraphics'; import stransformations from './stransformations'; // Constants ----------------------------------------- /** The distance, in virtual screen pixels, that we may grab the edge of the selection box to drag it. */ const GRABBABLE_DIST = { DESKTOP: 6, MOBILE: 18, }; // State --------------------------------------------- /** Whether the mouse is currently within the minimum distance to grab and drag the selection. */ let withinGrabDist = false; /** Whether we are currently dragging the selection. */ let areDragging = false; /** The ID of the pointer currently being used drag the selection. */ let pointerId: string | undefined = undefined; /** The last known square the pointer was hovering over. */ let lastPointerCoords: Coords | undefined; /** The integer coordinate the mouse has grabbed, if we're dragging the selection. */ let anchorCoords: Coords | undefined = undefined; // Methods ------------------------------------------- /** Returns the grabbable distance in virtual pixels depending on whether a mouse or touch input is being used. */ function getGrabbableDist(): number { return docutil.isMouseSupported() ? GRABBABLE_DIST.DESKTOP : GRABBABLE_DIST.MOBILE; } /** * Updates the logic that handles dragging the selection box from the edges. * ONLY CALL if there's an existing selection area, and we are not currently making a new selection! */ function update(): void { if (areDragging) { // Determine if the selection has been dropped const respectiveListener = mouse.getRelevantListener(); // Update its last known position if available if (respectiveListener.pointerExists(pointerId!)) lastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!; // Test if pointer released (execute selection translation) if (!respectiveListener.isPointerHeld(pointerId!)) dropSelection(); } else { // Determine if the board needs to be picked up, // or if the canvas cursor style should change. if (isMouseHoveringOverSelectionEdge()) { // Within grab distance if (!withinGrabDist) { withinGrabDist = true; scursor.addCursor('grab'); } // Determine if we picked up the selection if (mouse.isMouseDown(Mouse.LEFT) && !arrows.areHoveringAtleastOneArrow()) { // Start dragging mouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it mouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it pointerId = mouse.getMouseId(Mouse.LEFT)!; pickUpSelection(); } } else { // NOT within grab distance if (withinGrabDist) { withinGrabDist = false; scursor.removeCursor('grab'); } } } } /** Calculates whether the mouse is currently hovering within grab distance of the selection edge. */ function isMouseHoveringOverSelectionEdge(): boolean { const selectionWorldBox = selectiontool.getSelectionWorldBox()!; // Determine the mouse world coords const mouseWorld = mouse.getMouseWorld(Mouse.LEFT); if (!mouseWorld) return false; // Convert grab distance to world space const grabbableDist = space.convertPixelsToWorldSpace_Virtual(getGrabbableDist()); // Determine if the mouse is within the grabbable edge area. // This is true if the mouse is inside the selection box expanded by the grab distance, // but not inside the selection box shrunk by the grab distance. const mouseIsInOuterBox = mouseWorld[0] >= selectionWorldBox.left - grabbableDist && mouseWorld[0] <= selectionWorldBox.right + grabbableDist && mouseWorld[1] >= selectionWorldBox.bottom - grabbableDist && mouseWorld[1] <= selectionWorldBox.top + grabbableDist; const mouseIsInInnerBox = mouseWorld[0] > selectionWorldBox.left + grabbableDist && mouseWorld[0] < selectionWorldBox.right - grabbableDist && mouseWorld[1] > selectionWorldBox.bottom + grabbableDist && mouseWorld[1] < selectionWorldBox.top - grabbableDist; return mouseIsInOuterBox && !mouseIsInInnerBox; } function resetState(): void { withinGrabDist = false; scursor.removeCursor('grabbing'); scursor.removeCursor('grab'); areDragging = false; pointerId = undefined; lastPointerCoords = undefined; anchorCoords = undefined; } /** Grabs the selection box. */ function pickUpSelection(): void { areDragging = true; scursor.addCursor('grabbing'); // Determine the nearest coordinate of the selection the mouse picked up. // This will be the anchor const selectionIntBox: BoundingBox = selectiontool.getSelectionIntBox()!; const pointerCoordRounded: Coords = mouse.getTilePointerOver_Integer(pointerId!)!; // Clamp the pointer coord to the int box anchorCoords = [ bimath.clamp(pointerCoordRounded[0], selectionIntBox.left, selectionIntBox.right), bimath.clamp(pointerCoordRounded[1], selectionIntBox.bottom, selectionIntBox.top), ]; lastPointerCoords = anchorCoords; } function dropSelection(): void { // Determine the final distance to translate the selection. // Determine by how many tiles the pointer has dragged from the anchor const translation: Coords = coordutil.subtractCoords(lastPointerCoords!, anchorCoords!); // Reset state AFTER getting total translation resetState(); // If the translation is zero, skip the transformation if (translation[0] === 0n && translation[1] === 0n) return; const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!; stransformations.Translate(gamefile, mesh, selectionBox, translation); } // /** // * Whether we are currently dragging the selection, AND // * we have dragged it at least 1 square away from the anchor. // */ // function isDragTranslationPositive(): boolean { // if (!areDragging || !anchorCoords) return false; // // Determine the current int coord of the pointer // const pointerCoordRounded: Coords = getIntCoordOfPointer(); // // Determine by how many tiles the pointer has dragged from the anchor // const translation: Coords = coordutil.subtractCoords(pointerCoordRounded, anchorCoords!); // // Return whether that's absolutely positive // return translation[0] !== 0n || translation[1] !== 0n; // } // Rendering --------------------------------------------- function render(): void { if (!areDragging || !anchorCoords) return; // Determine the current int coord of the pointer const pointerCoordRounded: Coords = mouse.getTilePointerOver_Integer(pointerId!)!; // Determine by how many tiles the pointer has dragged from the anchor const translation: Coords = coordutil.subtractCoords(pointerCoordRounded, anchorCoords); // If the translation is zero, skip if (translation[0] === 0n && translation[1] === 0n) return; const selectionIntBox: BoundingBox = selectiontool.getSelectionIntBox()!; // Transform the selection box so we can show graphically where it will be moved to, if let go now. const translatedIntBox: BoundingBox = bounds.translateBoundingBox(selectionIntBox, translation); // Convert it to a world-space box, with edges rounded away to encapsulate the entirity of the squares. const translatedWorldBox: DoubleBoundingBox = selectiontool.convertIntBoxToWorldBox(translatedIntBox); stoolgraphics.renderSelectionBoxWireframe(translatedWorldBox); // stoolgraphics.renderSelectionBoxFill(translatedWorldBox); } // Exports ----------------------------------------------- export default { getGrabbableDist, update, resetState, render, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/selectiontool.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/selectiontool.ts /** * The Selection Tool for the Board Editor * * Acts similarly to that of Google Sheets */ import type { Coords } from '../../../../../../../shared/chess/util/coordutil'; import type { BoundingBox, BoundingBoxBD, DoubleBoundingBox, } from '../../../../../../../shared/util/math/bounds'; import bimath from '../../../../../../../shared/util/math/bimath'; import boardutil from '../../../../../../../shared/chess/util/boardutil'; import mouse from '../../../../util/mouse'; import sfill from './sfill'; import sdrag from './sdrag'; import arrows from '../../../rendering/arrows/arrows'; import meshes from '../../../rendering/meshes'; import gameslot from '../../../chess/gameslot'; import { Mouse } from '../../../input'; import etoolmanager from '../etoolmanager'; import stoolgraphics from './stoolgraphics'; import stransformations from './stransformations'; import guipositionheader from '../../../gui/boardeditor/guipositionheader'; import { listener_document, listener_overlay } from '../../../chess/game'; // State ---------------------------------------------- /** Whether or now we are currently making a selection. */ let selecting: boolean = false; /** The ID of the pointer currently being used creating a selection. */ let pointerId: string | undefined = undefined; /** The last known square the pointer was hovering over. */ let lastPointerCoords: Coords | undefined; /** The square that the selection began at. */ let startPoint: Coords | undefined; /** * The square that the selection ends at. * ONLY DEFINED when we have an actual selection made already, * NOT when we are currently MAKING a selection. */ let endPoint: Coords | undefined; // Methods ------------------------------------------- function update(): void { if (isExistingSelection()) testShortcuts(); // Is a current selection, or one is in progress if (!selecting) { // No selection in progress (either none made yet, or have already made one) // Update grabbing the selection box first if (isACurrentSelection()) { sfill.update(); // Update fill tool handler sdrag.update(); // Update selection box drag handler } // Test if a new selection is beginning if (mouse.isMouseDown(Mouse.LEFT) && !selecting && !arrows.areHoveringAtleastOneArrow()) { // Start new selection mouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it mouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it pointerId = mouse.getMouseId(Mouse.LEFT)!; beginSelection(); } } else { // Selection in progress const respectiveListener = mouse.getRelevantListener(); // Update its last known position if available if (respectiveListener.pointerExists(pointerId!)) lastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!; // Test if pointer released (finalize new selection) if (!respectiveListener.isPointerHeld(pointerId!)) endSelection(); } } /** Tests for keyboard shortcuts while using the Selection Tool. */ function testShortcuts(): void { // Delete selection if (listener_document.isKeyDown('Delete') || listener_document.isKeyDown('Backspace')) { const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox: BoundingBox = getSelectionIntBox()!; stransformations.Delete(gamefile, mesh, selectionBox); } } function beginSelection(): void { // console.log("Beginning selection"); startPoint = undefined; endPoint = undefined; selecting = true; sfill.resetState(); sdrag.resetState(); // Set the start point startPoint = mouse.getTilePointerOver_Integer(pointerId!)!; lastPointerCoords = startPoint; } function endSelection(): void { // console.error("Ending selection"); // Set the end point endPoint = lastPointerCoords; guipositionheader.onNewSelection(); selecting = false; pointerId = undefined; } // function cancelSelection(): void { // resetState(); // } function resetState(): void { selecting = false; pointerId = undefined; lastPointerCoords = undefined; startPoint = undefined; endPoint = undefined; sfill.resetState(); sdrag.resetState(); guipositionheader.onClearSelection(); } /** Whether there is a current selection, NOT whether we are currently MAKING a selection. */ function isACurrentSelection(): boolean { return !!startPoint && !!endPoint; } /** * Returns whether there is a current selection, or one in progress. * Also considered whether a selection area is renderable or not. */ function isExistingSelection(): boolean { return !!selecting || !!endPoint; } function render(): void { if (isExistingSelection()) { // There either is a selection, or we are currently making one const selectionWorldBox = getSelectionWorldBox()!; // Render the selection box stoolgraphics.renderSelectionBoxWireframe(selectionWorldBox); stoolgraphics.renderSelectionBoxFill(selectionWorldBox); if (isACurrentSelection()) { // Render the small square in the corner stoolgraphics.renderCornerSquare(selectionWorldBox); sfill.render(); // Fill tool graphics sdrag.render(); // Selection drag graphics } } else { // No selection, and not currently making one if (listener_overlay.getAllPhysicalPointers().length > 1) return; // Don't render if multiple fingers down // Outline the rank and file of the square hovered over stoolgraphics.outlineRankAndFile(); } } /** Returns the integer coordinate bounding box of our selection area. */ function getSelectionIntBox(): BoundingBox | undefined { const currentTile: Coords | undefined = endPoint || lastPointerCoords; if (!startPoint || !currentTile) return; return { left: bimath.min(startPoint[0], currentTile[0]), right: bimath.max(startPoint[0], currentTile[0]), bottom: bimath.min(startPoint[1], currentTile[1]), top: bimath.max(startPoint[1], currentTile[1]), }; } /** Calculates the world space edge coordinates of the current selection box. */ function getSelectionWorldBox(): DoubleBoundingBox | undefined { const intBox = getSelectionIntBox(); if (!intBox) return; return convertIntBoxToWorldBox(intBox); } /** * Converts an int selection box to a world-space box, rounding away * its edges outward to encapsulate the entirity of the squares. */ function convertIntBoxToWorldBox(intBox: BoundingBox): DoubleBoundingBox { // Moves the edges of the box outward to encapsulate the entirity of the squares, instead of just the centers. const roundedAwayBox: BoundingBoxBD = meshes.expandTileBoundingBoxToEncompassWholeSquare(intBox); // Convert it to a world-space box return meshes.applyWorldTransformationsToBoundingBox(roundedAwayBox); } /** * Returns the corners of the current selection. * ONLY CALL if you know a selection exists! */ function getSelectionCorners(): [Coords, Coords] { if (!startPoint || !endPoint) throw new Error("No current selection. Can't get selection corners."); return [startPoint, endPoint]; } /** * Sets the current selected area. * ONLY CALL if this is an overwriting of the existing * selection, NOT to set it when it does not have a value! */ function setSelection(corner1: Coords, corner2: Coords): void { if (!startPoint || !endPoint) throw new Error("No current selection. Can't set selection."); startPoint = corner1; endPoint = corner2; } /** Selects all pieces in the current position, and transitions to the selection. */ function selectAll(): void { etoolmanager.setTool('selection-tool'); // Switch if we're not already using const box = boardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces); if (box === undefined) { // No pieces, cancel selection resetState(); // Disabled for now as I'm not sure I like Selecting all immediately transitioning // guinavigation.recenter(); return; } startPoint = [box.left, box.top]; endPoint = [box.right, box.bottom]; guipositionheader.onNewSelection(); // Disabled for now as I'm not sure I like Selecting all immediately transitioning // Transition.zoomToCoordsBox(box); } // Exports ------------------------------------------------------ export default { update, resetState, isExistingSelection, render, getSelectionIntBox, getSelectionWorldBox, convertIntBoxToWorldBox, getSelectionCorners, setSelection, selectAll, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/sfill.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/sfill.ts /** * Selection Tool Fill * * This handles the fill operation when dragging the fill handle * on the bottom-right corner of the selection box. */ import type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil'; import bimath from '../../../../../../../shared/util/math/bimath'; import vectors from '../../../../../../../shared/util/math/vectors'; import bounds, { BoundingBox, DoubleBoundingBox, } from '../../../../../../../shared/util/math/bounds'; import mouse from '../../../../util/mouse'; import space from '../../../misc/space'; import sdrag from './sdrag'; import arrows from '../../../rendering/arrows/arrows'; import scursor from './scursor'; import gameslot from '../../../chess/gameslot'; import { Mouse } from '../../../input'; import selectiontool from './selectiontool'; import stoolgraphics from './stoolgraphics'; import stransformations from './stransformations'; // State --------------------------------------------- /** Whether the mouse is currently within the minimum distance to grab the fill handle. */ let withinGrabDist = false; /** Whether we are currently dragging the selection. */ let areFilling = false; /** The ID of the pointer currently being used drag the selection. */ let pointerId: string | undefined = undefined; /** The last known square the pointer was hovering over. */ let lastPointerCoords: Coords | undefined; // Methods ------------------------------------------- /** Returns whether we are currently filling. */ function areWeFilling(): boolean { return areFilling; } /** * Updates the logic that handles dragging the selection box from the edges. * ONLY CALL if there's an existing selection area, and we are not currently making a new selection! */ function update(): void { if (areFilling) { // Determine if the selection has been dropped const respectiveListener = mouse.getRelevantListener(); // Update its last known position if available if (respectiveListener.pointerExists(pointerId!)) lastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!; // Test if pointer released (execute selection translation) if (!respectiveListener.isPointerHeld(pointerId!)) executeFill(); } else { // Determine if the fill handle needs to be grabbed, // or if the canvas cursor style should change. if (isMouseHoveringOverFillHandle()) { // Within grab distance if (!withinGrabDist) { withinGrabDist = true; scursor.addCursor('crosshair'); } // Determine if we started dragging the fill handle if (mouse.isMouseDown(Mouse.LEFT) && !arrows.areHoveringAtleastOneArrow()) { // Start dragging mouse.claimMouseDown(Mouse.LEFT); // Remove the pointer down so other scripts don't use it mouse.cancelMouseClick(Mouse.LEFT); // Cancel any potential future click so other scripts don't use it pointerId = mouse.getMouseId(Mouse.LEFT)!; startFill(); } } else { // NOT within grab distance if (withinGrabDist) { withinGrabDist = false; scursor.removeCursor('crosshair'); } } } } /** Calculates whether the mouse is currently hovering within grab distance of the fill handle. */ function isMouseHoveringOverFillHandle(): boolean { const selectionWorldBox = selectiontool.getSelectionWorldBox()!; const fillHandleCorner: DoubleCoords = [ // Bottom-right corner selectionWorldBox.right, selectionWorldBox.bottom, ]; // Determine the mouse world coords const mouseWorld = mouse.getMouseWorld(Mouse.LEFT); if (!mouseWorld) return false; // Convert grab distance to world space const grabbableDist = space.convertPixelsToWorldSpace_Virtual(sdrag.getGrabbableDist()); // Determine the distance from the mouse to the fill handle corner const distToFillHandle = vectors.chebyshevDistanceDoubles(mouseWorld, fillHandleCorner); // Return whether it's within grab distance return distToFillHandle <= grabbableDist; } function resetState(): void { withinGrabDist = false; scursor.removeCursor('crosshair'); areFilling = false; pointerId = undefined; lastPointerCoords = undefined; } /** Grabs the selection box. */ function startFill(): void { areFilling = true; lastPointerCoords = mouse.getTilePointerOver_Integer(pointerId!)!; } function executeFill(): void { const fillBox = calculateFillBox(); // Reset state AFTER calculating fill box resetState(); if (!fillBox) return; // No fill to perform (let go within selection box) const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!; stransformations.Fill(gamefile, mesh, selectionBox, fillBox); } /** * Determines the fill axis and distance based on the current pointer position. */ function calculateFillBox(): BoundingBox | undefined { const selectionBox: BoundingBox = selectiontool.getSelectionIntBox()!; // If the pointer is contained within the selection box, skip if (bounds.boxContainsSquare(selectionBox, lastPointerCoords!)) return; const distXFromLeft = lastPointerCoords![0] - selectionBox.left; const distXFromRight = lastPointerCoords![0] - selectionBox.right; const distYFromBottom = lastPointerCoords![1] - selectionBox.bottom; const distYFromTop = lastPointerCoords![1] - selectionBox.top; const distXChoice = distXFromRight > 0n ? distXFromRight : distXFromLeft < 0n ? distXFromLeft : 0n; const distYChoice = distYFromTop > 0n ? distYFromTop : distYFromBottom < 0n ? distYFromBottom : 0n; // Determine which axis has the larger distance from the selection box if (bimath.abs(distXChoice) >= bimath.abs(distYChoice)) { // X Axis if (distXChoice > 0n) { // Filling to the right return { left: selectionBox.right + 1n, right: lastPointerCoords![0], bottom: selectionBox.bottom, top: selectionBox.top, }; } else { // Filling to the left return { left: lastPointerCoords![0], right: selectionBox.left - 1n, bottom: selectionBox.bottom, top: selectionBox.top, }; } } else { // Y axis if (distYChoice > 0n) { // Filling upwards return { left: selectionBox.left, right: selectionBox.right, bottom: selectionBox.top + 1n, top: lastPointerCoords![1], }; } else { // Filling downwards return { left: selectionBox.left, right: selectionBox.right, bottom: lastPointerCoords![1], top: selectionBox.bottom - 1n, }; } } } // Rendering --------------------------------------------- function render(): void { if (!areFilling) return; // Determine the fill int box to render depending on the state const fillBox = calculateFillBox(); if (!fillBox) return; // No fill to perform (let go within selection box) // Convert it to a world-space box, with edges rounded away to encapsulate the entirity of the squares. const worldFillBox: DoubleBoundingBox = selectiontool.convertIntBoxToWorldBox(fillBox); stoolgraphics.renderSelectionBoxWireframeDashed(worldFillBox); } // Exports ----------------------------------------------- export default { areWeFilling, update, resetState, render, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/stoolgraphics.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/stoolgraphics.ts /** * Selection Tool Graphics * * Contains the methods for rendering the graphics * of the Selection Tool in the Board Editor */ import type { Color } from '../../../../../../../shared/util/math/math'; import type { DoubleBoundingBox } from '../../../../../../../shared/util/math/bounds'; import type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil'; import bounds from '../../../../../../../shared/util/math/bounds'; import mouse from '../../../../util/mouse'; import space from '../../../misc/space'; import camera from '../../../rendering/camera'; import meshes from '../../../rendering/meshes'; import primitives from '../../../rendering/primitives'; import { createRenderable } from '../../../../webgl/Renderable'; // Constants --------------------------------------------------- /** * The color for the wireframe of the selection box, including the small square in the corner, * and the outline of the currently hovered square's rank & file, when there is no selection. */ const OUTLINE_COLOR: Color = [0, 0, 0, 1]; // Black /** The fill color of the selection box. */ const FILL_COLOR: Color = [0, 0, 0, 0.08]; // Transparent Black /** How many virtual screen pixels wide the corner square is. */ const CORNER_DOT_WIDTH = 6; /** How many virtual screen pixels wide the dashed outlines are. */ const DASHED_WIDTH = 1; /** How many virtual screen pixels long the dashes are. */ const DASHED_LENGTH = 6; // Methods ----------------------------------------------------- /** * Outlines the current rank and file of the square * the mouse is currently hovering over, for a total of 4 lines. */ function outlineRankAndFile(): void { // Determine what square the mouse is hovering over const currentTile: Coords | undefined = mouse.getTileMouseOver_Integer(); if (!currentTile) return; // The coordinates of the edges of the square const { left, right, bottom, top } = meshes.getCoordBoxWorld(currentTile); const data: number[] = []; const screenBox = camera.getRespectiveScreenBox(); // prettier-ignore data.push( // Horizontal: Lower screenBox.left, bottom, ...OUTLINE_COLOR, screenBox.right, bottom, ...OUTLINE_COLOR, // Horizontal: Upper screenBox.left, top, ...OUTLINE_COLOR, screenBox.right, top, ...OUTLINE_COLOR, // Vertical: Lefter left, screenBox.bottom, ...OUTLINE_COLOR, left, screenBox.top, ...OUTLINE_COLOR, // Vertical: Righter right, screenBox.bottom, ...OUTLINE_COLOR, right, screenBox.top, ...OUTLINE_COLOR, ); createRenderable(data, 2, 'LINES', 'color', true).render(); } /** * Renders a wireframe box around the provided box. * @param worldBox - Contains the world space edge coordinates of the box. */ function renderSelectionBoxWireframe(worldBox: DoubleBoundingBox): void { // Clamp to screen box to prevent overflow glitches when the box is very large. const screenBox = camera.getRespectiveScreenBox(); const clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox); if (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible const data: number[] = primitives.Rect( clampedBox.left, clampedBox.bottom, clampedBox.right, clampedBox.top, OUTLINE_COLOR, ); createRenderable(data, 2, 'LINE_LOOP', 'color', true).render(); } /** * Renders a dashed wireframe box around the provided box. * @param worldBox - Contains the world space edge coordinates of the box. */ function renderSelectionBoxWireframeDashed(worldBox: DoubleBoundingBox): void { // Clamp to screen box to prevent overflow glitches when the box is very large. const screenBox = camera.getRespectiveScreenBox(); const clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox); if (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible // Convert virtual pixel dimensions to world space const dashedWidth = space.convertPixelsToWorldSpace_Virtual(DASHED_WIDTH); const dashedLength = space.convertPixelsToWorldSpace_Virtual(DASHED_LENGTH); const data: number[] = primitives.DashedRect( clampedBox.left, clampedBox.bottom, clampedBox.right, clampedBox.top, dashedWidth, dashedLength, dashedLength, OUTLINE_COLOR, ); createRenderable(data, 2, 'TRIANGLES', 'color', true).render(); } /** * Renders a filled transparent box inside the provided box. * @param worldBox - Contains the world space edge coordinates of the box. */ function renderSelectionBoxFill(worldBox: DoubleBoundingBox): void { // Clamp to screen box to prevent overflow glitches when the box is very large. const screenBox = camera.getRespectiveScreenBox(); const clampedBox = bounds.clampDoubleBoundingBox(worldBox, screenBox); if (bounds.areBoxesDisjoint(clampedBox, screenBox)) return; // Box is off-screen -> not visible const fillData: number[] = primitives.Quad_Color( clampedBox.left, clampedBox.bottom, clampedBox.right, clampedBox.top, FILL_COLOR, ); createRenderable(fillData, 2, 'TRIANGLES', 'color', true).render(); } /** * Renders the small square in the corner of the selection box. * @param worldBox - Contains the world space edge coordinates of the selection box. */ function renderCornerSquare(worldBox: DoubleBoundingBox): void { // Convert width to world space const widthWorld = space.convertPixelsToWorldSpace_Virtual(CORNER_DOT_WIDTH); // Bottom right corner world space const corner: DoubleCoords = [worldBox.right, worldBox.bottom]; // Calculate vertex data const left = corner[0] - widthWorld / 2; const right = corner[0] + widthWorld / 2; const bottom = corner[1] - widthWorld / 2; const top = corner[1] + widthWorld / 2; const fillData: number[] = primitives.Quad_Color(left, bottom, right, top, OUTLINE_COLOR); createRenderable(fillData, 2, 'TRIANGLES', 'color', true).render(); } // Exports ---------------------------------------------------------- export default { outlineRankAndFile, renderSelectionBoxWireframe, renderSelectionBoxWireframeDashed, renderSelectionBoxFill, renderCornerSquare, }; ================================================ FILE: src/client/scripts/esm/game/boardeditor/tools/selection/stransformations.ts ================================================ // src/client/scripts/esm/game/boardeditor/tools/selection/stransformations.ts /** * Selection Tool Transformations * * Contains transformation functions for the current * selection from the Selection Tool in the Board Editor */ import type { Mesh } from '../../../rendering/piecemodels'; import type { Edit } from '../../../../../../../shared/chess/logic/movepiece'; import type { FullGame } from '../../../../../../../shared/chess/logic/gamefile'; import type { BoundingBox } from '../../../../../../../shared/util/math/bounds'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bounds from '../../../../../../../shared/util/math/bounds'; import bimath from '../../../../../../../shared/util/math/bimath'; import typeutil from '../../../../../../../shared/chess/util/typeutil'; import bdcoords from '../../../../../../../shared/chess/util/bdcoords'; import organizedpieces from '../../../../../../../shared/chess/logic/organizedpieces'; import vectors, { Vec2 } from '../../../../../../../shared/util/math/vectors'; import boardutil, { Piece } from '../../../../../../../shared/chess/util/boardutil'; import coordutil, { BDCoords, Coords } from '../../../../../../../shared/chess/util/coordutil'; import edithistory from '../../edithistory'; import selectiontool from './selectiontool'; // Types --------------------------------------------------------------------- /** A Piece object that also remembers its specialrights state. */ interface StatePiece extends Piece { specialrights: boolean; } // Constants ------------------------------------------------------------------ const NEGONE = bd.fromBigInt(-1n, 1); const HALF = bd.fromNumber(0.5, 1); const ONE = bd.fromBigInt(1n, 1); const TWO = bd.fromBigInt(2n, 1); // State ------------------------------------------------------------------------ /** Whatever's copied to the clipboard via the "Copy selection" action button. */ let clipboard: StatePiece[] | undefined; /** The box containing all clipboard pieces. */ let clipboardBox: BoundingBox | undefined; /** * The parity of which vector the pivot point of rotations shifts * so as the pieces don't land on floating point coords after rotation. * This makes it so that 2 consecutive rotations return to the original position. */ let rotationParity: boolean = false; // Selection Box Transformations ------------------------------------------------ /** Translates the selection by a given vector. */ function Translate( gamefile: FullGame, mesh: Mesh, selectionBox: BoundingBox, translation: Coords, ): void { const destinationBox = bounds.translateBoundingBox(selectionBox, translation); const newSelectionCorners: [Coords, Coords] = [ [destinationBox.left, destinationBox.top], [destinationBox.right, destinationBox.bottom], ]; // A function controlling how each piece is transformed const transformer = (piece: Piece): { coords: Coords; type: number } => ({ coords: coordutil.addCoords(piece.coords, translation), type: piece.type, }); // Execute the transformation Transform(gamefile, mesh, selectionBox, destinationBox, newSelectionCorners, transformer); } /** Extends the selection area by repeating its contents into the given fill box. */ function Fill( gamefile: FullGame, mesh: Mesh, selectionBox: BoundingBox, fillBox: BoundingBox, ): void { const piecesInSelection: Piece[] = getPiecesInBox(gamefile, selectionBox); const piecesInPasteBox: Piece[] = getPiecesInBox(gamefile, fillBox); // Determine the dimensions of the selection box const selectionWidth: bigint = selectionBox.right - selectionBox.left + 1n; const selectionHeight: bigint = selectionBox.top - selectionBox.bottom + 1n; // Dimensions of the fill box const fillBoxWidth: bigint = fillBox.right - fillBox.left + 1n; const fillBoxHeight: bigint = fillBox.top - fillBox.bottom + 1n; const isHorizontal = fillBox.left !== selectionBox.left; /** How many whole copies fit in the fill box, floored. */ let wholeCopies: bigint; /** +X/+Y or -X/-Y */ let isPositiveDirection: boolean; /** How much each copy's coordinate is incremented by each iteration. May be negative. */ let axisIncrement: bigint; /** The axis coordinate the fill box ends at. Also where we stop filling. */ let fillBoxAxisEnd: bigint; /** The axis translation for the current iteration. */ let currentCopyStartAxis: bigint; if (isHorizontal) { // Horizontal fill isPositiveDirection = fillBox.left > selectionBox.left; axisIncrement = isPositiveDirection ? selectionWidth : -selectionWidth; wholeCopies = fillBoxWidth / selectionWidth; fillBoxAxisEnd = isPositiveDirection ? fillBox.right : fillBox.left; currentCopyStartAxis = isPositiveDirection ? selectionBox.left : selectionBox.right; } else { // Vertical fill isPositiveDirection = fillBox.bottom > selectionBox.bottom; axisIncrement = isPositiveDirection ? selectionHeight : -selectionHeight; wholeCopies = fillBoxHeight / selectionHeight; fillBoxAxisEnd = isPositiveDirection ? fillBox.top : fillBox.bottom; currentCopyStartAxis = isPositiveDirection ? selectionBox.bottom : selectionBox.top; } /** A +1/-1 multiplier to allow us to use one comparison symbol, ">", for both positive and negative directions. */ const direction = isPositiveDirection ? 1n : -1n; const edit: Edit = { changes: [], state: { local: [], global: [] } }; // First, delete all pieces in the fill box. removeAllPieces(gamefile, edit, piecesInPasteBox); // Cache frequently-used references for slightly better performance const specialRights = gamefile.boardsim.state.global.specialRights; const getKey = coordutil.getKeyFromCoords; // Iterate over each whole copy, plus one additional for a partial if needed for (let i = 1n; i <= wholeCopies + 1n; i++) { currentCopyStartAxis += axisIncrement; /** Whether this iteration can only fit a partial copy. */ const partial: boolean = i === wholeCopies + 1n; if (partial && currentCopyStartAxis * direction > fillBoxAxisEnd * direction) break; // No more space to fill even a partial box (a whole number of copies fit exactly) // Add all the pieces from the selection box, translated to this copy's position for (const piece of piecesInSelection) { // Determine the translated coordinates for this piece in this copy const translatedCoords: Coords = isHorizontal ? [piece.coords[0] + axisIncrement * i, piece.coords[1]] : [piece.coords[0], piece.coords[1] + axisIncrement * i]; // Only add if within fill box (only might exceed it on the final partial copy) if (partial && !bounds.boxContainsSquare(fillBox, translatedCoords)) continue; // Queue the addition of the piece at its new location const hasSpecialRights = specialRights.has(getKey(piece.coords)); edithistory.queueAddPiece( gamefile, edit, translatedCoords, piece.type, hasSpecialRights, ); } } // Apply the collective edit and add it to the history applyEdit(gamefile, mesh, edit); // Update the selection area to be the box containing both the original selection and the filled area const newBox: BoundingBox = bounds.mergeBoundingBoxDoubles(selectionBox, fillBox); selectiontool.setSelection([newBox.left, newBox.top], [newBox.right, newBox.bottom]); } // Action Button Transformations ------------------------------------------------ /** Deletes the given selection box. */ function Delete(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { const piecesInSelection: Piece[] = getPiecesInBox(gamefile, box); const edit: Edit = { changes: [], state: { local: [], global: [] } }; removeAllPieces(gamefile, edit, piecesInSelection); applyEdit(gamefile, mesh, edit); } /** Copies the given selection box. */ function Copy(gamefile: FullGame, box: BoundingBox): void { const piecesInSelection: Piece[] = getPiecesInBox(gamefile, box); // Modify the pieces to include specialrights state // Cache frequently-used references for slightly better performance const specialRights = gamefile.boardsim.state.global.specialRights; const getKey = coordutil.getKeyFromCoords; // Modify the existing array in place to avoid performance hit of a new array. // Reverse loop that avoids re-evaluating length each iteration for (let i = piecesInSelection.length - 1; i >= 0; i--) { const p = piecesInSelection[i] as StatePiece; p.specialrights = specialRights.has(getKey(p.coords)); } clipboard = piecesInSelection as StatePiece[]; clipboardBox = box; } /** Pastes the copied region in whole multiples to fill the target box, but not exceed it. */ function Paste(gamefile: FullGame, mesh: Mesh, targetBox: BoundingBox): void { if (!clipboard || !clipboardBox) return; // Nothing to paste // Determine the dimensions of the clipboard box const clipboardWidth: bigint = clipboardBox.right - clipboardBox.left + 1n; const clipboardHeight: bigint = clipboardBox.top - clipboardBox.bottom + 1n; // Dimensions of the target box (current selection area to paste in) const targetBoxWidth: bigint = targetBox.right - targetBox.left + 1n; const targetBoxHeight: bigint = targetBox.top - targetBox.bottom + 1n; // Determine how many whole copies fit in the target box, in both dimensions, with a minimum of 1. const copiesX: bigint = bimath.max(targetBoxWidth / clipboardWidth, 1n); const copiesY: bigint = bimath.max(targetBoxHeight / clipboardHeight, 1n); // The actual paste box dimensions is the minimum box that fits all whole copies const actualPasteBoxWidth: bigint = clipboardWidth * copiesX; const actualPasteBoxHeight: bigint = clipboardHeight * copiesY; const actualPasteBox: BoundingBox = { left: targetBox.left, right: targetBox.left + actualPasteBoxWidth - 1n, bottom: targetBox.top - actualPasteBoxHeight + 1n, top: targetBox.top, }; // Determine the translation vector from top-left of clipboard to top-left of target box const clipboardCoords: Coords = [clipboardBox.left, clipboardBox.top]; const targetBoxCoords: Coords = [targetBox.left, targetBox.top]; const translation: Coords = coordutil.subtractCoords(targetBoxCoords, clipboardCoords); const edit: Edit = { changes: [], state: { local: [], global: [] } }; // First, delete all pieces in the actual paste box. const piecesInPasteBox: Piece[] = getPiecesInBox(gamefile, actualPasteBox); removeAllPieces(gamefile, edit, piecesInPasteBox); // Iterate over each copy position for (let x = 0n; x < copiesX; x++) { for (let y = 0n; y < copiesY; y++) { // Determine translation for this copy const thisTranslation: Coords = [ translation[0] + clipboardWidth * x, translation[1] + clipboardHeight * -y, ]; // Now, add all pieces from the clipboard, translated to this copy's position for (const piece of clipboard) { const translatedCoords = coordutil.addCoords(piece.coords, thisTranslation); // Queue the addition of the piece at its new location edithistory.queueAddPiece( gamefile, edit, translatedCoords, piece.type, piece.specialrights, ); } } } // Apply the collective edit and add it to the history applyEdit(gamefile, mesh, edit); // Update the selection area to the actual paste box selectiontool.setSelection( [actualPasteBox.left, actualPasteBox.top], [actualPasteBox.right, actualPasteBox.bottom], ); } /** Flips the selection box horizontally. */ function FlipHorizontal(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { Reflect(gamefile, mesh, box, 0); // Reflect across the X-axis } /** Flips the selection box vertically. */ function FlipVertical(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { Reflect(gamefile, mesh, box, 1); // Reflect across the Y-axis } /** * Reflects the selection box across a given axis. * @param axis The axis to reflect across (0 for X, 1 for Y). */ function Reflect(gamefile: FullGame, mesh: Mesh, box: BoundingBox, axis: 0 | 1): void { // Determine the bounds for calculating the reflection line based on the axis const [bound1, bound2] = axis === 0 ? [box.left, box.right] : [box.bottom, box.top]; // Calculate the reflection line with BigDecimals, for decimal precision. // 1 precision is enough to perfectly represent 1/2 increments, which is the finest we need. const bound1BD: BigDecimal = bd.fromBigInt(bound1, 1); const bound2BD: BigDecimal = bd.fromBigInt(bound2, 1); const sum: BigDecimal = bd.add(bound1BD, bound2BD); const reflectionLine: BigDecimal = bd.divide(sum, TWO, 0); // Working precision isn't needed because the quotient is rational // These haven't changed from the original selection box const selectionCorners: [Coords, Coords] = [ [box.left, box.top], [box.right, box.bottom], ]; // A function for controlling each piece's new state const transformer = (piece: Piece): { coords: Coords; type: number } => { // Reflect the piece's coordinate on the chosen axis const coordToReflect = piece.coords[axis]; const coordBD: BigDecimal = bd.fromBigInt(coordToReflect, 1); const distanceFromLine: BigDecimal = bd.subtract(coordBD, reflectionLine); const reflectedCoordBD: BigDecimal = bd.subtract(reflectionLine, distanceFromLine); // We already know it's a perfect integer so this doesn't lose precision const reflectedCoord: bigint = bd.toBigInt(reflectedCoordBD); // Create the new coordinates, modifying only the reflected axis const reflectedCoords: Coords = [...piece.coords]; // Create a mutable copy reflectedCoords[axis] = reflectedCoord; return { coords: reflectedCoords, type: piece.type }; }; // Execute the transformation Transform(gamefile, mesh, box, box, selectionCorners, transformer); } /** Rotates the selection 90 degrees to the left (counter-clockwise). */ function RotateLeft(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { Rotate(gamefile, mesh, box, false); // false for counter-clockwise } /** Rotates the selection 90 degrees to the right (clockwise). */ function RotateRight(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { Rotate(gamefile, mesh, box, true); // true for clockwise } /** Rotates the selection 90 degrees clockwise or counter-clockwise. */ function Rotate(gamefile: FullGame, mesh: Mesh, box: BoundingBox, clockwise: boolean): void { // Calculate the pivot point for rotation. const sumXEdgesBD = bd.fromBigInt(box.left + box.right, 1); const sumYEdgesBD = bd.fromBigInt(box.bottom + box.top, 1); const pivot: BDCoords = [ bd.divide(sumXEdgesBD, TWO, 0), // Working precision isn't needed because the quotient is rational bd.divide(sumYEdgesBD, TWO, 0), ]; // Adjust pivot for unstable rotations. // If that point is unstable, shift it by 0.5 to make it so. // Stable = In them middle of a square, or at a corner between squares. // Unstable = On an edge between squares, rotating the pieces would place them at floating point coords. // These work because with a precision of 1, only .0 and .5 fractional parts are possible. const selectionWidthXISEven = !bd.isInteger(pivot[0]); const selectionHeightYISEven = !bd.isInteger(pivot[1]); // If both dimensions are equal in evenness/oddness, then the pivot is stable (on a square or corner) // Otherwise, the rotation around an unstable pivot point on an edge causes pieces coordinates to not be integers. if (selectionWidthXISEven !== selectionHeightYISEven) { // This logic for parity, operation, and axis choice ensures that any sequence of // left/right rotations doesn't result in bias towards one vector. const thisParity = clockwise ? rotationParity : !rotationParity; // Use opposite parity for CCW const thisAxis = clockwise ? 1 : 0; // Shift Y axis for CW, X axis for CCW const operation = thisParity ? bd.add : bd.subtract; pivot[thisAxis] = operation(pivot[thisAxis], HALF); rotationParity = !rotationParity; } // Calculate the rotated selection box const rotatedBoxCorner1: Coords = rotatePoint([box.left, box.top], pivot, clockwise); const rotatedBoxCorner2: Coords = rotatePoint([box.right, box.bottom], pivot, clockwise); const rotatedBox: BoundingBox = { left: bimath.min(rotatedBoxCorner1[0], rotatedBoxCorner2[0]), right: bimath.max(rotatedBoxCorner1[0], rotatedBoxCorner2[0]), bottom: bimath.min(rotatedBoxCorner1[1], rotatedBoxCorner2[1]), top: bimath.max(rotatedBoxCorner1[1], rotatedBoxCorner2[1]), }; const newSelectionCorners: [Coords, Coords] = [rotatedBoxCorner1, rotatedBoxCorner2]; // A function controlling how each piece is transformed const transformer = (piece: Piece): { coords: Coords; type: number } => ({ coords: rotatePoint(piece.coords, pivot, clockwise), type: piece.type, }); // Execute the transformation Transform(gamefile, mesh, box, rotatedBox, newSelectionCorners, transformer); } /** * Rotates a point around a pivot 90 degrees clockwise or counter-clockwise. * @param point The point to rotate. * @param pivot The pivot point to rotate around. MUST BE IN THE middle of a square, or on a corner between squares, otherwise there will be precision loss when rounding the rotated point to integers. * @param clockwise Whether to rotate clockwise (true) or counter-clockwise (false). * @returns The rotated point. */ function rotatePoint(point: Coords, pivot: BDCoords, clockwise: Boolean): Coords { // Represent coord as BDCoords for high precision const pointBD = bdcoords.FromCoords(point, 1); // 1. Translate to origin to get relative coordinates const relativeX = bd.subtract(pointBD[0], pivot[0]); const relativeY = bd.subtract(pointBD[1], pivot[1]); // 2. Apply the 90 degree rotation based on direction // For CCW (+90): direction = 1, (x, y) -> (-y, x) // For CW (-90): direction = -1, (x, y) -> (y, -x) const direction = clockwise ? NEGONE : ONE; // rotatedRelativeX = -direction * relativeY const rotatedRelativeX = bd.multiply(relativeY, bd.negate(direction)); // rotatedRelativeY = direction * relativeX const rotatedRelativeY = bd.multiply(relativeX, direction); // 3. Translate back from the origin const finalX = bd.add(rotatedRelativeX, pivot[0]); const finalY = bd.add(rotatedRelativeY, pivot[1]); return [bd.toBigInt(finalX), bd.toBigInt(finalY)]; } /** Inverts the color of the pieces in the selection box. */ function InvertColor(gamefile: FullGame, mesh: Mesh, box: BoundingBox): void { // These haven't changed from the original selection box const selectionCorners: [Coords, Coords] = [ [box.left, box.top], [box.right, box.bottom], ]; // A function for controlling each piece's new state const transformer = (piece: Piece): { coords: Coords; type: number } => { const newType = typeutil.invertType(piece.type); return { coords: piece.coords, type: newType }; }; // Execute the transformation Transform(gamefile, mesh, box, box, selectionCorners, transformer); } // Transformation Helpers ----------------------------------------------------- /** * Executes a transformation, where pieces within the selection may be moved or modified. * Handles clearing the destination area, clearing the selection area, * moving the pieces, and updating the selection area. */ function Transform( gamefile: FullGame, mesh: Mesh, sourceBox: BoundingBox, destinationBox: BoundingBox, newSelectionCorners: [Coords, Coords], /** A function to transform an individual piece's coordinates and type. */ transformer: (_piece: Piece) => { coords: Coords; type: number }, ): void { const piecesInSource = getPiecesInBox(gamefile, sourceBox); const piecesInDestination = getPiecesInBox(gamefile, destinationBox); // Determine whether the destination box is entirely contained within the border const withinBorder = gamefile.basegame.gameRules.worldBorder ? bounds.boxContainsBox(gamefile.basegame.gameRules.worldBorder, destinationBox) : true; const edit: Edit = { changes: [], state: { local: [], global: [] } }; // Clear the destination area of any pieces not part of the original selection for (const piece of piecesInDestination) { if (bounds.boxContainsSquare(sourceBox, piece.coords)) continue; edithistory.queueRemovePiece(gamefile, edit, piece); } // Delete all pieces in the original selection area removeAllPieces(gamefile, edit, piecesInSource); // Cache frequently-used references for slightly better performance const specialRights = gamefile.boardsim.state.global.specialRights; const getKey = coordutil.getKeyFromCoords; // Add all pieces in the original selection area, but transformed for (const piece of piecesInSource) { // Determine the new state for this piece const transformed = transformer(piece); // Skip if the destination is out of bounds if ( !withinBorder && !bounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder!, transformed.coords) ) continue; // Queue the addition of the piece at its new location const hasSpecialRights = specialRights.has(getKey(piece.coords)); edithistory.queueAddPiece( gamefile, edit, transformed.coords, transformed.type, hasSpecialRights, ); } // Apply the collective edit and add it to the history applyEdit(gamefile, mesh, edit); // Update the selection area selectiontool.setSelection(newSelectionCorners[0], newSelectionCorners[1]); } // Utility ------------------------------------------------------------ /** Queues all the pieces in the list to be removed in this Edit. */ function removeAllPieces(gamefile: FullGame, edit: Edit, pieces: Piece[]): void { for (const piece of pieces) { edithistory.queueRemovePiece(gamefile, edit, piece); } } /** Applies the provided edit and adds it to the history. */ function applyEdit(gamefile: FullGame, mesh: Mesh, edit: Edit): void { if (edit.changes.length === 0 && edit.state.global.length === 0) return; // No changes made => don't need to apply // Apply the collective edit and add it to the history edithistory.runEdit(gamefile, mesh, edit, true); edithistory.addEditToHistory(edit); } /** Calculates all pieces within the given box area. */ function getPiecesInBox(gamefile: FullGame, intBox: BoundingBox): Piece[] { const o = gamefile.boardsim.pieces; // Organized pieces const selectionBoxWidth: bigint = intBox.right - intBox.left; const selectionBoxHeight: bigint = intBox.top - intBox.bottom; // The dimensions of the selection determine which organized line axis // we'll be reading from, for greater performance. const axis: 0 | 1 = selectionBoxWidth >= selectionBoxHeight ? 0 : 1; const coordPositions: bigint[] = axis === 0 ? o.XPositions : o.YPositions; const step: Vec2 = axis === 0 ? [1n, 0n] : [0n, 1n]; const slideKey = vectors.getKeyFromVec2(step); const lines = o.lines.get(slideKey)!; // All lines of pieces going in one vector direction /** Running list of all pieces within the box. */ const piecesInSelection: Piece[] = []; // The start and end keys of those lines const linesStart = axis === 0 ? intBox.bottom : intBox.left; const linesEnd = axis === 0 ? intBox.top : intBox.right; const rangeStart = axis === 0 ? intBox.left : intBox.bottom; const rangeEnd = axis === 0 ? intBox.right : intBox.top; const numOfLines: bigint = linesEnd - linesStart + 1n; const lineEntries: bigint = BigInt(lines.size); // If the total number of line entries is less than the number of lines in the selection box, // iterate through them instead. It's more efficient. if (lineEntries <= numOfLines) { for (const thisLine of lines.values()) { for (let a = 0; a < thisLine.length; a++) { const idx = thisLine[a]!; // Check if the piece coords is within the selection area const coords: Coords = boardutil.getCoordsFromIdx(o, idx); if (bounds.boxContainsSquare(intBox, coords)) { piecesInSelection.push(boardutil.getDefinedPieceFromIdx(o, idx)); } } } } else { // Iterate through each line to find all pieces within the selection box for (let i = linesStart; i <= linesEnd; i++) { const coordsForKey: Coords = axis === 0 ? [0n, i] : [i, 0n]; // 0n makes no difference for the final key of the line, it can be anything. const lineKey = organizedpieces.getKeyFromLine(step, coordsForKey); const thisLine: number[] | undefined = lines.get(lineKey); if (!thisLine) continue; // Empty line for (let a = 0; a < thisLine.length; a++) { const idx = thisLine[a]!; // The piece is in the selection area if its axis coord is within bounds const thisCoord: bigint = coordPositions[idx]!; if (thisCoord >= rangeStart && thisCoord <= rangeEnd) { piecesInSelection.push(boardutil.getDefinedPieceFromIdx(o, idx)); } } } } return piecesInSelection; } // API ------------------------------------------------------------------------- /** Drops the reference to the clipboard contents. */ function resetState(): void { clipboard = undefined; clipboardBox = undefined; } // Exports -------------------------------------------------------------------- export default { // Selection Box Transformations Translate, Fill, // Action Button Transformations Delete, Copy, Paste, FlipHorizontal, FlipVertical, RotateLeft, RotateRight, InvertColor, // API resetState, }; ================================================ FILE: src/client/scripts/esm/game/chess/checkmatepractice.ts ================================================ // src/client/scripts/esm/game/chess/checkmatepractice.ts /** * This script handles checkmate practice logic */ import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js'; import type { Coords, CoordsKey } from '../../../../../shared/chess/util/coordutil.js'; import bimath from '../../../../../shared/util/math/bimath.js'; import variant from '../../../../../shared/chess/variants/variant.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import validcheckmates from '../../../../../shared/chess/util/validcheckmates.js'; import { players as p, ext as e, rawTypes as r, } from '../../../../../shared/chess/util/typeutil.js'; import docutil from '../../util/docutil.js'; import gameslot from './gameslot.js'; import selection from '../chess/selection.js'; import gameloader from './gameloader.js'; import enginegame from '../misc/enginegame.js'; import guipractice from '../gui/guipractice.js'; import guigameinfo from '../gui/guigameinfo.js'; import LocalStorage from '../../util/LocalStorage.js'; import movesequence from '../chess/movesequence.js'; import validatorama from '../../util/validatorama.js'; import { engineDictionary } from './engines/engine.js'; import { retryFetch, RetryFetchOptions } from '../../util/httputils.js'; // Variables ---------------------------------------------------------------------------- /** These checkmates we may place the black king nearer to the white pieces. */ const checkmatesWithBlackRoyalNearer = [ '1K1Q1N-1k', '1Q1R1N-1k', '1Q2N-1k', '1Q1N1B-1k', '1K1N2B1B-1k', '1K2N1B1B-1k', '1K1R1N1B-1k', '1K1AR1R-1k', '1K1CH1N-1k', '1K1R2N-1k', '2K1R-1k', '1K2N6B-1k', '1K2HA1B-1k', '1K3HA-1k', ]; const nameOfCompletedCheckmatesInStorage: string = 'checkmatePracticeCompletion'; /** * A list of checkmate strings we have beaten * [ "2Q-1k", "3R-1k", "2CH-1k"] * * This will be initialized when guipractice calls {@link updateCompletedCheckmates} for the first time! * If we initialize it right here, we crash in production, because LocalStorage is not defined yet. * @type {string[]} */ let completedCheckmates: string[]; const expiryOfCompletedCheckmatesMillis: number = 1000 * 60 * 60 * 24 * 365; // 1 year /** Whether we are in a checkmate practice engine game. */ let inCheckmatePractice: boolean = false; /** Whether the player is allowed to undo a move in the current position. */ let undoingIsLegal: boolean = false; // Functions ---------------------------------------------------------------------------- // Set a listener for the logout event, to refresh the checkmates list document.addEventListener('logout', updateCompletedCheckmates); function setUndoingIsLegal(value: boolean): void { undoingIsLegal = value; guigameinfo.update_GameControlButtons(value); } function areInCheckmatePractice(): boolean { return inCheckmatePractice; } /** * Starts a checkmate practice game */ function startCheckmatePractice(checkmateSelectedID: string): void { console.log('Loading practice checkmate game.'); inCheckmatePractice = true; setUndoingIsLegal(false); initListeners(); const position = generateCheckmateStartingPosition(checkmateSelectedID); const specialRights = new Set(); const variantOptions: VariantOptions = { fullMove: 1, position, state_global: { specialRights }, gameRules: variant.getBareMinimumGameRules(), }; const currentEngine = 'engineCheckmatePractice' as const; const options = { event: 'Infinite chess checkmate practice', timeControl: '-' as const, variant: null, youAreColor: p.WHITE, currentEngine, engineConfig: { checkmateSelectedID: checkmateSelectedID, engineTimeLimitPerMoveMillis: engineDictionary[currentEngine].defaultTimeLimitPerMoveMillis, }, variantOptions, showGameControlButtons: true as true, }; gameloader.startEngineGame(options); } function onGameUnload(): void { closeListeners(); inCheckmatePractice = false; setUndoingIsLegal(false); } function initListeners(): void { document.addEventListener('guigameinfo-undoMove', undoMove); document.addEventListener('guigameinfo-restart', restartGame); } function closeListeners(): void { document.removeEventListener('guigameinfo-undoMove', undoMove); document.removeEventListener('guigameinfo-restart', restartGame); } /** * This method generates a random starting position object for a given checkmate practice ID * @param checkmateID - a string containing the ID of the selected checkmate practice problem * @returns a starting position object corresponding to that ID */ function generateCheckmateStartingPosition(checkmateID: string): Map { // error if user somehow submitted invalid checkmate ID if (!Object.values(validcheckmates.validCheckmates).flat().includes(checkmateID)) throw Error('User tried to play invalid checkmate practice.'); // place the black king not so far away for specific variants const blackroyalnearer: boolean = checkmatesWithBlackRoyalNearer.includes(checkmateID); const position = new Map(); // the position to be generated let blackpieceplaced: boolean = false; // monitors if a black piece has already been placed let whitebishopparity: number = Math.floor(Math.random() * 2); // square color of first white bishop batch // read the elementID and convert it to a position const piecelist: RegExpMatchArray | null = checkmateID.match(/[0-9]+[a-zA-Z]+/g); if (!piecelist) return position; for (const entry of piecelist) { let amount: number = parseInt(entry.match(/[0-9]+/)![0]); // number of pieces to be placed const strpiece: string = entry.match(/[a-zA-Z]+/)![0]; // piecetype to be placed const piece: number = icnconverter.getTypeFromAbbr(strpiece); // place amount many pieces of type piece while (amount !== 0) { if (typeutil.getColorFromType(piece) === p.WHITE) { if (blackpieceplaced) throw Error('Must place all white pieces before placing black pieces.'); // randomly generate white piece coordinates in square around origin const x: bigint = BigInt(Math.floor(Math.random() * (blackroyalnearer ? 7 : 11))) - (blackroyalnearer ? 3n : 5n); const y: bigint = BigInt(Math.floor(Math.random() * (blackroyalnearer ? 7 : 11))) - (blackroyalnearer ? 3n : 5n); const key: CoordsKey = coordutil.getKeyFromCoords([x, y]); // check if square is occupied and white bishop parity is fulfilled if ( !position.has(key) && !(piece === r.BISHOP + e.W && Number((x + y) % 2n) !== whitebishopparity) ) { position.set(key, piece); amount -= 1; } } else { // randomly generate black piece coordinates at a distance const x: bigint = BigInt(Math.floor(Math.random() * 3)) + (blackroyalnearer ? 8n : 12n); const y: bigint = BigInt(Math.floor(Math.random() * (blackroyalnearer ? 17 : 35))) - (blackroyalnearer ? 9n : 17n); const key: CoordsKey = coordutil.getKeyFromCoords([x, y]); // check if square is occupied or potentially threatened if (!position.has(key) && squareNotInSight(key, position)) { position.set(key, piece); amount -= 1; blackpieceplaced = true; } } } // flip white bishop parity whitebishopparity = 1 - whitebishopparity; } return position; } /** * This method checks that the input square is not on the same row, column or diagonal as any key in the position Map * It also checks that it is not attacked by a knightrider * @param square - square of black piece * @param position - position Map containing all white pieces * @returns true or false, depending on if the square is in sight or not */ function squareNotInSight(square: CoordsKey, position: Map): boolean { const [sx, sy]: Coords = coordutil.getCoordsFromKey(square); for (const [key, value] of position) { const [x, y]: Coords = coordutil.getCoordsFromKey(key); if (x === sx || y === sy || bimath.abs(sx - x) === bimath.abs(sy - y)) return false; if (value === r.KNIGHTRIDER + e.W) { if ( bimath.abs(sx - x) === 2n * bimath.abs(sy - y) || 2n * bimath.abs(sx - x) === bimath.abs(sy - y) ) { return false; } } } return true; } /** * Only for dev testing * Erases checkmate practice progress in local storage * Call {@link checkmatepractice.eraseCheckmatePracticeProgressFromLocalStorage} in developer tools to use this */ function eraseCheckmatePracticeProgressFromLocalStorage(): void { LocalStorage.deleteItem(nameOfCompletedCheckmatesInStorage); console.log('DELETED all checkmate practice progress.'); if (!completedCheckmates) return; // Haven't open the checkmate practice menu yet, so it's not defined. completedCheckmates.length = 0; guipractice.updateCheckmatesBeaten(completedCheckmates); // Delete the 'beaten' class from all } /** * Updates completedCheckmates list and redraws the GUI by calling guipractice.updateCheckmatesBeaten() */ function updateCompletedCheckmates(): void { // Update completedCheckmates according to checkmates_beaten cookie, if it exists, and if we are logged in const cookieCheckmates: string | undefined = docutil.getCookieValue('checkmates_beaten'); if (validatorama.areWeLoggedIn() && cookieCheckmates !== undefined) { // console.log("checkmates_beaten cookie was present!"); completedCheckmates = decodeURIComponent(cookieCheckmates).match(/[^,]+/g) || []; // match() returns null if no matches } else { // Else, use LocalStorage as a fallback completedCheckmates = LocalStorage.loadItem(nameOfCompletedCheckmatesInStorage) || []; } guipractice.updateCheckmatesBeaten(completedCheckmates); } /** * Updates the completedCheckmates variable with the beaten checkmatePracticeID, * and sends a message to the server if the player is logged in */ async function markCheckmateBeaten(checkmatePracticeID: string): Promise { if (!completedCheckmates) throw Error('Cannot mark checkmate beaten when it was never initialized!'); if (!Object.values(validcheckmates.validCheckmates).flat().includes(checkmatePracticeID)) throw Error('User completed invalid checkmate practice.'); // Add the checkmate ID to the beaten list if (!completedCheckmates.includes(checkmatePracticeID)) completedCheckmates.push(checkmatePracticeID); console.log('Marked checkmate practice as completed!'); // Update LocalStorage and exit, if we are not logged in if (!validatorama.areWeLoggedIn()) { LocalStorage.saveItem( nameOfCompletedCheckmatesInStorage, completedCheckmates, expiryOfCompletedCheckmatesMillis, ); return; } // We ARE logged in. Send a POST request to tell the server we have beaten a new checkmate! // Configure the POST request const fetchInit: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, body: JSON.stringify({ new_checkmate_beaten: checkmatePracticeID }), }; const token: string | undefined = await validatorama.getAccessToken(); if (token) (fetchInit.headers as Record)['Authorization'] = `Bearer ${token}`; const retryOptions: RetryFetchOptions = { // With these settings, the fifth attempt occurs 1m 15s after the first. maxAttempts: 5, initialDelayMs: 5000, backoffFactor: 2, }; try { // Use retryFetch wrapper to try the same POST multiple times // until it succeeds. This is just in case of a server error, // or a server restart at the exact same time, thus making // them have to solve the same checkmate again. const response: Response = await retryFetch( '/api/update-checkmatelist', fetchInit, retryOptions, ); if (response.ok) { console.log('Server recorded checkmate completion successfully.'); // Do this now, since the server will have updated the cookie containing the completed checkmates guipractice.updateCheckmatesBeaten(completedCheckmates); } else { // Handle unsuccessful response // This means retries were exhausted on a 500, or it was a non-retryable response (e.g., 400, 401) // that the retryFetch logic didn't retry. const errorData = await response.json(); console.error( `Failed to update checkmate list on the server (final status ${response.status}) after all attempts:`, errorData.message || errorData, ); } } catch (error) { // This catch block handles cases where retries were exhausted on network errors. console.error( 'Error sending checkmate list to the server after all attempts (network/unhandled error):', error, ); } } /** Called when an engine game ends */ function onEngineGameConclude(): void { // Were we doing checkmate practice if (!inCheckmatePractice) return; // Not in checkmate practice const gameConclusion: GameConclusion | undefined = gameslot.getGamefile()!.basegame.gameConclusion; if (gameConclusion === undefined) throw Error('Game conclusion is undefined, should not have called onEngineGameConclude()'); // Did we win or lose? if (gameConclusion.victor === undefined) throw Error('Victor should never be undefined when concluding an engine game.'); if (!(enginegame.getOurColor() === gameConclusion.victor)) return; // Lost // WON!!! 🎉 // Add the checkmate to the list of completed! const checkmatePracticeID: string = guipractice.getCheckmateSelectedID(); markCheckmateBeaten(checkmatePracticeID); } /** * This function gets called by enginegame.ts whenever a human player submitted a move */ function registerHumanMove(): void { if (!inCheckmatePractice) return; // The engine game is not a checkmate practice game const { basegame } = gameslot.getGamefile()!; if (!undoingIsLegal && gamefileutility.isGameOver(basegame) && basegame.moves.length > 0) { // allow player to undo move if it ended the game setUndoingIsLegal(true); } else if (undoingIsLegal && !gamefileutility.isGameOver(basegame)) { // don't allow player to undo move while engine thinks setUndoingIsLegal(false); } } /** * This function gets called by enginegame.ts whenever an engine player submitted a move */ function registerEngineMove(): void { if (!inCheckmatePractice) return; // The engine game is not a checkmate practice game const { basegame } = gameslot.getGamefile()!; if (!undoingIsLegal && basegame.moves.length > 1) { // allow player to undo move after engine has moved setUndoingIsLegal(true); } } function undoMove(): void { if (!inCheckmatePractice) return console.error('Undoing moves is currently not allowed for non-practice mode games'); const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; if ( undoingIsLegal && (enginegame.isItOurTurn() || gamefileutility.isGameOver(gamefile.basegame)) && gamefile.basegame.moves.length > 0 ) { // > 0 catches scenarios where stalemate occurs on the first move setUndoingIsLegal(false); // go to latest move before undoing moves movesequence.viewFront(gamefile, mesh); // If it's their turn, only rewind one move. if (enginegame.isItOurTurn() && gamefile.basegame.moves.length > 1) movesequence.rewindMove(gamefile, mesh); movesequence.rewindMove(gamefile, mesh); selection.reselectPiece(); } } function restartGame(): void { if (!inCheckmatePractice) return console.error( 'Restarting games is currently not supported for non-practice mode games', ); gameloader.unloadGame(); // Unload current game startCheckmatePractice(guipractice.getCheckmateSelectedID()); } // Exports ------------------------------------------------------------------------------ export default { areInCheckmatePractice, startCheckmatePractice, onGameUnload, updateCompletedCheckmates, eraseCheckmatePracticeProgressFromLocalStorage, onEngineGameConclude, registerHumanMove, registerEngineMove, }; ================================================ FILE: src/client/scripts/esm/game/chess/clientmetadatautil.ts ================================================ // src/client/scripts/esm/game/chess/clientmetadatautil.ts /** * Client-side helpers for building and parsing ICN game metadata. */ import type { MetadataKey } from '../../../../../shared/chess/util/metadatautil.js'; import type { Condition, GameConclusion } from '../../../../../shared/chess/util/winconutil.js'; import type { MetaData, Rating, TimeControl } from '../../../../../shared/types.js'; import * as z from 'zod'; import timeutil from '../../../../../shared/util/timeutil.js'; import winconutil from '../../../../../shared/chess/util/winconutil.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; // Constants ----------------------------------------------------------------------- /** * The hardcoded English string used in ICN metadata to represent the human player * in engine and board-editor games. Metadata must always be in English. */ const YOU_NAME_ICN_METADATA = '(You)' as const; // Functions ----------------------------------------------------------------------- /** * Resolves a timestamp (ms since epoch) from UTCDate and UTCTime metadata strings. * Falls back to the current time if UTCDate is not provided. * If UTCDate is provided but UTCTime is not, midnight (00:00:00) is assumed. */ function resolveTimestampFromMetadata(UTCDate?: string, UTCTime?: string): number { if (UTCDate !== undefined) { return timeutil.convertUTCDateUTCTimeToTimeStamp(UTCDate, UTCTime); } return Date.now(); } /** * Builds a {@link MetaData} object for client-side games (local, engine, board editor). * Automatically populates `Site`, `Round`, `UTCDate`, and `UTCTime`. * @param event - The `Event` string describing the game. * @param timeControl - The time control string (e.g. `"600+5"`), or `"-"` for untimed. * @param utcTimestamp - The epoch-ms timestamp used for the `UTCDate`/`UTCTime` fields. */ function buildBaseGameMetadata( event: string, timeControl: TimeControl, utcTimestamp: number, ): MetaData { const { UTCDate, UTCTime } = timeutil.convertTimestampToUTCDateUTCTime(utcTimestamp); return { Event: event, Site: 'https://www.infinitechess.org/', Round: '-', TimeControl: timeControl, UTCDate, UTCTime, }; } /** * Helper function that uses generics to link the metadata key to its value type. * Inside the function typescript doesn't error when we are transferring the property. */ function copyMetadataField( target: MetaData, source: MetaData, key: K, ): void { // TS knows that target[key] and source[key] have the same type: MetaData[K] target[key] = source[key]; } /** Calculates the game conclusion from the Result metadata and termination CODE. */ function getGameConclusionFromResultAndTermination( result: string, termination: Condition, ): GameConclusion { // prettier-ignore const victor = result === '1-0' ? p.WHITE : result === '0-1' ? p.BLACK : result === '1/2-1/2' ? null : result === '*' ? undefined : ((): never => { throw Error(`Unsupported result (${result})!`); })(); const gameConclusion: any = { condition: termination }; // Only attach victor if it is defined if (victor !== undefined) gameConclusion.victor = victor; // Make sure it's type safe const parseResult = winconutil.gameConclusionSchema.safeParse(gameConclusion); if (!parseResult.success) throw new Error( `When parsing GameConclusion from metadata, condition "${termination}" and victor "${victor}" is an invalid combination. ZodError: ${z.prettifyError(parseResult.error)}`, ); return parseResult.data; } /** * Parses the elo and confidence from WhiteElo/BlackElo metadata. * ONLY HAS AS MUCH PRECISION as what's in the metadata. * DOES NOT KNOW whether their current rating is now confident, if thir WhiteElo/BlackElo was not confident. */ function getRatingFromWhiteBlackElo(whiteBlackElo: string): Rating { const [elo, emptyStr] = whiteBlackElo.split('?'); // emptyStr will be '' if the '?' is present, otherwise it will be undefined. return { value: Number(elo), confident: emptyStr === undefined, }; } // Exports ----------------------------------------------------------------------- export default { YOU_NAME_ICN_METADATA, resolveTimestampFromMetadata, buildBaseGameMetadata, copyMetadataField, getGameConclusionFromResultAndTermination, getRatingFromWhiteBlackElo, }; ================================================ FILE: src/client/scripts/esm/game/chess/copygame.ts ================================================ // src/client/scripts/esm/game/chess/copygame.ts /** * This script handles copying games */ import type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js'; import icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js'; import toast from '../gui/toast.js'; import docutil from '../../util/docutil.js'; import drawrays from '../rendering/highlights/annotations/drawrays.js'; import drawsquares from '../rendering/highlights/annotations/drawsquares.js'; import boardeditor from '../boardeditor/boardeditor.js'; import gamecompressor from './gamecompressor.js'; import gameslot, { PresetAnnotes } from './gameslot.js'; const variantsTooBigToCopyPositionToICN: VariantCode[] = [ 'Omega_Squared', 'Omega_Cubed', 'Omega_Fourth', '5D_Chess', ]; /** * Copies the current game to the clipboard in ICN notation. * This callback is called when the "Copy Game" button is pressed. * @param copySinglePosition - If true, only copy the current position, not the entire game. It won't have the moves list. */ function copyGame(copySinglePosition: boolean): void { if (boardeditor.areInBoardEditor()) return; // Editor has its own handler const gamefile = gameslot.getGamefile()!; const variantCode = gamefile.boardsim.variant; // Add the preset annotation overrides from the previously pasted game, if present. const preset_squares = drawsquares.getPresetOverrides(); const preset_rays = drawrays.getPresetOverrides(); let presetAnnotes: PresetAnnotes | undefined; if (preset_squares || preset_rays) { presetAnnotes = {}; if (preset_squares) presetAnnotes.squares = preset_squares; if (preset_rays) presetAnnotes.rays = preset_rays; } const longformatIn = gamecompressor.compressGamefile( gamefile, copySinglePosition, presetAnnotes, ); const largeGame: boolean = variantCode !== null && variantsTooBigToCopyPositionToICN.includes(variantCode); // Also specify the position if we're copying a single position, so the starting position will be different. const skipPosition: boolean = largeGame && !copySinglePosition; const shortformat: string = icnconverter.LongToShort_Format(longformatIn, { skipPosition, compact: false, spaces: false, comments: false, make_new_lines: false, move_numbers: false, }); docutil.copyToClipboard(shortformat); toast.show(translations.copypaste.copied_game); } export default { copyGame, }; ================================================ FILE: src/client/scripts/esm/game/chess/engines/engine.ts ================================================ // src/client/scripts/esm/game/chess/engines/engine.ts /* * This module contains the centralized data structure for all engines. * Add a new entry to engineDictionary when adding a new engine. */ import hydrochess_card from './enginecards/hydrochess_card.js'; // Types ------------------------------------------------------------------------ /** A single engine entry object in the engine dictionary. */ export interface Engine { /** * World border distance for this engine. * Engine games have a world border enabled so as to keep the position within safe floating point range. * If the variant's world border is smaller, that will be used instead. */ worldBorder: bigint; /** * The number of milliseconds the engine thinks when Time Control is unlimited. * May vary from engine to engine because of different engine speeds and requirements. */ defaultTimeLimitPerMoveMillis: number; /** Display name shown in the UI for this engine. */ displayName: string; /** The maximum strength level supported by this engine. */ maxStrengthLevel: number; } /** Union of all valid engine names, derived from the keys of engineDictionary. */ export type ValidEngine = keyof typeof engineDictionary; // Constants -------------------------------------------------------------------- /** * Centralized data structure for all engine properties. * Add a new entry here when adding a new engine. */ export const engineDictionary = { engineCheckmatePractice: { // worldBorder: BigInt(Number.MAX_SAFE_INTEGER), // FREEZES practice checkmate engine if you move to the border worldBorder: BigInt(1e15), // 1 Quadrillion (~11% the distance of Number.MAX_SAFE_INTEGER) defaultTimeLimitPerMoveMillis: 500, displayName: 'Practice Bot', maxStrengthLevel: 1, }, hydrochess: { worldBorder: hydrochess_card.I64_MAX - 2000n, defaultTimeLimitPerMoveMillis: 4000, displayName: 'HydroChess', maxStrengthLevel: 3, }, } satisfies { [key: string]: Engine }; // Functions -------------------------------------------------------------------- /** * Returns a formatted engine name string, optionally including its strength level. * If the provided strength level is the maximum for the engine, it is omitted. */ export function getFormattedEngineName(engineName: ValidEngine, strengthLevel?: number): string { const name = engineDictionary[engineName].displayName; const maxLevel = engineDictionary[engineName].maxStrengthLevel; return strengthLevel !== undefined && strengthLevel !== maxLevel ? `${name} (Level ${strengthLevel})` : name; } ================================================ FILE: src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts ================================================ // src/client/scripts/esm/game/chess/engines/engineCheckmatePractice.ts /** * This script runs a chess engine for checkmate practice that computes the best move for the black royal piece. * It is called as a WebWorker from enginegame.js so that it can run asynchronously from the rest of the website. * You may specify a different engine to be used by specifying a different engine name in the gameOptions when initializing an engine game. * * @author Andreas Tsevas */ import type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Coords, CoordsKey, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import jsutil from '../../../../../../shared/util/jsutil.js'; import organizedpieces from '../../../../../../shared/chess/logic/organizedpieces.js'; import { primalityTest } from '../../../../../../shared/util/isprime.js'; import { detectInsufficientMaterial } from '../../../../../../shared/chess/logic/insufficientmaterial.js'; import icnconverter, { MoveCoords } from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import { rawTypes as r, ext as e, players as p, numTypes, } from '../../../../../../shared/chess/util/typeutil.js'; // If the Webworker during creation is not declared as a module, than type imports will have to be imported this way: // type gamefile = import("../../chess/logic/gamefile").default; // type Coords = import("../../chess/util/coordutil").Coords; /** * Let the main thread know that the Worker has finished fetching and * its code is now executing! We may now hide the spinny pawn loading animation. */ postMessage('readyok'); // Here, the engine webworker received messages from the outside self.onmessage = function (e: MessageEvent): void { const message = e.data as { stringGamefile: string; engineConfig: { checkmateSelectedID: string; engineTimeLimitPerMoveMillis: number }; requestGeneratedMoves: boolean; }; if (message.requestGeneratedMoves) return; // ignore generated moves requests in this engine, this doesn't support sending them input_gamefile = JSON.parse(message.stringGamefile, jsutil.parseReviver); // parse the gamefile (it's nested functions won't be included) // console.log("input_gamefile", jsutil.deepCopyObject(input_gamefile)); checkmateSelectedID = message.engineConfig.checkmateSelectedID; engineTimeLimitPerMoveMillis = message.engineConfig.engineTimeLimitPerMoveMillis; globallyBestScore = -Infinity; globalSurvivalPlies = 0; globallyBestVariation = {}; if (!engineInitialized) initEvalWeightsAndSearchProperties(); // initialize the eval function weights and global search properties engineStartTime = Date.now(); enginePositionCounter = 0; runEngine(); }; /** Seeded RNG function, will be initialized in runEngine() */ let rand: Function; /** Whether the engine has already been initialized for the current game */ let engineInitialized: boolean = false; /** Externally supplied gamefile */ let input_gamefile: FullGame; /** Start time of current engine calculation in millis */ let engineStartTime: number; /** The number of positions evaluated by this engine in total during current calculation */ let enginePositionCounter: number; /** Time limit for the engine to think in milliseconds */ let engineTimeLimitPerMoveMillis: number; // the ID of the currently selected checkmate let checkmateSelectedID: string; // The informtion that is currently considered best by this engine let globallyBestScore: number; let globalSurvivalPlies: number; let globallyBestVariation: { [key: number]: [number, DoubleCoords] }; // e.g. { 0: [NaN, [1,0]], 1: [3,[2,4]], 2: [NaN, [-1,1]], 3: [2, [5,6]], ... } = { 0: black move, 1: white piece index & move, 2: black move, ... } // the real coordinates of the black royal piece in the gamefile let gamefile_royal_coords: DoubleCoords; // Black royal piece properties. The black royal piece is always at square [0,0] // prettier-ignore const king_moves: DoubleCoords[] = [ [-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [0, -1], [1, -1], ]; // prettier-ignore const centaur_moves: DoubleCoords[] = [ [-1, 2], [1, 2], [-2, 1], [-1, 1], [0, 1], [1, 1], [2, 1], [-1, 0], [1, 0], [-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1], [-1, -2], [1, -2] ]; let royal_moves: DoubleCoords[]; // king_moves or centaur_moves let royal_type: 'k' | 'rc'; // "k" or "rc" // White pieces. Their coordinates are relative to the black royal let start_piecelist: number[]; // list of white pieces in starting position, like [3,4,4,4,2, ... ]. Meaning of numbers given by pieceNameDictionary let start_coordlist: DoubleCoords[]; // list of tuples, like [[2,3], [5,6], [6,7], ...], pieces are corresponding to ordering in start_piecelist // only used for parsing in the position const pieceNameDictionary: { [pieceType: number]: number } = { // 0 corresponds to a captured piece [r.QUEEN + e.W]: 1, [r.ROOK + e.W]: 2, [r.BISHOP + e.W]: 3, [r.KNIGHT + e.W]: 4, [r.KING + e.W]: 5, [r.PAWN + e.W]: 6, [r.AMAZON + e.W]: 7, [r.HAWK + e.W]: 8, [r.CHANCELLOR + e.W]: 9, [r.ARCHBISHOP + e.W]: 10, [r.KNIGHTRIDER + e.W]: 11, [r.HUYGEN + e.W]: 12, }; function invertPieceNameDictionary(json: { [key: string]: number }): { [key: number]: number } { const inv: { [key: number]: number } = {}; for (const key in json) { inv[json[key]!] = Number(key); } return inv; } const invertedPieceNameDictionaty = invertPieceNameDictionary(pieceNameDictionary); // legal move storage for pieces in piecelist // prettier-ignore const pieceTypeDictionary: { [key: number]: { rides?: DoubleCoords[], jumps?: DoubleCoords[], is_royal?: boolean, is_pawn?: boolean, is_huygen?: boolean } } = { 0: {}, // 0 corresponds to a captured piece 1: {rides: [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]}, // queen 2: {rides: [[1, 0], [0, 1], [-1, 0], [0, -1]]}, // rook 3: {rides: [[1, 1], [-1, -1], [1, -1], [-1, 1]]}, // bishop 4: {jumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // knight 5: {jumps: [[-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [0, -1], [1, -1]], is_royal: true}, // king 6: {jumps: [[0, 1]], is_pawn: true}, //pawn 7: {rides: [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]], jumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // amazon 8: {jumps: [[2, 0], [3, 0], [2, 2], [3, 3], [0, 2], [0, 3], [-2, 2], [-3, 3], [-2, 0], [-3, 0], [-2, -2], [-3, -3], [0, -2], [0, -3], [2, -2], [3, -3]]}, //hawk 9: {rides: [[1, 0], [0, 1], [-1, 0], [0, -1]], jumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // chancellor 10: {rides: [[1, 1], [-1, -1], [1, -1], [-1, 1]], jumps: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // archbishop 11: {rides: [[1, 2], [-1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, 1], [-2, -1]]}, // knightrider 12: {jumps: [[2, 0], [-2, 0], [0, 2], [0, -2]], rides: [[1, 0], [0, 1], [-1, 0], [0, -1]], is_huygen: true } // huygen }; // define what "short range" means for each piece. Jump moves to at least as near as the values in this table are considered shortrange const shortRangeJumpDictionary: { [key: number]: number } = { 4: 5, // knight 5: 4, // king - cannot be captured 6: 4, // pawn 7: 5, // amazon 8: 8, // hawk 9: 5, // chancellor 10: 5, // archbishop 12: 10, // huygen }; // weights for the evaluation function let pieceExistenceEvalDictionary: { [key: number]: number }; let distancesEvalDictionary: { [key: number]: [number, (_square: DoubleCoords) => number][] }; let legalMoveEvalDictionary: { [key: number]: { [key: number]: number } }; let centerOfMassEvalDictionary: { [key: string]: [number, number, number, (_square: DoubleCoords) => number][]; }; // number of candidate squares for white rider pieces to consider along a certain direction (2*wiggleroom + 1) let wiggleroomDictionary: { [key: number]: number }; // whether to consider white pawn moves as candidate moves let ignorepawnmoves: boolean; // whether to consider white royal moves as candidate moves let ignoreroyalmoves: boolean; // whether to enter "trap flee mode" whenever the black royal is surrounded by white pieces let mayEnterTrapFleeMode: boolean; let numOfPiecesForTrap: number; let maxDistanceForTrap: number; let maxDistanceForRoyal_Flee: number; let trapFleeDictionary: { [key: string]: [number, number, number] }; // whether to enter "protected rider flee mode" whenever the black royal is near the specified protected white rider let mayEnterProtectedRiderFleeMode: boolean; let riderTypeToFleeFrom: number; let maxDistanceForRider: number; let maxDistanceForProtector: number; let protectedRiderFleeDictionary: { [key: string]: [number, number, number] }; // bestMoveList stores the best black response for very specific positions in some variants let bestMoveList: { bestMove: DoubleCoords; piecelist: number[]; coordlist: DoubleCoords[] }[] = []; /** * This method initializes the weights the evaluation function according to the checkmate ID provided, as well as global search properties */ function initEvalWeightsAndSearchProperties(): void { // default ignorepawnmoves = false; // default ignoreroyalmoves = false; // default mayEnterTrapFleeMode = false; // default mayEnterProtectedRiderFleeMode = false; // weights for piece values of white pieces pieceExistenceEvalDictionary = { 0: 0, // 0 corresponds to a captured piece 1: -1_000_000, // queen 2: -800_000, // rook 3: -100_000, // bishop 4: -800_000, // knight 5: 0, // king - cannot be captured 6: -100_000, // pawn 7: -1_000_000, // amazon 8: -800_000, // hawk 9: -800_000, // chancellor 10: -800_000, // archbishop 11: -800_000, // knightrider 12: -800_000, // huygen }; // weights and distance functions for white piece distance to the black king // the first entry for each piece is for black to move, the second entry is for white to move // prettier-ignore distancesEvalDictionary = { 1: [[2, manhattanNorm], [2, manhattanNorm]], // queen 2: [[2, manhattanNorm], [2, manhattanNorm]], // rook 3: [[2, manhattanNorm], [2, manhattanNorm]], // bishop 4: [[15, manhattanNorm], [15, manhattanNorm]], // knight 5: [[30, manhattanNorm], [30, manhattanNorm]], // king 6: [[200, pawnNorm], [200, pawnNorm]], // pawn 7: [[14, manhattanNorm], [14, manhattanNorm]], // amazon 8: [[7, manhattanNorm], [7, manhattanNorm]], // hawk 9: [[2, manhattanNorm], [2, manhattanNorm]], // chancellor 10: [[16, manhattanNorm], [16, manhattanNorm]], // archbishop 11: [[16, manhattanNorm], [16, manhattanNorm]], // knightrider 12: [[6, manhattanNorm], [6, manhattanNorm]], // huygen }; // eval scores for number of legal moves of black royal if (royal_type === 'k') { legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: -75, 2: -50, 3: -25, 4: -12, 5: -8, 6: -4, 7: -2, 8: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: -60, 2: -45, 3: -22, 4: -10, 5: -6, 6: -3, 7: -1, 8: 0, }, }; } else { legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: -100, 2: -90, 3: -80, 4: -70, 5: -50, 6: -40, 7: -30, 8: -25, 9: -20, 10: -15, 11: -12.5, 12: -10, 13: -7.5, 14: -5, 15: -2.5, 16: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: -100, 2: -85, 3: -75, 4: -65, 5: -45, 6: -35, 7: -25, 8: -20, 9: -15, 10: -12.5, 11: -10, 12: -7.5, 13: -5, 14: -2, 15: -1, 16: 0, }, }; engineInitialized = true; } // number of candidate squares for white rider pieces to consider along a certain direction (2*wiggleroom + 1) wiggleroomDictionary = { 1: 2, // queen 2: 2, // rook 3: 2, // bishop 7: 2, // amazon 9: 2, // chancellor 10: 1, // archbishop 11: 1, // knightrider 12: 5, // huygen }; // variant-specific weights: // score for distance of black royal to center of mass of white pieces of given type near black king // piecetype, cutoff, weight, distancefunction // prettier-ignore centerOfMassEvalDictionary = { "1K1N2B1B-1k": [[3, 14, 20, manhattanNorm], [3, 14, 20, manhattanNorm]], // bishop "5HU-1k": [[12, 20, 30, manhattanNorm], [12, 20, 30, manhattanNorm]], // huygen }; // whether to enter "trap flee mode" whenever the black royal is surrounded by white pieces // numOfPiecesForTrap, maxDistanceForTrap, maxDistanceForRoyal_Flee trapFleeDictionary = { '1K2HA1B-1k': [3, 8, 10], '1K3HA-1k': [3, 14, 10], }; if (checkmateSelectedID in trapFleeDictionary) { mayEnterTrapFleeMode = true; [numOfPiecesForTrap, maxDistanceForTrap, maxDistanceForRoyal_Flee] = trapFleeDictionary[checkmateSelectedID]!; } // whether to enter "protected rider flee mode" whenever the black royal is near the specified protected white rider // riderTypeToFleeFrom, maxDistanceForRider, maxDistanceForProtector protectedRiderFleeDictionary = { '1K1R2N-1k': [2, Infinity, 10], // rook '1K1CH1N-1k': [9, Infinity, 10], // chancellor }; if (checkmateSelectedID in protectedRiderFleeDictionary) { mayEnterProtectedRiderFleeMode = true; [riderTypeToFleeFrom, maxDistanceForRider, maxDistanceForProtector] = protectedRiderFleeDictionary[checkmateSelectedID]!; } // prettier-ignore switch (checkmateSelectedID) { case '2Q-1k': legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: -250, 2: -220, 3: -190, 4: -160, 5: -120, 6: -90, 7: -60, 8: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: -220, 2: -190, 3: -160, 4: -130, 5: -100, 6: -70, 7: -40, 8: 0, }, }; break; case '1K1AM-1k': ignoreroyalmoves = true; legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, }, }; break; case "1K2N1B1B-1k": distancesEvalDictionary[3] = [[12, manhattanNorm], [12, manhattanNorm]]; // bishop break; case "1K1R1B1B-1k": distancesEvalDictionary[5] = [[15, specialNorm], [15, specialNorm]]; // king break; case "1K1R1N1B-1k": distancesEvalDictionary[4] = [[8, specialNorm], [8, specialNorm]]; // knight break; case "2K1R-1k": distancesEvalDictionary[5] = [[40, specialNorm], [40, specialNorm]]; // king break; case "1K2AR-1k": distancesEvalDictionary[10] = [[15, vincinityNorm], [15, vincinityNorm]]; // archbishop distancesEvalDictionary[5] = [[15, manhattanNorm], [15, manhattanNorm]]; // king break; case '2R1N1P-1k': ignorepawnmoves = true; break; case "1K2N6B-1k": distancesEvalDictionary[4] = [[30, vincinityNorm], [30, vincinityNorm]]; // knight legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: -250, 2: -220, 3: -190, 4: -160, 5: -120, 6: -90, 7: -60, 8: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: -220, 2: -190, 3: -160, 4: -130, 5: -100, 6: -70, 7: -40, 8: 0, }, }; break; case "1K1Q1P-1k": distancesEvalDictionary[1] = [[-5, manhattanNorm], [-5, manhattanNorm]]; // queen distancesEvalDictionary[5] = [[0, () => 0], [0, () => 0]]; // king bestMoveList = [ {bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[-2,-2]]}, {bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[2,-2]]}, {bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-4],[-2,-2]]}, {bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-4],[2,-2]]}, {bestMove: [1,-1], piecelist: [5, 1, 6], coordlist: [[0,2],[-2,-2],[0,-5]]}, {bestMove: [-1,-1], piecelist: [5, 1, 6], coordlist: [[0,2],[2,-2],[0,-5]]}, {bestMove: [1,0], piecelist: [6, 1, 5], coordlist: [[1,-2],[-2,-1],[1,2]]}, {bestMove: [-1,0], piecelist: [6, 1, 5], coordlist: [[-1,-2],[2,-1],[-1,2]]}, {bestMove: [1,-1], piecelist: [6, 5, 1], coordlist: [[1,-2],[1,2],[-1,-5]]}, {bestMove: [-1,-1], piecelist: [6, 5, 1], coordlist: [[-1,-2],[-1,2],[1,-5]]}, {bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[-3,-3]]}, {bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[3,-3]]}, {bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[-1,-3]]}, {bestMove: [0,-1], piecelist: [6, 5, 1], coordlist: [[0,-2],[0,2],[1,-3]]}, {bestMove: [1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[-4,-4]]}, {bestMove: [-1,-1], piecelist: [5, 6, 1], coordlist: [[0,2],[0,-3],[4,-4]]}, {bestMove: [0,-1], piecelist: [5, 1, 6], coordlist: [[1,2],[-1,-3],[1,-3]]}, {bestMove: [0,-1], piecelist: [5, 6, 1], coordlist: [[-1,2],[-1,-3],[1,-3]]}, {bestMove: [-1,1], piecelist: [1, 6, 5], coordlist: [[-2,-1],[2,-2],[1,3]]}, {bestMove: [1,1], piecelist: [1, 6, 5], coordlist: [[2,-1],[-2,-2],[-1,3]]}, ]; break; case "1K3NR-1k": distancesEvalDictionary[5] = [[20, manhattanNorm], [20, manhattanNorm]]; // king legalMoveEvalDictionary = { // in check 0: { 0: -Infinity, // checkmate 1: -25, 2: -17, 3: -8, 4: -4, 5: -3, 6: -2, 7: -1, 8: 0, }, // not in check 1: { 0: Infinity, // stalemate 1: -20, 2: -15, 3: -6, 4: -3, 5: -2, 6: -1, 7: -1, 8: 0, }, }; break; } } // computes the 2-norm of a square function diagonalNorm(square: DoubleCoords): number { return Math.sqrt(square[0] ** 2 + square[1] ** 2); } // computes the squared 2-norm of a square function diagonalNormSquared(square: DoubleCoords): number { return square[0] ** 2 + square[1] ** 2; } // computes the manhattan norm of a square function manhattanNorm(square: DoubleCoords): number { return Math.abs(square[0]) + Math.abs(square[1]); } // computes the manhattan distance of two squares function manhattanDistance(square1: DoubleCoords, square2: DoubleCoords): number { return Math.abs(square1[0] - square2[0]) + Math.abs(square1[1] - square2[1]); } // special norm = manhattan + diagonal function specialNorm(square: DoubleCoords): number { return diagonalNorm(square) + manhattanNorm(square); } // pawn norm: gives slight malus for black king being near and above the pawn. Also gives malus for black king being above white pawn everywhere function pawnNorm(square: DoubleCoords): number { const prefactor = square[1] < 0 && manhattanNorm(square) < 5 ? 1 : 6; return prefactor * (0.5 * diagonalNorm(square) + 1.5 * manhattanNorm(square) + 0.5 * square[1]); } // special norm, which gives a massive malus to the piece being near the black king for black function vincinityNorm(square: DoubleCoords): number { const diagnormsquared = diagonalNormSquared(square); const penalty = diagnormsquared < 3 ? -16 : diagnormsquared < 9 ? -8 : diagnormsquared < 19 ? -4 : 0; return manhattanNorm(square) + penalty; } // center of mass of all white pieces near the black king function get_center_of_mass( piece_type: number, cutoff: number, piecelist: number[], coordlist: DoubleCoords[], ): DoubleCoords | false { let numpieces: number = 0; let center: DoubleCoords = [0, 0]; for (let i = 0; i < piecelist.length; i++) { if (piecelist[i] === piece_type && manhattanNorm(coordlist[i]!) <= cutoff) { center = add_move(center, coordlist[i]!); numpieces++; } } if (numpieces === 0) return false; else return rescaleVector(1 / numpieces, center); } /** * Checks if v is a multiple of direction, and returns a boolean and the factor * @param v - vector like [10,20] * @param direction - vector like [1,2] * @returns like [boolean, scalar multiple factor] */ function is_natural_multiple(v: DoubleCoords, direction: DoubleCoords): [boolean, number] { let scalar: number; if (direction[0] !== 0) scalar = v[0] / direction[0]; else scalar = v[1] / direction[1]; return [scalar > 0 && scalar * direction[0] === v[0] && scalar * direction[1] === v[1], scalar]; } // checks if a rider on a given square threatens a given target square // exclude_white_piece_squares specifies whether to exclude occupied squares from being threatened // ignore_blockers specifies whether to completely ignore blocking pieces in piecelist&coordlist // threatening_own_square specifies whether a piece can threaten its own square function rider_threatens( direction: DoubleCoords, piece_square: DoubleCoords, target_square: DoubleCoords, is_huygen: boolean, piecelist: number[], coordlist: DoubleCoords[], { exclude_white_piece_squares = false, ignore_blockers = false, threatening_own_square = false, } = {}, ): boolean { if (threatening_own_square && squares_are_equal(piece_square, target_square)) return true; const [works, distance] = is_natural_multiple( [target_square[0] - piece_square[0], target_square[1] - piece_square[1]], direction, ); if (!works) return false; if (is_huygen && !primalityTest(distance)) return false; if (ignore_blockers) return true; // loop over all potential blockers for (let i = 0; i < coordlist.length; i++) { if (piecelist[i] === 0) continue; else if (exclude_white_piece_squares && squares_are_equal(coordlist[i]!, target_square)) return false; const [collinear, thispiecedistance] = is_natural_multiple( [coordlist[i]![0]! - piece_square[0]!, coordlist[i]![1]! - piece_square[1]!], direction, ); if (!collinear) continue; else if (is_huygen && !primalityTest(thispiecedistance)) continue; else if (thispiecedistance < distance) return false; } return true; } // adds two squares function add_move(square: DoubleCoords, v: DoubleCoords): DoubleCoords { return [square[0] + v[0], square[1] + v[1]]; } // stretches vector by scalar function rescaleVector(scalar: number, v: DoubleCoords): DoubleCoords { return [scalar * v[0], scalar * v[1]]; } // computes the cross product of two vectors function crossProduct(v1: DoubleCoords, v2: DoubleCoords): number { return v1[0] * v2[1] - v1[1] * v2[0]; } // checks if two squares are equal function squares_are_equal(square_1: DoubleCoords, square_2: DoubleCoords): boolean { return square_1[0] === square_2[0] && square_1[1] === square_2[1]; } // checks if a list of squares contains a given square function tuplelist_contains_tuple(tuplelist: DoubleCoords[], tuple: DoubleCoords): boolean { return tuplelist.some((entry) => squares_are_equal(entry, tuple)); } // checks if a square is occupied by a white piece function square_is_occupied( square: DoubleCoords, piecelist: number[], coordlist: DoubleCoords[], ): boolean { return coordlist.some( (entry, index) => piecelist[index] !== 0 && squares_are_equal(entry, square), ); } // checks if a white piece at index piece_index in the piecelist&coordlist threatens a given square function piece_threatens_square( piece_index: number, target_square: DoubleCoords, piecelist: number[], coordlist: DoubleCoords[], ): boolean { const piece_type = piecelist[piece_index]!; // piece no longer exists if (piece_type === 0) return false; const piece_properties = pieceTypeDictionary[piece_type]!; const piece_square = coordlist[piece_index]!; // piece is already on square if (squares_are_equal(piece_square, target_square)) return false; // pawn threatening if (piece_properties.is_pawn) { if ( squares_are_equal(add_move(piece_square, [-1, 1]), target_square) || squares_are_equal(add_move(piece_square, [1, 1]), target_square) ) return true; else return false; } // jump move threatening if (piece_properties.jumps) { if ( tuplelist_contains_tuple(piece_properties.jumps, [ target_square[0] - piece_square[0], target_square[1] - piece_square[1], ]) ) return true; } // rider move threatening if (piece_properties.rides) { for (const ride_directrion of piece_properties.rides) { const is_huygen = piece_properties.is_huygen ? true : false; if ( rider_threatens( ride_directrion, piece_square, target_square, is_huygen, piecelist, coordlist, ) ) return true; } } return false; } // checks if any white piece threatens a given square function square_is_threatened( target_square: DoubleCoords, piecelist: number[], coordlist: DoubleCoords[], ): boolean { for (let index = 0; index < coordlist.length; index++) { if (piece_threatens_square(index, target_square, piecelist, coordlist)) return true; } return false; } /** * Computes an array of all the squares that the black royal can legally move to in the given position */ function get_black_legal_moves( inTrapFleeMode: boolean, piecelist: number[], coordlist: DoubleCoords[], ): DoubleCoords[] { // If black is in flee mode, he cannot capture white pieces return royal_moves.filter( (square) => !square_is_threatened(square, piecelist, coordlist) && !(inTrapFleeMode && square_is_occupied(square, piecelist, coordlist)), ); } /** * Computes the number of squares that the black royal can legally move to in the given position */ function get_black_legal_move_amount( inTrapFleeMode: boolean, piecelist: number[], coordlist: DoubleCoords[], ): number { return get_black_legal_moves(inTrapFleeMode, piecelist, coordlist).length; } // checks if the black royal is in check function is_check(piecelist: number[], coordlist: DoubleCoords[]): boolean { return square_is_threatened([0, 0], piecelist, coordlist); } // Unused functions /* // checks if the black royal is mated function is_mate(inTrapFleeMode, piecelist, coordlist) { if (get_black_legal_move_amount(inTrapFleeMode, piecelist, coordlist) == 0 && square_is_threatened([0, 0], piecelist, coordlist)) return true; else return false; } // checks if the black royal is stalemated function is_stalemate(inTrapFleeMode, piecelist, coordlist) { if (get_black_legal_move_amount(inTrapFleeMode, piecelist, coordlist) == 0 && !square_is_threatened([0, 0], piecelist, coordlist)) return true; else return false; } */ // determine if black is surrounded by at least numOfPiecesForTrap nonroyal white pieces function isBlackInTrap(piecelist: number[], coordlist: DoubleCoords[]): boolean { let nearbyNonroyalWhites = 0; for (let i = 0; i < piecelist.length; i++) { if (piecelist[i]! !== 0 && manhattanNorm(coordlist[i]!) <= maxDistanceForTrap) { if (!pieceTypeDictionary[piecelist[i]!]!.is_royal) nearbyNonroyalWhites++; // black is not in trap if white royal is nearby else if (manhattanNorm(coordlist[i]!) <= maxDistanceForRoyal_Flee) return false; } } // black is surrounded by at least numOfPiecesForTrap nonroyal white pieces return nearbyNonroyalWhites >= numOfPiecesForTrap; } // determine if black is near specified protected rider function isBlackNearProtectedRider(piecelist: number[], coordlist: DoubleCoords[]): boolean { for (let i = 0; i < piecelist.length; i++) { if (piecelist[i] === riderTypeToFleeFrom) { if (manhattanNorm(coordlist[i]!) <= maxDistanceForRider) { for (let j = 0; j < piecelist.length; j++) { if ( j !== i && piecelist[j] !== 0 && manhattanDistance(coordlist[i]!, coordlist[j]!) <= maxDistanceForProtector ) { return true; } } } // single rider that matters is not protected or too far away return false; } } return false; } // calculate a list of interesting squares to move to for a white piece with a certain piece index function get_white_piece_candidate_squares( piece_index: number, piecelist: number[], coordlist: DoubleCoords[], ): DoubleCoords[] { const candidate_squares: DoubleCoords[] = []; const piece_type = piecelist[piece_index]!; // piece no longer exists if (piece_type === 0) return candidate_squares; const piece_properties = pieceTypeDictionary[piece_type]!; const piece_square = coordlist[piece_index]!; if (ignorepawnmoves && piece_properties.is_pawn) return candidate_squares; if (ignoreroyalmoves && piece_properties.is_royal) return candidate_squares; // jump moves if (piece_properties.jumps) { const num_jumps = piece_properties.jumps.length; const shortrangeLimit = shortRangeJumpDictionary[piece_type]!; let best_target_square: DoubleCoords; let bestmove_distance = Infinity; let bestmove_diagSquaredNorm = Infinity; for (let move_index = 0; move_index < num_jumps; move_index++) { const target_square = add_move(piece_square, piece_properties.jumps[move_index]!); // do not jump onto an occupied square if (square_is_occupied(target_square, piecelist, coordlist)) continue; // do not move a royal piece onto a square controlled by black if (piece_properties.is_royal && tuplelist_contains_tuple(royal_moves, target_square)) continue; // check if target_square is a royal move if (tuplelist_contains_tuple(royal_moves, target_square)) { let blunders_piece = true; // create copy of piece list without piece at piece_index const temp_piecelist = [...piecelist]; temp_piecelist[piece_index] = 0; // only consider target square if another piece defends it as well, else it will be captured for (let index = 0; index < coordlist.length; index++) { if ( index !== piece_index && piece_threatens_square(index, target_square, temp_piecelist, coordlist) ) { blunders_piece = false; break; } } if (blunders_piece) continue; } const target_distance = manhattanNorm(target_square); const target_diagSquaredNorm = diagonalNormSquared(target_square); // tiebreaker // only add jump moves that are short range in relation to black king if (target_distance <= shortrangeLimit) { candidate_squares.push(target_square); } // keep single jump move nearest to the black king in memory else if ( target_distance < bestmove_distance || (target_distance === bestmove_distance && target_diagSquaredNorm < bestmove_diagSquaredNorm) ) { bestmove_distance = target_distance; bestmove_diagSquaredNorm = target_diagSquaredNorm; best_target_square = target_square; } } // if no jump move has been added and piece has no ride moves or is a huygens, add single best jump move as candidate if ( candidate_squares.length === 0 && best_target_square! !== undefined && (!piece_properties.rides || piece_properties.is_huygen) ) candidate_squares.push(best_target_square!); } // ride moves if (piece_properties.rides) { const num_directions = piece_properties.rides.length; // check each pair of rider directions v1 and v2. // Project them onto the square coordinates by solving c1*v1 + c2*v2 == - piece_square. // only works if movement directions are not collinear // See https://math.stackexchange.com/a/1307635/998803 for (let i1 = 0; i1 < num_directions; i1++) { const v1 = piece_properties.rides[i1]!; for (let i2 = i1 + 1; i2 < num_directions; i2++) { const v2 = piece_properties.rides[i2]!; const denominator = crossProduct(v1, v2); if (denominator === 0) continue; const c1 = crossProduct(v2, piece_square) / denominator; const c2 = -crossProduct(v1, piece_square) / denominator; if (c1 < 0 || c2 <= 0) continue; // suitable values for c1 and c2 were found, now compute min and max values for c1 and c2 to consider const c1_min = Math.ceil(c1 - wiggleroomDictionary[piece_type]!); const c1_max = Math.floor(c1 + wiggleroomDictionary[piece_type]!); const c2_min = Math.ceil(c2 - wiggleroomDictionary[piece_type]!); const c2_max = Math.floor(c2 + wiggleroomDictionary[piece_type]!); // adds suitable squares along v1 to the candidates list // prettier-ignore add_suitable_squares_to_candidate_list( candidate_squares, piece_index, piece_square, v1, v2, c1_min, c1_max, c2_min, c2_max, piecelist, coordlist ); // adds suitable squares along v2 to the candidates list // prettier-ignore add_suitable_squares_to_candidate_list( candidate_squares, piece_index, piece_square, v2, v1, c2_min, c2_max, c1_min, c1_max, piecelist, coordlist ); } } } return candidate_squares; } // adds suitable squares along v1 to the candidates list, using v2 as the attack vector towards the king function add_suitable_squares_to_candidate_list( candidate_squares: DoubleCoords[], piece_index: number, piece_square: DoubleCoords, v1: DoubleCoords, v2: DoubleCoords, c1_min: number, c1_max: number, c2_min: number, c2_max: number, piecelist: number[], coordlist: DoubleCoords[], ): void { // iterate through all candidate squares in v1 direction candidates_loop: for (let rc1 = c1_min; rc1 <= c1_max; rc1++) { const target_square = add_move(piece_square, rescaleVector(rc1, v1)); // do not add square already in candidates list if (tuplelist_contains_tuple(candidate_squares, target_square)) continue candidates_loop; // if piece is huygens, discard all nonprime candidate squares or squares already covered by jump moves const is_huygen = pieceTypeDictionary[piecelist[piece_index]!]!.is_huygen ? true : false; if (is_huygen) { const distance = manhattanDistance(piece_square, target_square); if (!primalityTest(distance)) continue candidates_loop; } const square_near_king_1 = add_move(target_square, rescaleVector(c2_min, v2)); const square_near_king_2 = add_move(target_square, rescaleVector(c2_max, v2)); // ensure that piece threatens target square if ( !rider_threatens(v1, piece_square, target_square, is_huygen, piecelist, coordlist, { exclude_white_piece_squares: true, }) ) continue; // ensure that target square threatens square near black king if ( !rider_threatens(v2, target_square, square_near_king_1, false, piecelist, coordlist, { threatening_own_square: true, }) && !rider_threatens(v2, target_square, square_near_king_2, false, piecelist, coordlist, { threatening_own_square: true, }) ) continue; // check if target_square is a royal move if (tuplelist_contains_tuple(royal_moves, target_square)) { // create copy of piece list without piece at piece_index const temp_piecelist = [...piecelist]; temp_piecelist[piece_index] = 0; // only add target square if another piece defends it as well, else it will be captured for (let index = 0; index < coordlist.length; index++) { if ( index !== piece_index && piece_threatens_square(index, target_square, temp_piecelist, coordlist) ) { candidate_squares.push(target_square); continue candidates_loop; } } } // target square is not a royal move else { // loop over all accepted candidate squares to eliminate reduncancies with new square redundancy_loop: for (let i = 0; i < candidate_squares.length; i++) { // skip over accepted candidate square if it is a royal move if (tuplelist_contains_tuple(royal_moves, candidate_squares[i]!)) continue redundancy_loop; // skip over accepted candidate square if its coords have a different sign from the current candidate square else if (Math.sign(target_square[0]!) !== Math.sign(candidate_squares[i]![0]!)) continue redundancy_loop; else if (Math.sign(target_square[1]!) !== Math.sign(candidate_squares[i]![1]!)) continue redundancy_loop; // eliminate current candidate square if it lies on the same line as accepted candidate square, but further away else if ( rider_threatens( v2, target_square, candidate_squares[i]!, is_huygen, piecelist, coordlist, { ignore_blockers: true }, ) ) continue candidates_loop; // replace accepted candidate square with current candidate square if they lie on the same line as, but new square is nearer else if ( rider_threatens( v2, candidate_squares[i]!, target_square, is_huygen, piecelist, coordlist, { ignore_blockers: true }, ) ) { candidate_squares[i] = target_square; continue candidates_loop; } } candidate_squares.push(target_square); } } } // calculate a list of interesting moves for the white pieces in the position given by piecelist&coordlist // if inProtectedRiderFleeMode, then moves by pieces with type riderTypeToFleeFrom are not considered function get_white_candidate_moves( inProtectedRiderFleeMode: boolean, piecelist: number[], coordlist: DoubleCoords[], ): DoubleCoords[][] { const candidate_moves: DoubleCoords[][] = []; for (let piece_index = 0; piece_index < piecelist.length; piece_index++) { if (inProtectedRiderFleeMode && riderTypeToFleeFrom === piecelist[piece_index]) candidate_moves.push([]); else candidate_moves.push( get_white_piece_candidate_squares(piece_index, piecelist, coordlist), ); } return candidate_moves; } /** * Updates the position by moving the piece given by piece_index to target_square */ function make_white_move( piece_index: number, target_square: DoubleCoords, piecelist: number[], coordlist: DoubleCoords[], ): [number[], DoubleCoords[]] { const new_piecelist = piecelist.map((a) => { return a; }); const new_coordlist = coordlist.map((a) => { return [...a]; }) as DoubleCoords[]; new_coordlist[piece_index] = target_square; return [new_piecelist, new_coordlist]; } /** * Given a direction that the black royal moves to, this shifts all white pieces relative to [0,0] and returns an updated piecelist&coordlist */ function make_black_move( move: DoubleCoords, piecelist: number[], coordlist: DoubleCoords[], ): [number[], DoubleCoords[]] { const new_piecelist: number[] = []; const new_coordlist: DoubleCoords[] = []; for (let i = 0; i < piecelist.length; i++) { if (move[0]! === coordlist[i]![0]! && move[1]! === coordlist[i]![1]!) { // white piece is captured new_piecelist.push(0); } else { // white piece is not captured new_piecelist.push(piecelist[i]!); } // shift coordinates new_coordlist.push(add_move(coordlist[i]!, [-move[0]!, -move[1]!])); } return [new_piecelist, new_coordlist]; } /** * Returns an evaluation score for a given position according to the evaluation dictionaries * TODO: cap distance function when white to move * @param {Array} piecelist * @param {Array} coordlist * @param {Boolean} black_to_move - false on white's turns, true on black's turns * @param {Boolean} inTrapFleeMode - whether black is in trap flee mode -> leads to lower scores, if true * @param {Boolean} inProtectedRiderFleeMode - whether black is in protected rider flee mode -> leads to higher scores, if true * @returns {Number} */ function get_position_evaluation( piecelist: number[], coordlist: DoubleCoords[], black_to_move: boolean, inTrapFleeMode: boolean, inProtectedRiderFleeMode: boolean, ): number { let score = 0; // add penalty based on number of legal moves of black royal const incheck = is_check(piecelist, coordlist); score += legalMoveEvalDictionary[incheck ? 0 : 1]![ get_black_legal_move_amount(false, piecelist, coordlist) ]!; // do not give stalemate Infinity reward if white to move or black in trap flee mode if (score === Infinity && (!black_to_move || inTrapFleeMode)) score = 1.5 * legalMoveEvalDictionary[0]![1]!; const black_to_move_num = black_to_move ? 0 : 1; for (let i = 0; i < piecelist.length; i++) { // add penalty based on existence of white pieces score += pieceExistenceEvalDictionary[piecelist[i]!]!; // add score based on distance of black royal to white shortrange pieces if (piecelist[i]! in distancesEvalDictionary) { const [weight, distancefunction] = distancesEvalDictionary[piecelist[i]!]![black_to_move_num]!; if (inProtectedRiderFleeMode && riderTypeToFleeFrom === piecelist[i]) score += 50 * weight * distancefunction(coordlist[i]!); else score += weight * distancefunction(coordlist[i]!); } } // add score based on distance of black royal to center of mass of white pieces near black king if (checkmateSelectedID in centerOfMassEvalDictionary) { const [piecetype, cutoff, weight, distancefunction] = centerOfMassEvalDictionary[checkmateSelectedID]![black_to_move_num]!; const center_of_mass = get_center_of_mass( piecetype, cutoff, start_piecelist, start_coordlist, ); if (center_of_mass) score += weight * distancefunction(center_of_mass); } return score; } /** * Performs a standard search with alpha-beta pruning through the game tree and updates globallyBestVariation and the like * @param {Array} piecelist * @param {Array} coordlist * @param {Number} depth * @param {Number} start_depth - does not get changed at all during recursion * @param {Boolean} black_to_move * @param {Boolean} followingPrincipal - whether the function is still following the (initial) principal variation * @param {Boolean} inTrapFleeMode - whether one should neglect all white candidate moves in deeper search beyond the first white node * @param {Boolean} inProtectedRiderFleeMode - whether one should neglect all white candidate moves by rider in deeper search and reward distance from him * @param {DoubleCoords[]} black_killer_list - list of black killer moves that is being maintained when white to move * @param {Number[]} white_killer_list - list white killer pieces that is being maintained when black to move * @param {Number} alpha * @param {Number} beta * @param {Number} alphaPlies - alpha beta for remaining plies in the game: tiebreak in case of early game over: the more plies the game lasts the better for black * @param {Number} betaPlies * @returns {Object} with properties "score", "move" and "termination_depth" */ function alphabeta( piecelist: number[], coordlist: DoubleCoords[], depth: number, start_depth: number, black_to_move: boolean, followingPrincipal: boolean, inTrapFleeMode: boolean, inProtectedRiderFleeMode: boolean, black_killer_list: DoubleCoords[], white_killer_list: Number[], alpha: number, beta: number, alphaPlies: number, betaPlies: number, ): { score: number; bestVariation: { [key: number]: [number, DoubleCoords] }; survivalPlies: number; black_killer_move?: DoubleCoords; white_killer_piece_index?: Number; terminate_now: boolean; } { enginePositionCounter++; // Empirically: The bot needs roughly 40ms to check 3000 positions, so check every 40ms if enough time has passed to terminate computation if ( enginePositionCounter % 3000 === 0 && Date.now() - engineStartTime >= engineTimeLimitPerMoveMillis ) { return { score: NaN, bestVariation: {}, survivalPlies: NaN, terminate_now: true }; // If game over, return position evaluation } else if (black_to_move && get_black_legal_move_amount(false, piecelist, coordlist) === 0) { return { score: get_position_evaluation( piecelist, coordlist, black_to_move, inTrapFleeMode && start_depth - depth > 1, inProtectedRiderFleeMode, ), bestVariation: {}, survivalPlies: start_depth - depth, terminate_now: false, }; // At max depth, return position evaluation } else if (depth === 0) { return { score: get_position_evaluation( piecelist, coordlist, black_to_move, inTrapFleeMode && start_depth - depth > 1, inProtectedRiderFleeMode, ), bestVariation: {}, survivalPlies: start_depth + 1, terminate_now: false, }; } let bestVariation: { [key: number]: [number, DoubleCoords] } = {}; // Black to move if (black_to_move) { let maxScore = -Infinity; let maxPlies = -Infinity; let black_killer_move: DoubleCoords | undefined = undefined; let black_moves = get_black_legal_moves( inTrapFleeMode && start_depth - depth > 1, piecelist, coordlist, ); // Black is in trap flee mode and considers no white candidate moves no piece captures from here on out: if (mayEnterTrapFleeMode && depth === start_depth && isBlackInTrap(piecelist, coordlist)) inTrapFleeMode = true; // Black is in protected rider flee mode and considers no white rider candidate moves no piece captures from here on out: if ( mayEnterProtectedRiderFleeMode && depth === start_depth && isBlackNearProtectedRider(piecelist, coordlist) ) inProtectedRiderFleeMode = true; // Order black moves by immediate evaluation function if (depth > 1 && black_moves.length > 1) { const black_move_evals: number[] = []; for (const move of black_moves) { const [order_piecelist, order_coordlist] = make_black_move( move, piecelist, coordlist, ); const order_score = get_position_evaluation( order_piecelist, order_coordlist, false, inTrapFleeMode && start_depth - depth > 1, inProtectedRiderFleeMode, ); black_move_evals.push(order_score); } // Get sorted indices const order_indices = black_move_evals .map((_, i) => i) .sort((a, b) => black_move_evals[b]! - black_move_evals[a]!); // Reorder black_moves arrays based on sorted indices black_moves = order_indices.map((i) => black_moves[i]!); } // Use killer move heuristic, i.e. put moves in black_killer_list in front if (black_killer_list.length > 0) { const reordered_moves_killers: DoubleCoords[] = []; const reordered_moves_nonkillers: DoubleCoords[] = []; for (const move of black_moves) { if (tuplelist_contains_tuple(black_killer_list, move)) reordered_moves_killers.push(move); // Add killer moves to the first list else reordered_moves_nonkillers.push(move); // Add non-killer moves to second list } black_moves.length = 0; black_moves.push(...reordered_moves_killers, ...reordered_moves_nonkillers); } // If we are still in followingPrincipal mode, do principal variation ordering if (followingPrincipal && globallyBestVariation[start_depth - depth]) { for (let index = 0; index < black_moves.length; index++) { if ( squares_are_equal( black_moves[index]!, globallyBestVariation[start_depth - depth]![1]!, ) ) { // Shuffe principal move to the front of black_moves const optimal_move = black_moves.splice(index, 1)[0]!; black_moves.unshift(optimal_move); break; } } } else { // We are too deep now, principal variation no longer applies followingPrincipal = false; } // loop over all possible black moves, do alpha beta pruning with (alpha, beta) (and (alphaPlies, betaPlies) as the tiebreaker) blackMoveLoop: for (const move of black_moves) { const [new_piecelist, new_coordlist] = make_black_move(move, piecelist, coordlist); const evaluation = alphabeta( new_piecelist, new_coordlist, depth - 1, start_depth, false, followingPrincipal, inTrapFleeMode, inProtectedRiderFleeMode, [], white_killer_list, alpha, beta, alphaPlies, betaPlies, ); if (evaluation.terminate_now) return { score: NaN, bestVariation: {}, survivalPlies: NaN, terminate_now: true }; followingPrincipal = false; // append white killer piece to running white_killer_list, if it caused a beta cutoff if (evaluation.white_killer_piece_index) white_killer_list.push(evaluation.white_killer_piece_index); const new_score = evaluation.score; const survivalPlies = evaluation.survivalPlies; if (new_score >= maxScore) { if ( new_score > maxScore || survivalPlies > maxPlies || (survivalPlies === maxPlies && rand() < 0.5) || Object.keys(bestVariation).length === 0 ) { bestVariation = evaluation.bestVariation; bestVariation[start_depth - depth] = [NaN, move]; maxScore = new_score; maxPlies = survivalPlies; alpha = Math.max(alpha, new_score); alphaPlies = Math.max(alphaPlies, survivalPlies); if ( depth === start_depth && new_score >= globallyBestScore && survivalPlies >= globalSurvivalPlies ) { globallyBestVariation = bestVariation; globallyBestScore = new_score; globalSurvivalPlies = survivalPlies; } } } if (beta < alpha || (beta === alpha && betaPlies < alphaPlies)) { black_killer_move = move; break blackMoveLoop; } } return { score: maxScore, bestVariation: bestVariation, survivalPlies: maxPlies, black_killer_move: black_killer_move, terminate_now: false, }; // White to move } else { let minScore = Infinity; let minPlies = Infinity; let white_killer_piece_index: Number | undefined = undefined; let candidate_moves: DoubleCoords[][]; if (inTrapFleeMode && start_depth - depth > 1) candidate_moves = [[coordlist[0]], ...Array(piecelist.length - 1).fill([])]; else candidate_moves = get_white_candidate_moves( inProtectedRiderFleeMode, piecelist, coordlist, ); // go through pieces for in increasing order of what piece has how many candidate moves const indices = [...Array(piecelist.length).keys()]; indices.sort((a, b) => { return candidate_moves[a]!.length - candidate_moves[b]!.length; }); // Use killer move heuristic, i.e. put pieces in white_killer_list in front if (white_killer_list.length > 0) { const reordered_indices_killers: number[] = []; const reordered_indices_nonkillers: number[] = []; for (const piece_index of indices) { if (piece_index in white_killer_list) reordered_indices_killers.push(piece_index); // Add killer moves to the first list else reordered_indices_nonkillers.push(piece_index); // Add non-killer moves to second list } indices.length = 0; indices.push(...reordered_indices_killers, ...reordered_indices_nonkillers); } // If we are still in followingPrincipal mode, do principal variation ordering if (followingPrincipal && globallyBestVariation[start_depth - depth]) { for (let p_index = 0; p_index < indices.length; p_index++) { if (indices[p_index] === globallyBestVariation[start_depth - depth]![0]!) { // Shuffe principal piece index to the front of indices const optimal_index = indices.splice(p_index, 1)[0]!; indices.unshift(optimal_index); // Loop over candidate moves for principal piece for ( let m_index = 0; m_index < candidate_moves[optimal_index]!.length; m_index++ ) { if ( squares_are_equal( candidate_moves[optimal_index]![m_index]!, globallyBestVariation[start_depth - depth]![1]!, ) ) { // Shuffe principal move to the front of candidate_moves for that piece const optimal_move = candidate_moves[optimal_index]!.splice( m_index, 1, )[0]!; candidate_moves[optimal_index]!.unshift(optimal_move); break; } } break; } } } else { // We are too deep now, principal variation no longer applies followingPrincipal = false; } // loop over all possible white moves, do alpha beta pruning with (alpha, beta) (and (alphaPlies, betaPlies) as the tiebreaker) whiteMoveLoop: for (const piece_index of indices) { for (const target_square of candidate_moves[piece_index]!) { const [new_piecelist, new_coordlist] = make_white_move( piece_index, target_square, piecelist, coordlist, ); const evaluation = alphabeta( new_piecelist, new_coordlist, depth - 1, start_depth, true, followingPrincipal, inTrapFleeMode, inProtectedRiderFleeMode, black_killer_list, [], alpha, beta, alphaPlies, betaPlies, ); if (evaluation.terminate_now) return { score: NaN, bestVariation: {}, survivalPlies: NaN, terminate_now: true, }; followingPrincipal = false; // append black killer move to running black_killer_list, if it caused a beta cutoff if (evaluation.black_killer_move) black_killer_list.push(evaluation.black_killer_move); const new_score = evaluation.score; const survivalPlies = evaluation.survivalPlies; if (new_score <= minScore) { if ( new_score < minScore || survivalPlies < minPlies || (survivalPlies === minPlies && rand() < 0.5) || Object.keys(bestVariation).length === 0 ) { bestVariation = evaluation.bestVariation; bestVariation[start_depth - depth] = [piece_index, target_square]; minScore = new_score; minPlies = survivalPlies; beta = Math.min(beta, new_score); betaPlies = Math.min(betaPlies, survivalPlies); } } if (beta < alpha || (beta === alpha && betaPlies < alphaPlies)) { white_killer_piece_index = piece_index; break whiteMoveLoop; } } } return { score: minScore, bestVariation: bestVariation, survivalPlies: minPlies, white_killer_piece_index: white_killer_piece_index, terminate_now: false, }; } } /** * Performs a search with alpha-beta pruning through the game tree with iteratively greater depths */ function runIterativeDeepening( piecelist: number[], coordlist: DoubleCoords[], maxdepth: number, ): void { // immediately initialize and set globallyBestVariation randomly, in case nothing better ever gets found const black_moves = get_black_legal_moves(false, piecelist, coordlist); globallyBestVariation[0] = [NaN, black_moves[Math.floor(rand() * black_moves.length)]!]; const [dummy_piecelist, dummy_coordlist] = make_black_move( globallyBestVariation[0]![1]!, piecelist, coordlist, ); globallyBestScore = get_position_evaluation( dummy_piecelist, dummy_coordlist, false, false, false, ); globalSurvivalPlies = 1; try { // iteratively deeper and deeper search for (let depth = 1; depth <= maxdepth; depth = depth + 2) { const evaluation = alphabeta( piecelist, coordlist, depth, depth, true, true, false, false, [], [], -Infinity, Infinity, 0, Infinity, ); if (evaluation.terminate_now) { // console.log("Search interrupted at depth " + depth); break; } globallyBestVariation = evaluation.bestVariation; globallyBestScore = evaluation.score; globalSurvivalPlies = evaluation.survivalPlies; // console.log(`Depth ${depth}, Plies To Mate: ${globalSurvivalPlies}, Best score: ${globallyBestScore}, Best move by Black: ${globallyBestVariation[0]![1]!}.`); // early exit conditions if (depth === 1) { const black_move = globallyBestVariation[0]![1]!; const [new_piecelist, new_coordlist] = make_black_move( black_move, piecelist, coordlist, ); // If a piece is captured, immediately check for insuffmat // We do this by constructing the piecesOrganizedByKey property of a dummy gamefile // This works as long insufficientmaterial.js only cares about piecesOrganizedByKey if ( new_piecelist.filter((x) => x === 0).length > piecelist.filter((x) => x === 0).length ) { const piecesOrganizedByKey = new Map(); piecesOrganizedByKey.set( '0,0', royal_type === 'k' ? r.KING + e.B : r.ROYALCENTAUR + e.B, ); for (let i = 0; i < piecelist.length; i++) { if (new_piecelist[i] !== 0) { piecesOrganizedByKey.set( new_coordlist[i]!.toString() as CoordsKey, invertedPieceNameDictionaty[new_piecelist[i]!]!, ); } } const emptyPieceMovesets = {}; // <--- Is this gonna be an issue? const basegame = input_gamefile.basegame; const dummy_board = { moves: [], // pieceMovesets is the only required gamefile property that is lost when sending the gamefile to the engine. // This will cause the possible slides to be calculated incorrectly, and thus the `lines` property not entirely filled out. // I THINK we are safe though, because I saw nowhere in detectInsufficientMaterial() where it reads the lines. pieces: organizedpieces.processInitialPosition( piecesOrganizedByKey, emptyPieceMovesets, basegame.gameRules.turnOrder, input_gamefile.boardsim.editor, basegame.gameRules.promotionsAllowed, ).pieces, } as unknown as Board; if (detectInsufficientMaterial(basegame.gameRules, dummy_board)) break; } // special case for 3B3B-1k variant after piece capture // enforce parity constraint to never get checkmated: the king will always move to the square color with fewer bishops unless making a capture if (checkmateSelectedID === '3B3B-1k' && piecelist.length < 6) { const parity = coordlist.filter(([a, b]) => (a + b) % 2 === 0).length < 3 ? 0 : 1; const optimal_move = black_moves.find( ([a, b]) => Math.abs((a + b) % 2) === parity, ); if (optimal_move !== undefined) { globallyBestVariation[0] = [NaN, optimal_move]; break; } } } } } catch (error) { // If engine suggests illegal move for black, choose it randomly, else abort with currently best move if (!tuplelist_contains_tuple(black_moves, globallyBestVariation[0]![1]!)) globallyBestVariation[0] = [NaN, black_moves[Math.floor(rand() * black_moves.length)]!]; console.error( 'Something went wrong with the iterative deepening calculation, aborting early...', ); console.error(error); } } /** * Given some string, returns an array of four random seeds * Source: https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript */ function cyrb128(str: string): number[] { let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762; for (let i = 0, k; i < str.length; i++) { k = str.charCodeAt(i); h1 = h2 ^ Math.imul(h1 ^ k, 597399067); h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); h3 = h4 ^ Math.imul(h3 ^ k, 951274213); h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); } h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); ((h1 ^= h2 ^ h3 ^ h4), (h2 ^= h1), (h3 ^= h1), (h4 ^= h1)); return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]; } /** * Given some number, returns a seeded function that draws uniformly random numbers between 0 and 1 * Source: https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript */ function mulberry32(a: number): () => number { return function (): number { let t = (a += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * Converts a target square for the black king to move to into a MoveCoords Object, taking into account gamefile_royal_coords */ function move_to_gamefile_move(target_square: DoubleCoords): string { const endCoords: DoubleCoords = [ gamefile_royal_coords[0] + target_square[0], gamefile_royal_coords[1] + target_square[1], ]; // Convert the floating point numbers to BigInt coordinates before passing the move to the game const moveCoords: MoveCoords = { startCoords: [BigInt(gamefile_royal_coords[0]), BigInt(gamefile_royal_coords[1])], endCoords: [BigInt(endCoords[0]!), BigInt(endCoords[1]!)], }; // Now convert to most compact string notation: "x,y>x,y=Q" that the engine API accepts. return icnconverter.getCompactMoveFromDraft(moveCoords); } function doesTypeExist(boardsim: Board, type: number): boolean { const range = boardsim.pieces.typeRanges.get(type); if (range === undefined) return false; return range.end - range.start - range.undefineds.length > 0; } function getFirstOfType(boardsim: Board, type: number): DoubleCoords | undefined { const range = boardsim.pieces.typeRanges.get(type); if (range === undefined) return; if (range.end - range.start - range.undefineds.length <= 0) return; let undefinedidx = 0; for (let idx = range.start; idx < range.end; idx++) { if (idx === range.undefineds[undefinedidx]) { // Is our next undefined piece entry, skip. undefinedidx++; continue; } const bigintCoords: Coords = [ boardsim.pieces.XPositions[idx]!, boardsim.pieces.YPositions[idx]!, ]; // Convert the bigint coordinates to floating point coordinates that the engine works with. return convertBigIntCoordsToFloating(bigintCoords); } return; } /** * Converts bigint coords to floating point coords that the engine works with. * We can do this since the game gaurantees all moves are within safe limits. */ function convertBigIntCoordsToFloating(coords: Coords): DoubleCoords { return [Number(coords[0]!), Number(coords[1]!)]; } /** * This function is called from outside and initializes the engine calculation given the provided gamefile */ async function runEngine(): Promise { try { const board = input_gamefile.boardsim; // get real coordinates and parse type of black royal piece if (doesTypeExist(board, r.KING + e.B)) { gamefile_royal_coords = getFirstOfType(board, r.KING + e.B)!; royal_moves = king_moves; royal_type = 'k'; } else if (doesTypeExist(board, r.ROYALCENTAUR + e.B)) { gamefile_royal_coords = getFirstOfType(board, r.ROYALCENTAUR + e.B)!; royal_moves = centaur_moves; royal_type = 'rc'; } else { return console.error('No black king or royal centaur found in game'); } // create list of types and coords of white pieces, in order to initialize start_piecelist and start_coordlist start_piecelist = []; start_coordlist = []; for (const [type, range] of board.pieces.typeRanges) { let undefinedidx = 0; for (let idx = range.start; idx < range.end; idx++) { if (idx === range.undefineds[undefinedidx]) { // Is our next undefined piece entry, skip. undefinedidx++; continue; } if (Math.floor(type / numTypes) !== p.WHITE) continue; const bigintCoords: Coords = [ board.pieces.XPositions[idx]!, board.pieces.YPositions[idx]!, ]; // Convert the bigint coordinates to floating point coordinates that the engine works with. const coords = convertBigIntCoordsToFloating(bigintCoords); start_piecelist.push(pieceNameDictionary[type]!); // shift all white pieces, so that the black royal is at [0,0] start_coordlist.push([ coords[0] - gamefile_royal_coords[0], coords[1] - gamefile_royal_coords[1], ]); } } // reorder white piecelist and coordlist so that RNG is always initialized in the same way const sort_indices = start_coordlist .map((coord, index) => ({ coord: coord, index: index })) // Store index and coord in an object .sort((a, b) => { // Sort chosen objects by the stored coords const normA = manhattanNorm(a.coord); const normB = manhattanNorm(b.coord); if (normA !== normB) return normA - normB; else if (a.coord[1] !== b.coord[1]) return a.coord[1] - b.coord[1]; else return a.coord[0] - b.coord[0]; }) .map((object) => object.index); // Extract the new order of indices start_piecelist = sort_indices.map((i) => start_piecelist[i]!); // Reorder start_piecelist based on sort_indices start_coordlist = sort_indices.map((i) => start_coordlist[i]!); // Reorder start_coordlist based on sort_indices // Initialize seeded RNG function based on starting position const seedString = `${start_piecelist.toString()}|${start_coordlist.toString()}`; const seedArray = cyrb128(seedString); rand = mulberry32(seedArray[0]!); // If current position is recorded in bestMoveList, then don't do search but just do bestMove let positionInBestMoveList: boolean = false; for (const entry of bestMoveList) { if (JSON.stringify(start_piecelist) === JSON.stringify(entry.piecelist)) { if (JSON.stringify(start_coordlist) === JSON.stringify(entry.coordlist)) { globallyBestVariation[0] = [NaN, entry.bestMove]; positionInBestMoveList = true; break; } } } // run iteratively deepened move search if (!positionInBestMoveList) runIterativeDeepening(start_piecelist, start_coordlist, Infinity); // console.log(isBlackInTrap(start_piecelist, start_coordlist)); // console.log(get_white_candidate_moves(false, start_piecelist, start_coordlist)); // console.log(globalSurvivalPlies); // console.log(globallyBestVariation); // console.log(enginePositionCounter); // submit engine move after enough time has passed const time_now = Date.now(); if (time_now - engineStartTime < engineTimeLimitPerMoveMillis) { await new Promise((r) => setTimeout(r, engineTimeLimitPerMoveMillis - (time_now - engineStartTime)), ); } postMessage({ type: 'move', data: move_to_gamefile_move(globallyBestVariation[0]![1]!) }); } catch (e) { console.error('An error occured in the engine computation of the checkmate practice'); console.error(e); } } ================================================ FILE: src/client/scripts/esm/game/chess/engines/enginecards/hydrochess_card.ts ================================================ // src/client/scripts/esm/game/chess/engines/enginecards/hydrochess_card.ts import type { VariantOptions } from '../../../../../../../shared/chess/logic/initvariant'; import bimath from '../../../../../../../shared/util/math/bimath'; import bounds from '../../../../../../../shared/util/math/bounds'; import coordutil from '../../../../../../../shared/chess/util/coordutil'; import typeutil, { RawType, rawTypes as r, players as p, } from '../../../../../../../shared/chess/util/typeutil'; type SupportedResult = { supported: true } | { supported: false; reason: string }; // Constants ------------------------------------------------------------- /** Maximum signed 64-bit integer value (2^63 - 1). Used in Rust. */ const I64_MAX = 2n ** 63n - 1n; /** The maximum world border distance the engine can handle. */ const BORDER_CAP = I64_MAX - 1000n; // Small cushion const SUPPORTED_VARIANTS = new Set([ 'Classical', 'Confined_Classical', 'Classical_Plus', 'Core', 'CoaIP', 'CoaIP_HO', 'CoaIP_RO', 'CoaIP_NO', 'Palace', 'Pawndard', 'Standarch', 'Space_Classic', 'Space', 'Abundance', 'Pawn_Horde', 'Knightline', 'Obstocean', 'Chess', 'Omega', ]); // Functions ------------------------------------------------------------- /** * Determines whether the given position is supported by the engine. * If it's not, and we play a game with it anyway, the engine may crash. */ function isPositionSupported(variantOptions: VariantOptions): SupportedResult { // 1. Any win condition that is not checkmate, royalcapture, allroyalscaptured, or allpiecescaptured is unsupported. const supportedWinConditions = [ 'checkmate', 'royalcapture', 'allroyalscaptured', 'allpiecescaptured', ]; const usedWinConditions: string[] = Object.values( variantOptions.gameRules.winConditions, ).flat(); for (const winCondition of usedWinConditions) { if (!supportedWinConditions.includes(winCondition)) return { supported: false, reason: `Unsupported win condition: ${winCondition}.` }; } // 2. World border larger than i64 is unsupported. if ( !variantOptions.gameRules.worldBorder || Object.values(variantOptions.gameRules.worldBorder).some( (dist) => dist === null || bimath.abs(dist) > BORDER_CAP, ) ) { return { supported: false, reason: `World border exceeds limit.`, }; } // 3. Boundary of all pieces is entirely contained within world border (no piece out of bounds) if (variantOptions.gameRules.worldBorder) { const allCoords = [...variantOptions.position.keys()].map((coordsKey) => coordutil.getCoordsFromKey(coordsKey), ); const piecesBox = bounds.getBoxFromCoordsList(allCoords); if (!bounds.boxContainsBox(variantOptions.gameRules.worldBorder, piecesBox)) return { supported: false, reason: `Pieces are out of bounds.`, }; } // 4. Maximum of one promotion line per player. if (variantOptions.gameRules.promotionRanks) { for (const playerRanks of Object.values(variantOptions.gameRules.promotionRanks)) { if (playerRanks.length > 1) { return { supported: false, reason: `Multiple promotion lines per player.`, }; } } } // 5. Not too many pieces in total, excluding neutral pieces (voids/obstacles). const maxPieces = 200; let nonNeutralCount = 0; for (const type of variantOptions.position.values()) { const color = typeutil.getColorFromType(type); if (color !== p.NEUTRAL) nonNeutralCount++; } if (nonNeutralCount > maxPieces) { return { supported: false, reason: `Too many pieces: ${nonNeutralCount} (max ${maxPieces}).`, }; } // 6. Only suppported pieces may be present. const supportedPieces: RawType[] = [ r.VOID, r.OBSTACLE, r.KING, r.GIRAFFE, r.CAMEL, r.ZEBRA, r.KNIGHTRIDER, r.AMAZON, r.QUEEN, // rawTypes.ROYALQUEEN, // Not extensively tested r.HAWK, r.CHANCELLOR, r.ARCHBISHOP, r.CENTAUR, r.ROYALCENTAUR, r.ROSE, r.KNIGHT, r.GUARD, r.HUYGEN, r.ROOK, r.BISHOP, r.PAWN, ]; for (const type of variantOptions.position.values()) { const rawType = typeutil.getRawType(type); if (!supportedPieces.includes(rawType)) { return { supported: false, reason: `Unsupported piece type: ${typeutil.getRawTypeStr(rawType)}.`, }; } } return { supported: true }; } export default { // Constants I64_MAX, BORDER_CAP, SUPPORTED_VARIANTS, // Functions isPositionSupported, }; ================================================ FILE: src/client/scripts/esm/game/chess/engines/hydrochess.ts ================================================ // src/client/scripts/esm/game/chess/engines/hydrochess.ts /** * HydroChess Engine * A JavaScript wrapper for the WASM implementation of HydroChess * * @author FirePlank */ import icnconverter, { LongFormatIn, } from '../../../../../../shared/chess/logic/icn/icnconverter.js'; // @ts-ignore without this, the type check job fails import wasmUrl from '../../../../../pkg/hydrochess/pkg/hydrochess_wasm_bg.wasm'; // @ts-ignore without this, the type check job fails import init, * as wasmBindings from '../../../../../pkg/hydrochess/pkg/hydrochess_wasm.js'; const wasm = wasmBindings as typeof wasmBindings; let wasmInitialized = false; let wasmInitPromise: Promise | null = null; interface EngineConfig { engineTimeLimitPerMoveMillis?: number; strengthLevel?: number; } interface EngineWorkerMessage { stringGamefile: string; lf: LongFormatIn; engineConfig?: EngineConfig; youAreColor: number; wtime?: number; btime?: number; winc?: number; binc?: number; requestGeneratedMoves?: boolean; } interface WasmBestMoveResult { from: string; to: string; promotion?: string | null; } // Initializes the WASM module. // @returns Promise that resolves when the WASM module is initialized async function initWasm(): Promise { if (!wasmInitPromise) { console.debug('[Engine] Initializing HydroChess WASM module'); wasmInitPromise = init({ module_or_path: wasmUrl }) .then(async () => { console.debug('[Engine] HydroChess WASM module initialized'); wasmInitialized = true; postMessage('readyok'); return true; }) .catch((err: unknown) => { console.error('[Engine] Failed to initialize HydroChess WASM module', err); wasmInitialized = false; return false; }); } return wasmInitPromise!; } // Initialize WASM when the module is loaded void initWasm(); // Main entry point for the engine self.onmessage = async function (e: MessageEvent): Promise { const data = e.data; // Ensure WASM is initialized before processing commands if (!wasmInitialized) { const initialized = await initWasm(); if (!initialized) { console.error('[Engine] WASM module failed to initialize'); postMessage({ type: 'move', data: null }); return; } } try { const engineColor = data.youAreColor; // Convert compressed gamefile (lf) to ICN string const icnString = icnconverter.LongToShort_Format(data.lf, { compact: true, skipPosition: false, spaces: false, comments: false, make_new_lines: false, move_numbers: false, }); // Initialize engine configuration const engineConfig = { strength_level: data.engineConfig?.strengthLevel ?? 3, wtime: data.wtime ?? 0, btime: data.btime ?? 0, winc: data.winc ?? 0, binc: data.binc ?? 0, }; let engine; try { engine = wasm.Engine.from_icn(icnString, engineConfig); } catch (e) { console.error('[Engine] Failed to start engine from ICN:', e); postMessage({ type: 'move', data: null }); return; } // Send generated moves for debugging if requested if (data.requestGeneratedMoves === true) { const legalMoves: WasmBestMoveResult[] = engine.get_legal_moves_js(); const formattedMoves: string[] = legalMoves.map((m) => `${m.from}>${m.to}`); // Send the generated moves back to the main thread for rendering postMessage({ type: 'generatedMoves', data: formattedMoves }); engine.free(); return; } const timeLimit = data.engineConfig?.engineTimeLimitPerMoveMillis ?? 0; const bestMoveResult = engine.get_best_move_with_time(timeLimit, true); engine.free(); if (!bestMoveResult) { console.error('[Engine] No best move result returned from WASM'); postMessage({ type: 'move', data: null }); return; } // Format: "x,y>x,y" or "x,y>x,y=Q" (promotion) const from = bestMoveResult.from; const to = bestMoveResult.to; let moveString = `${from}>${to}`; if (bestMoveResult.promotion) { const promoAbbr = mapRustPromotionToSiteAbbr(bestMoveResult.promotion, engineColor); moveString += `=${promoAbbr}`; } postMessage({ type: 'move', data: moveString }); } catch (error) { console.error(`[Engine] Error finding best move:`, error); postMessage({ type: 'move', data: null }); } }; function mapRustPromotionToSiteAbbr( promotion: string | null | undefined, engineColor: number, ): string { const code = String(promotion ?? '').toLowerCase(); const isWhite = engineColor === 1; const map: Record = { q: { w: 'Q', b: 'q' }, r: { w: 'R', b: 'r' }, b: { w: 'B', b: 'b' }, n: { w: 'N', b: 'n' }, m: { w: 'AM', b: 'am' }, h: { w: 'HA', b: 'ha' }, c: { w: 'CH', b: 'ch' }, a: { w: 'AR', b: 'ar' }, e: { w: 'CE', b: 'ce' }, g: { w: 'GU', b: 'gu' }, l: { w: 'CA', b: 'ca' }, i: { w: 'GI', b: 'gi' }, z: { w: 'ZE', b: 'ze' }, y: { w: 'RQ', b: 'rq' }, d: { w: 'RC', b: 'rc' }, s: { w: 'NR', b: 'nr' }, u: { w: 'HU', b: 'hu' }, o: { w: 'RO', b: 'ro' }, k: { w: 'K', b: 'k' }, p: { w: 'P', b: 'p' }, }; const entry = map[code]; if (!entry) return isWhite ? 'Q' : 'q'; return isWhite ? entry.w : entry.b; } export {}; ================================================ FILE: src/client/scripts/esm/game/chess/game.ts ================================================ // src/client/scripts/esm/game/chess/game.ts /** * This script prepares our game. * * And contains our main update() and render() methods */ import type { Mesh } from '../rendering/piecemodels.js'; import type { Color } from '../../../../../shared/util/math/math.js'; import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import clock from '../../../../../shared/chess/logic/clock.js'; import bimath from '../../../../../shared/util/math/bimath.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import gui from '../gui/gui.js'; import mouse from '../../util/mouse.js'; import pieces from '../rendering/pieces.js'; import arrows from '../rendering/arrows/arrows.js'; import border from '../rendering/border.js'; import camera from '../rendering/camera.js'; import invites from '../misc/invites.js'; import gameslot from './gameslot.js'; import guititle from '../gui/guititle.js'; import boardpos from '../rendering/boardpos.js'; import controls from '../misc/controls.js'; import snapping from '../rendering/highlights/snapping.js'; import guiclock from '../gui/guiclock.js'; import premoves from './premoves.js'; import keybinds from '../misc/keybinds.js'; import animation from '../rendering/animation.js'; import selection from './selection.js'; import boarddrag from '../rendering/boarddrag.js'; import starfield from '../rendering/starfield.js'; import gameloader from './gameloader.js'; import highlights from '../rendering/highlights/highlights.js'; import droparrows from '../rendering/dragging/droparrows.js'; import dragarrows from '../rendering/dragging/dragarrows.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import boardtiles from '../rendering/boardtiles.js'; import Transition from '../rendering/transitions/Transition.js'; import primitives from '../rendering/primitives.js'; import maskedDraw from '../../webgl/maskedDraw.js'; import arrowshifts from '../rendering/arrows/arrowshifts.js'; import annotations from '../rendering/highlights/annotations/annotations.js'; import boardeditor from '../boardeditor/boardeditor.js'; import perspective from '../rendering/perspective.js'; import piecemodels from '../rendering/piecemodels.js'; import screenshake from '../rendering/screenshake.js'; import { GameBus } from '../GameBus.js'; import coordinates from '../rendering/coordinates.js'; import frametracker from '../rendering/frametracker.js'; import WaterRipples from '../rendering/WaterRipples.js'; import guinavigation from '../gui/guinavigation.js'; import draganimation from '../rendering/dragging/draganimation.js'; import webgl, { gl } from '../rendering/webgl.js'; import promotionlines from '../rendering/promotionlines.js'; import arrowsgraphics from '../rendering/arrows/arrowsgraphics.js'; import { ProgramManager } from '../../webgl/ProgramManager.js'; import { EffectZoneManager } from '../rendering/effect_zone/EffectZoneManager.js'; import arrowlegalmovehighlights from '../rendering/arrows/arrowlegalmovehighlights.js'; import selectedpiecehighlightline from '../rendering/highlights/selectedpiecehighlightline.js'; import buffermodel, { createRenderable } from '../../webgl/Renderable.js'; import { CreateInputListener, InputListener } from '../input.js'; import { PostProcessingPipeline, PostProcessPass, } from '../../webgl/post_processing/PostProcessingPipeline.js'; // Variables ------------------------------------------------------------------------------- const element_overlay: HTMLElement = document.getElementById('overlay')!; /** The input listener for the overlay element */ let listener_overlay: InputListener; /** The input listener for the document element */ let listener_document: InputListener; /** Manager of our Shaders */ let programManager: ProgramManager; /** Manager of Post Processing Effects */ let pipeline: PostProcessingPipeline; /** Manager of Effect Zones */ let effectZoneManager: EffectZoneManager | undefined; // /** // * Replaces the starfield with a gradient color flow inside void. // * Used for creating video footage. // */ // let colorFlowRenderer: ColorFlowRenderer; // Functions ------------------------------------------------------------------------------- function init(): void { programManager = new ProgramManager(gl); buffermodel.init(gl, programManager); maskedDraw.init(programManager); pipeline = new PostProcessingPipeline(gl, programManager); effectZoneManager = new EffectZoneManager(gl, programManager); // colorFlowRenderer = new ColorFlowRenderer(gl); WaterRipples.init(programManager, gl.canvas.width, gl.canvas.height); boardtiles.init(); listener_overlay = CreateInputListener(element_overlay, { keyboard: false }); listener_document = CreateInputListener(document); gui.prepareForOpen(); guititle.open(); // Update the pipeline on canvas resize document.addEventListener('canvas_resize', (event) => { const { width, height } = event.detail; pipeline.resize(width, height); }); } // Update the game every single frame function update(): void { screenshake.update(); controls.testOutGameToggles(); invites.update(); // Any input should trigger the next frame to render. if (listener_document.atleastOneInput() || listener_overlay.atleastOneInput()) frametracker.onVisualChange(); if (gameloader.areWeLoadingGame()) return; // If the game isn't totally finished loading, nothing is visible, only the loading animation. const gamefile = gameslot.getGamefile(); const mesh = gameslot.getMesh(); if (!gamefile) { // Only do title screen updates boardpos.update(); boardtiles.recalcVariables(); // Update the effect zone manager. effectZoneManager!.update(getFurthestTileVisible()); return; } // There is a gamefile, update everything board-related... starfield.update(); // Update the star field animation, if needed. controls.testInGameToggles(gamefile, mesh); perspective.update(); // Update perspective camera according to mouse movement const timeWinner = clock.update(gamefile.basegame); if (timeWinner && !onlinegame.areInOnlineGame()) { // undefined if no clock has ran out gamefileutility.setConclusion(gamefile.basegame, { victor: timeWinner, condition: 'time' }); gameslot.concludeGame(); } guiclock.update(gamefile.basegame); controls.updateNavControls(); // Update board dragging, and WASD to move, scroll to zoom boardpos.update(); // Updates the board's position and scale according to its velocity boarddrag.dragBoard(); // Calculate new board position if it's being dragged. After updateNavControls(), executeArrowShifts(), boardpos.update // BEFORE board.recalcVariables(), as that needs to be called after the board position is updated. Transition.update(); // AFTER boarddrag.dragBoard() or picking up the board has a spring back effect to it // AFTER:transition.update() since that updates the board position boardtiles.recalcVariables(); // Update the effect zone manager (after board variables are recalculated). effectZoneManager!.update(getFurthestTileVisible()); // Check if the board needs to be pinched (will not single-pointer grab) // This needs to be high up, as pinching the board has priority over the pointer than a lot of things. boarddrag.checkIfBoardPinched(); // NEEDS TO BE BEFORE selection.update() and boarddrag.checkIfBoardSingleGrabbed() // because the drawing tools of the boad editor might take precedence and claim the left mouse click boardeditor.update(); // NEEDS TO BE AFTER animation.update() because this updates droparrows.ts and that needs to overwrite animations. // BEFORE arrows.update(), since this may forward to front, which changes all arrows visible. selection.update(); // NEEDS TO BE AFTER guinavigation.update(), because otherwise arrows.js may think we are hovering // over a piece from before forwarding/rewinding a move, causing a crash. arrows.update(); // NEEDS TO BE AFTER arrows.update() !!! Because this modifies the arrow indicator list. // NEEDS TO BE BEFORE boarddrag.checkIfBoardSingleGrabbed() because that shift arrows needs to overwrite this. animation.update(); draganimation.updateDragLocation(); // BEFORE droparrows.shiftArrows() so that can overwrite this. droparrows.shiftArrows(); // Shift the arrows of the dragged piece AFTER selection.update() makes any moves made! dragarrows.update(); // AFTER droparrows.shiftArrows(), BEFORE executeArrowShifts(). arrowshifts.executeArrowShifts(); // Execute any arrow modifications made by animation.js or arrowsdrop.js. Before arrowlegalmovehighlights.update(), dragBoard() droparrows.updateLegalCaptureArrows(); // AFTER executeArrowShifts(), so rebuilt arrow lines don't reset pulsating opacities. arrowlegalmovehighlights.update(); // After executeArrowShifts() // BEFORE annotations.update() since adding new highlights snaps to what mini image is being hovered over. // NEEDS TO BE BEFORE checkIfBoardDragged(), because clicks should prioritize teleporting to miniimages over dragging the board! // AFTER: boardpos.dragBoard(), because whether the miniimage are visible or not depends on our updated board position and scale. snapping.teleportToEntitiesIfClicked(); // AFTER snapping.updateEntitiesHovered() snapping.teleportToSnapIfClicked(); premoves.update(gamefile, mesh); // BEFORE annotations update(), since if right click cancels premoves, we don't want to draw arrows. // AFTER snapping.updateEntitiesHovered(), since adding/removing depends on current hovered entities. annotations.update(); // AFTER snapping.updateSnapping(), since clicking on a highlight line should claim the click that would other wise collapse all annotations. testIfEmptyBoardRegionClicked(gamefile, mesh); // If we clicked an empty region of the board, collapse annotations and cancel premoves. // Now we can check if the board needs to be single-pointer grabbed, // as other scripts may have claimed the pointer first. // AFTER: selection.update(), animation.update() because shift arrows needs to overwrite that. // After entities.updateEntitiesHovered() because clicks prioritize those. boarddrag.checkIfBoardSingleGrabbed(); gameloader.update(); // Updates whatever game is currently loaded. guinavigation.updateElement_Coords(); // Update the division on the screen displaying your current coordinates // preferences.update(); // ONLY USED for temporarily micro adjusting theme properties & colors } /** * Tests if by clicking an empty region of the board, * we need to clear premoves and collapse annotations. */ function testIfEmptyBoardRegionClicked(gamefile: FullGame, mesh: Mesh | undefined): void { const mouseKeybind = keybinds.getCollapseMouseButton(); if (mouseKeybind === undefined) return; // No button is assigned to collaping annotes / cancelling premoves currently if (mouse.isMouseClicked(mouseKeybind)) { mouse.claimMouseClick(mouseKeybind); premoves.cancelPremoves(gamefile, mesh); annotations.Collapse(); } } /** * Renders everthing in-game, and applies post processing effects to the final image. */ function render(): void { // First gather all post processing effects this frame const passes: PostProcessPass[] = []; // Append water ripples of really far moves! passes.push(...WaterRipples.getPass()); // Add the current effect zone passes passes.push(...effectZoneManager!.getActivePostProcessPasses()); // Set them in the pipeline pipeline.setPasses(passes); // Only use the pipeline if there are any current effects, // as a completely empty pipeline still increases gpu usage by roughly 33% // Tell the pipeline to begin. All subsequent rendering will go to a texture. if (passes.length > 0) pipeline.begin(); // Render the game scene renderScene(); // Tell the pipeline we are finished drawing the scene. // It will handle drawing the result to the screen. if (passes.length > 0) pipeline.end(); } /** Renders all in our scene. */ function renderScene(): void { if (gameloader.areWeLoadingGame()) return; // If the game isn't totally finished loading, nothing is visible, only the loading animation. const gamefile = gameslot.getGamefile(); const mesh = gameslot.getMesh(); // if (!gamefile) return boardtiles.render(); // No gamefile, on the selection menu. Only render the checkerboard and nothing else. if (!gamefile) { effectZoneManager!.renderBoard(); return; } // Star Field Animation: Appears in border & voids maskedDraw.execute( () => piecemodels.renderVoids(mesh), // INCLUSION MASK is our voids () => border.drawPlayableRegionMask(gamefile.basegame.gameRules.worldBorder), // EXCLUSION MASK is our playable region () => starfield.render(), // MAIN SCENE // () => colorFlowRenderer.render(loadbalancer.getDeltaTime()), // Replaces starfield with a gradient color flow 'or', // Intersection Mode: Draw in both the inclusion and inversion of exclusion regions. ); // Board Tiles & Voids: Mask the playable region so the tiles // don't render outside the world border or where voids should be maskedDraw.execute( () => border.drawPlayableRegionMask(gamefile.basegame.gameRules.worldBorder), // INCLUSION MASK containing playable region () => piecemodels.renderVoids(mesh), // EXCLUSION MASK (voids) () => renderTilesAndPromoteLines(), // MAIN SCENE 'and', // Intersection Mode: Draw where the inclusion and inversion of exclusion regions intersect. ); if (camera.getDebug() && !perspective.getEnabled()) renderOutlineofScreenBox(); /** * What is the order of rendering? * * Board tiles * Highlights * Pieces * Arrows * Crosshair */ // Using depth function "ALWAYS" means we don't have to render with a tiny z offset webgl.executeWithDepthFunc_ALWAYS(() => { coordinates.render(); selectedpiecehighlightline.render(); highlights.render(gamefile.boardsim); GameBus.dispatch('render-below-pieces'); snapping.render(); // Renders ghost image or glow dot over snapped point on highlight lines. animation.renderTransparentSquares(); // Required to hide the piece currently being animated draganimation.renderTransparentSquare(); // Required to hide the piece currently being animated }); // The rendering of the pieces needs to use the normal depth function, because the // rendering of currently-animated pieces needs to be blocked by animations. pieces.renderPiecesInGame(gamefile.boardsim, mesh); // Using depth function "ALWAYS" means we don't have to render with a tiny z offset webgl.executeWithDepthFunc_ALWAYS(() => { animation.renderAnimations(); selection.renderGhostPiece(); // If not after pieces.renderPiecesInGame(), wont render on top of existing pieces draganimation.renderPiece(); dragarrows.render(); arrowsgraphics.render(); boardeditor.render(); annotations.render_abovePieces(); GameBus.dispatch('render-above-pieces'); perspective.renderCrosshair(); }); } /** Renders items that need to be able to be masked by the world border. */ function renderTilesAndPromoteLines(): void { effectZoneManager!.renderBoard(); promotionlines.render(); } /** * [DEBUG] Renders an outline of the viewing screen bounding box. * Will only be visible if camera debug mode is on. */ function renderOutlineofScreenBox(): void { const { left, right, bottom, top } = camera.getScreenBoundingBox(false); // const color: Color = [0.65,0.15,0, 1]; // Maroon (matches light brown wood theme) const color: Color = [0, 0, 0, 0.5]; // Transparent Black const data = primitives.Rect(left, bottom, right, top, color); createRenderable(data, 2, 'LINE_LOOP', 'color', true).render(); } /** Returns the absolute value of the furthest tile from the origin on our screen. */ function getFurthestTileVisible(): bigint { const tileBox = boardtiles.gboundingBox(false); let furthest: bigint = 0n; if (bimath.abs(tileBox.left) > furthest) furthest = bimath.abs(tileBox.left); if (bimath.abs(tileBox.right) > furthest) furthest = bimath.abs(tileBox.right); if (bimath.abs(tileBox.top) > furthest) furthest = bimath.abs(tileBox.top); if (bimath.abs(tileBox.bottom) > furthest) furthest = bimath.abs(tileBox.bottom); return furthest; } /** Returns the overlay element covering the entire canvas. */ function getOverlay(): HTMLElement { return element_overlay; } export default { init, update, render, getOverlay, }; export { listener_overlay, listener_document }; ================================================ FILE: src/client/scripts/esm/game/chess/gamecompressor.ts ================================================ // src/client/scripts/esm/game/chess/gamecompressor.ts /** * This script handles the compression of a gamefile into a more simple json format, * suitable for the icnconverter to turn it into ICN (Infinite Chess Notation). */ import type { MoveFull } from '../../../../../shared/chess/logic/movepiece.js'; import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { CoordsKey } from '../../../../../shared/chess/util/coordutil.js'; import type { EnPassant } from '../../../../../shared/chess/logic/state.js'; import type { GameRules } from '../../../../../shared/chess/util/gamerules.js'; import state from '../../../../../shared/chess/logic/state.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import boardchanges from '../../../../../shared/chess/logic/boardchanges.js'; import { MovePreprint, LongFormatIn, PresetAnnotes, } from '../../../../../shared/chess/logic/icn/icnconverter.js'; /** * This is the bare minimum gamefile you need to keep track of STATE, * or, properties of a gamefile that may change from making moves, * and you don't record the moves list so second-handedly keep track * of states like whosTurn and fullMove number. * * This is used in {@link GameToPosition} when converting a gamefile to a single position. */ interface SimplifiedGameState { // The pieces position: Map; // The turnOrder rotating essentially keeps track of whos turn it is in the position. turnOrder: GameRules['turnOrder']; // The fullMove number increments with every turn cycle fullMove: number; // For state.ts, the 3 global game states state_global: { specialRights: Set; enpassant?: EnPassant; moveRuleState?: number; }; } /** * Primes the provided gamefile to for the icnconverter to turn it into an ICN * @param gamefile - The gamefile * @param copySinglePosition - If true, only copy the current position, not the entire game. It won't have the moves list. * @param presetAnnotes - Should be specified if we have overrides for the variant's preset annotations. * @returns The primed gamefile for converting into ICN format */ function compressGamefile( { basegame, boardsim }: FullGame, copySinglePosition?: boolean, presetAnnotes?: PresetAnnotes, ): LongFormatIn { // console.log("Compressing gamefile for ICN conversion..."); // console.log("Basegame:", jsutil.deepCopyObject(basegame)); // console.log("Boardsim:", jsutil.deepCopyObject(boardsim)); /* * We need to calculate the game state so that, if desired, * we can convert the gamefile to a single position. */ const gameRulesCopy = jsutil.deepCopyObject(basegame.gameRules); let gamestate: SimplifiedGameState = { position: jsutil.deepCopyObject(boardsim.startSnapshot.position), turnOrder: gameRulesCopy.turnOrder, fullMove: boardsim.startSnapshot.fullMove, state_global: jsutil.deepCopyObject(boardsim.startSnapshot.state_global), }; // Modify the state if we're applying moves to match a single position if (copySinglePosition) gamestate = GameToPosition(gamestate, boardsim.moves, boardsim.state.local.moveIndex + 1); // Convert -1 based to 0 based // Start constructing the abridged gamefile const long_format_in: LongFormatIn = { metadata: jsutil.deepCopyObject(basegame.metadata), position: gamestate.position, gameRules: gameRulesCopy, fullMove: gamestate.fullMove, state_global: gamestate.state_global, moves: copySinglePosition ? [] : convertMovesToICNConverterInMove(boardsim.moves), }; // Add the preset annotation overrides from the previously pasted game, if present. if (presetAnnotes) long_format_in.presetAnnotes = presetAnnotes; // console.log("Constructed LongFormatIn:", jsutil.deepCopyObject(long_format_in)); return long_format_in; } function convertMovesToICNConverterInMove(moves: MoveFull[]): MovePreprint[] { const mappedMoves = moves.map((move: MoveFull) => { const movePreprint: MovePreprint = { type: move.type, startCoords: move.startCoords, endCoords: move.endCoords, token: move.token, flags: move.flags, }; // Optionals if (move.promotion !== undefined) movePreprint.promotion = move.promotion; if (move.comment) movePreprint.comment = move.comment; if (move.clockStamp !== undefined) movePreprint.clockStamp = move.clockStamp; return movePreprint; }); return jsutil.deepCopyObject(mappedMoves); } // Converting a Game to Single Position --------------------------------------------------------------------------------- /** * Takes a simple game state and applies the desired moves to it, modifying it. * @param longform * @param moves - The moves of the original gamefile to apply to the state * @param [halfmoves] - Number of halfmoves from starting position to apply to the state (Infinity: final position of game) */ function GameToPosition( longform: SimplifiedGameState, moves: MoveFull[], halfmoves: number = 0, ): SimplifiedGameState { if (halfmoves === Infinity) halfmoves = moves.length; // If we want the final position, set halfmoves to the length of the moves array if (moves.length < halfmoves) throw Error( `Cannot convert game to position. Moves length (${moves.length}) is less than desired halfmoves (${halfmoves}).`, ); if (halfmoves === 0) return longform; // No changes needed // console.log('Before converting gamestate to single position:', jsutil.deepCopyObject(longform)); // First update the fullMove number. Increment one for each full turn cycle applied to the state. longform.fullMove += Math.floor(halfmoves / longform.turnOrder.length); // Iterate through each move, progressively applying their game state changes, // until we reach the desired halfmove. for (let i = 0; i < halfmoves; i++) { const move = moves[i]!; // Apply the move's state changes. // state.applyMove(longform, move.state, true, { globalChange: true }); // Apply the State of the move state.applyGlobalStateChanges(longform.state_global, move.state.global, true); // Next apply the logical (piece) changes. boardchanges.runChanges_Position(longform.position, move.changes); // Rotate the turn order, moving the first player to the back longform.turnOrder.push(longform.turnOrder.shift()!); } // console.log('After converting gamestate to single position:', jsutil.deepCopyObject(longform)); return longform; } // Exports -------------------------------------------------------------------------------------------------------------- export default { compressGamefile, GameToPosition, }; export type { SimplifiedGameState }; ================================================ FILE: src/client/scripts/esm/game/chess/gamecompressor.unit.test.ts ================================================ // src/client/scripts/esm/game/chess/gamecompressor.unit.test.ts import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { SimplifiedGameState } from './gamecompressor.js'; import { describe, it, expect } from 'vitest'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import gamecompressor from './gamecompressor.js'; describe('gamecompressor', () => { describe('compressGamefile', () => { it('should compress a basic gamefile correctly', () => { const mockMetaData = { Event: 'Boston Infinite Chess Party', Site: 'https://www.infinitechess.org/', TimeControl: '-', Round: '-', UTCDate: '1987.06.27', UTCTime: '12:00:00', Variant: 'standard', White: 'Rick', Black: 'Waterman', } as const; const mockGame: FullGame = { basegame: { metadata: mockMetaData, dateTimestamp: Date.now(), // The game rules are essential for the compressor to know the turn order gameRules: { turnOrder: [p.WHITE, p.BLACK], } as any, moves: [], whosTurn: p.WHITE, untimed: true, clocks: undefined, }, boardsim: { startSnapshot: { position: new Map(), fullMove: 1, state_global: { specialRights: new Set(), }, }, moves: [], state: { local: { moveIndex: -1, }, }, } as any, }; const result = gamecompressor.compressGamefile(mockGame); expect(result.metadata).toEqual(mockGame.basegame.metadata); expect(result.fullMove).toBe(1); expect(result.moves).toEqual([]); }); }); describe('GameToPosition', () => { it('should return the same state if halfmoves is 0', () => { const initialState: SimplifiedGameState = { position: new Map(), turnOrder: [p.WHITE, p.BLACK], fullMove: 1, state_global: { specialRights: new Set(), }, }; const result = gamecompressor.GameToPosition(initialState, [], 0); expect(result).toBe(initialState); }); }); }); ================================================ FILE: src/client/scripts/esm/game/chess/gameformulator.ts ================================================ // src/client/scripts/esm/game/chess/gameformulator.ts /** * This script takes an ICN, or a compressed abridged gamefile, and constructs a full gamefile from them. */ import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { MovePacket } from '../../../../../shared/types.js'; import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import type { MovePreprint, LongFormatIn, } from '../../../../../shared/chess/logic/icn/icnconverter.js'; import variant from '../../../../../shared/chess/variants/variant.js'; import gamefile from '../../../../../shared/chess/logic/gamefile.js'; import clientmetadatautil from './clientmetadatautil.js'; /** * Formulates a whole gamefile from a smaller simpler abridged one. * @param longformIn - The return value of gamecompressor.compressGamefile() * @param validateMoves - Optional flag to validate move legality during formulation, throwing an error if any move is illegal. */ function formulateGame(longformIn: LongFormatIn, validateMoves?: true): FullGame { if (longformIn.position === undefined || longformIn.state_global.specialRights === undefined) { throw Error( 'Invalid longformIn when formulating game: Missing position or special rights.', ); } /** String array of the moves in their most compact notation (e.g. "4,7>4,8Q") */ const moves: MovePacket[] = longformIn.moves?.map((m: MovePreprint) => { const move: MovePacket = { token: m.token }; if (m.clockStamp !== undefined) move.clockStamp = m.clockStamp; return move; }) ?? []; const variantOptions: VariantOptions = { fullMove: longformIn.fullMove, gameRules: longformIn.gameRules, position: longformIn.position!, state_global: { specialRights: longformIn.state_global.specialRights, enpassant: longformIn.state_global.enpassant, moveRuleState: longformIn.state_global.moveRuleState, }, }; const resolvedTimestamp = clientmetadatautil.resolveTimestampFromMetadata( longformIn.metadata.UTCDate, longformIn.metadata.UTCTime, ); const resolvedVariant = variant.resolveVariantCode(longformIn.metadata.Variant); return gamefile.initFullGame( longformIn.metadata, resolvedTimestamp, resolvedVariant, { variantOptions, moves }, validateMoves, ); } export default { formulateGame, }; ================================================ FILE: src/client/scripts/esm/game/chess/gameloader.ts ================================================ // src/client/scripts/esm/game/chess/gameloader.ts /** * This script contains the logic for loading any kind of game onto our game board: * * Local * * Online * * Analysis Board (in the future) * * Board Editor (in the future) * * It not only handles the logic of the gamefile, * but also prepares and opens the UI elements for that type of game. */ import type { Player } from '../../../../../shared/chess/util/typeutil.js'; import type { Additional } from '../../../../../shared/chess/logic/gamefile.js'; import type { ValidEngine } from './engines/engine.js'; import type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js'; import type { EngineConfig } from '../misc/enginegame.js'; import type { PresetAnnotes } from '../../../../../shared/chess/logic/icn/icnconverter.js'; import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import type { ServerGameInfo } from '../websocket/socketschemas.js'; import type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js'; import type { ClockValues, MetaData, MovePacket, ParticipantState, TimeControl, } from '../../../../../shared/types.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import variant from '../../../../../shared/chess/variants/variant.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import gui from '../gui/gui.js'; import gameslot from './gameslot.js'; import boardpos from '../rendering/boardpos.js'; import guiclock from '../gui/guiclock.js'; import IndexedDB from '../../util/IndexedDB.js'; import Transition from '../rendering/transitions/Transition.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import enginegame from '../misc/enginegame.js'; import guipalette from '../gui/boardeditor/guipalette.js'; import perspective from '../rendering/perspective.js'; import guigameinfo from '../gui/guigameinfo.js'; import boardeditor from '../boardeditor/boardeditor.js'; import loadingscreen from '../gui/loadingscreen.js'; import guinavigation from '../gui/guinavigation.js'; import guiboardeditor from '../gui/boardeditor/guiboardeditor.js'; import clientmetadatautil from './clientmetadatautil.js'; import { engineDictionary, getFormattedEngineName } from './engines/engine.js'; // Variables -------------------------------------------------------------------- /** The type of game we are in, whether local or online, if we are in a game. */ let typeOfGameWeAreIn: undefined | 'local' | 'online' | 'engine' | 'editor'; /** * True when the gamefile is currently loading either the graphical * (such as the SVG requests and spritesheet generation) or engine script. * * If so, the spinny pawn loading animation will be open. */ let gameLoading: boolean = false; // Getters -------------------------------------------------------------------- /** * Returns true if we are in ANY type of game, whether local, online, engine, analysis, or editor. * * If we're on the title screen or the lobby, this will be false. */ function areInAGame(): boolean { return typeOfGameWeAreIn !== undefined; } /** Returns the type of game we are in. */ function getTypeOfGameWeIn(): typeof typeOfGameWeAreIn { return typeOfGameWeAreIn; } function areInLocalGame(): boolean { return typeOfGameWeAreIn === 'local'; } function isItOurTurn(): boolean { if (typeOfGameWeAreIn === undefined) throw Error("Can't tell if it's our turn when we're not in a game!"); if (typeOfGameWeAreIn === 'online') return onlinegame.isItOurTurn(); else if (typeOfGameWeAreIn === 'engine') return enginegame.isItOurTurn(); else if (typeOfGameWeAreIn === 'editor') return true; else if (typeOfGameWeAreIn === 'local') return true; // Always our turn in this case else throw Error( "Don't know how to tell if it's our turn in this type of game: " + typeOfGameWeAreIn, ); } function getOurColor(): Player | undefined { if (typeOfGameWeAreIn === undefined) throw Error("Can't get our color when we're not in a game!"); if (typeOfGameWeAreIn === 'online') return onlinegame.getOurColor(); else if (typeOfGameWeAreIn === 'engine') return enginegame.getOurColor(); throw Error("Can't get our color in this type of game: " + typeOfGameWeAreIn); } /** * Returns true if either the graphics (spritesheet generating), * or engine script, of the gamefile are currently being loaded. * * If so, the spinny pawn loading animation will be open. */ function areWeLoadingGame(): boolean { return gameLoading; } /** * Updates whatever game is currently loaded, for what needs to be updated. */ function update(): void { if (typeOfGameWeAreIn === 'online') onlinegame.update(); } // Start Game -------------------------------------------------------------------- /** Starts a local game according to the options provided. */ async function startLocalGame(options: { variant: VariantCode; timeControl: TimeControl; }): Promise { typeOfGameWeAreIn = 'local'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const variantName = variant.getVariantName(options.variant); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( `Casual local ${variantName} infinite chess game`, options.timeControl, dateTimestamp, ); metadata.Variant = variantName; gameslot .loadGamefile({ metadata, variant: options.variant, dateTimestamp, viewWhitePerspective: true, allowEditCoords: true, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); // Open the gui stuff AFTER initiating the logical stuff, // because the gui DEPENDS on the other stuff. openGameinfoBarAndConcludeGameIfOver(metadata, false); } /** Starts an online game according to the options provided by the server. */ async function startOnlineGame(options: { gameInfo: ServerGameInfo; /** The metadata of the game, including the TimeControl, player names, date, etc.. */ metadata: MetaData; gameConclusion?: GameConclusion; /** Existing moves, if any, to forward to the front of the game. Should be specified if reconnecting to an online. Each move should be in the most compact notation, e.g., `['1,2>3,4','10,7>10,8Q']`. */ moves: MovePacket[]; clockValues?: ClockValues; youAreColor?: Player; participantState?: ParticipantState; }): Promise { // console.log("Starting online game with invite options:"); // console.log(jsutil.deepCopyObject(options)); typeOfGameWeAreIn = 'online'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const storageKey = onlinegame.getKeyForOnlineGameVariantOptions(options.gameInfo.id); const additional: Additional = { moves: options.moves, variantOptions: await IndexedDB.loadItem(storageKey), gameConclusion: options.gameConclusion, // If the clock values are provided, adjust the timer of whos turn it is depending on ping. clockValues: options.clockValues, }; const resolvedVariant = variant.resolveVariantCode(options.metadata.Variant); const resolvedTimestamp = clientmetadatautil.resolveTimestampFromMetadata( options.metadata.UTCDate, options.metadata.UTCTime, ); gameslot .loadGamefile({ metadata: options.metadata, variant: resolvedVariant, dateTimestamp: resolvedTimestamp, viewWhitePerspective: options.youAreColor === p.WHITE, allowEditCoords: false, additional, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); onlinegame.initOnlineGame({ gameInfo: options.gameInfo, youAreColor: options.youAreColor, participantState: options.participantState, }); // We need this here because otherwise if we reconnect to the page after refreshing, the sound effects don't play. // IF THIS DOES NOT COME AFTER onlinegame.initOnlineGame(), then guiclock inaccurately thinks it's a local game, // THUS playing the drum sound effect for our opponent. const basegame = gameslot.getGamefile()!.basegame; if (!basegame.untimed) guiclock.rescheduleSoundEffects(basegame.clocks); // Open the gui stuff AFTER initiating the logical stuff, // because the gui DEPENDS on the other stuff. openGameinfoBarAndConcludeGameIfOver(options.metadata, false); } /** Starts an engine game according to the options provided. */ async function startEngineGame(options: { /** The 'Event' string of the game's metadata. */ event: string; /** Time control string for the game (e.g. `'600+5'`), or `'-'` for untimed. */ timeControl: TimeControl; /** If it's not a practice checkmate, this is the variant code. * MUTUALLY EXCLUSIVE with variantOptions. */ variant: VariantCode | null; /** MUTUALLY EXCLUSIVE with Variant. */ variantOptions?: VariantOptions; youAreColor: Player; currentEngine: ValidEngine; engineConfig: EngineConfig; /** Whether to show the Undo and Restart buttons on the gameinfo bar. For checkmate practice games. */ showGameControlButtons?: true; }): Promise { if (options.variant && options.variantOptions) throw Error( "Can't provide both Variant and variantOptions at the same time when starting an engine game. They are mutually exclusive.", ); if (!options.variant && !options.variantOptions) throw Error('Must provide either Variant or variantOptions when starting an engine game.'); typeOfGameWeAreIn = 'engine'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const formattedEngineName = getFormattedEngineName( options.currentEngine, options.engineConfig.strengthLevel, ); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( options.event, options.timeControl, dateTimestamp, ); if (options.variant) metadata.Variant = variant.getVariantName(options.variant); metadata.White = options.youAreColor === p.WHITE ? clientmetadatautil.YOU_NAME_ICN_METADATA : formattedEngineName; metadata.Black = options.youAreColor === p.BLACK ? clientmetadatautil.YOU_NAME_ICN_METADATA : formattedEngineName; /** A promise that resolves when the GRAPHICAL (spritesheet) part of the game has finished loading. */ const graphicalPromise: Promise = gameslot.loadGamefile({ metadata, variant: options.variant, dateTimestamp, viewWhitePerspective: options.youAreColor === p.WHITE, allowEditCoords: false, additional: { variantOptions: options.variantOptions, worldBorderDist: engineDictionary[options.currentEngine].worldBorder, }, }); /** A promise that resolves when the engine script has been fetched. */ const enginePromise: Promise = enginegame .initEngineGame(options) .then(() => enginegame.onMovePlayed()); // Without this, the engine won't start calculating moves if it's first to move. /** * This resolves when BOTH the graphical and engine promises resolve, * OR rejects immediately when one of them rejects! */ Promise.all([graphicalPromise, enginePromise]) .then((_results: any[]) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); openGameinfoBarAndConcludeGameIfOver(metadata, options.showGameControlButtons); } /** Initializes the board editor. */ async function startBoardEditor(): Promise { typeOfGameWeAreIn = 'editor'; gameLoading = true; await loadingscreen.open(); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( 'Position created using ingame board editor', '-', dateTimestamp, ); const variantCode: VariantCode = 'Classical'; metadata.Variant = variant.getVariantName(variantCode); gameslot .loadGamefile({ metadata, variant: variantCode, dateTimestamp, viewWhitePerspective: true, allowEditCoords: true, /** * Enable to tell the gamefile to include large amounts of undefined slots for every single piece type in the game. * This lets us board edit without worry of regenerating the mesh every time we add a piece. * * This flag triggers the gamefile to add images for EVERY single piece in the spritesheet! * If that also includes all COLORS, then loading a game can take a few seconds... */ additional: { editor: true }, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); await guipalette.initUI(); boardeditor.initBoardEditor(true); // Dirty position since its a new unsaved position being loaded } /** Initializes a local game from a custom position. */ async function startCustomLocalGame(options: { additional: { moves?: MovePacket[]; variantOptions: VariantOptions; }; presetAnnotes?: PresetAnnotes; }): Promise { typeOfGameWeAreIn = 'local'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( 'Casual local custom infinite chess game', '-', dateTimestamp, ); gameslot .loadGamefile({ ...options, metadata, dateTimestamp, variant: null, // Not specified for custom position viewWhitePerspective: true, allowEditCoords: true, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); // Open the gui stuff AFTER initiating the logical stuff, // because the gui DEPENDS on the other stuff. openGameinfoBarAndConcludeGameIfOver(metadata, false); } /** Starts an engine game from a custom position. */ async function startCustomEngineGame(options: { timeControl: TimeControl; additional: { moves?: MovePacket[]; variantOptions: VariantOptions; }; presetAnnotes?: PresetAnnotes; youAreColor: Player; currentEngine: ValidEngine; engineConfig: EngineConfig; /** Whether to show the Undo and Restart buttons on the gameinfo bar. For checkmate practice games. */ showGameControlButtons?: true; }): Promise { typeOfGameWeAreIn = 'engine'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const formattedEngineName = getFormattedEngineName( options.currentEngine, options.engineConfig.strengthLevel, ); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( 'Casual computer custom infinite chess game', options.timeControl, dateTimestamp, ); metadata.White = options.youAreColor === p.WHITE ? clientmetadatautil.YOU_NAME_ICN_METADATA : formattedEngineName; metadata.Black = options.youAreColor === p.BLACK ? clientmetadatautil.YOU_NAME_ICN_METADATA : formattedEngineName; /** A promise that resolves when the GRAPHICAL (spritesheet) part of the game has finished loading. */ const graphicalPromise: Promise = gameslot.loadGamefile({ metadata, variant: null, // Not specified for custom position dateTimestamp, viewWhitePerspective: options.youAreColor === p.WHITE, allowEditCoords: false, additional: { variantOptions: options.additional.variantOptions, worldBorderDist: engineDictionary[options.currentEngine].worldBorder, }, }); /** A promise that resolves when the engine script has been fetched. */ const enginePromise: Promise = enginegame .initEngineGame(options) .then(() => enginegame.onMovePlayed()); // Without this, the engine won't start calculating moves if it's first to move. /** * This resolves when BOTH the graphical and engine promises resolve, * OR rejects immediately when one of them rejects! */ Promise.all([graphicalPromise, enginePromise]) .then((_results: any[]) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); openGameinfoBarAndConcludeGameIfOver(metadata, options.showGameControlButtons); } /** Initializes the board editor from a custom position. */ async function startBoardEditorFromCustomPosition( options: { additional: { moves?: MovePacket[]; variantOptions: VariantOptions; }; presetAnnotes?: PresetAnnotes; }, /** Whether the position has unsaved changes. Defaults to true (dirty). */ dirty: boolean, /** Whether the pawnDoublePush flag should be set for the position in the editor game rules */ pawnDoublePush?: boolean, /** Whether the castling flag should be set for the position in the editor game rules */ castling?: boolean, ): Promise { typeOfGameWeAreIn = 'editor'; gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const dateTimestamp = Date.now(); const metadata = clientmetadatautil.buildBaseGameMetadata( 'Position created using ingame board editor', '-', dateTimestamp, ); // Variant options are copied before the gamefile is loaded and this potentially manipualtes them const variantOptionsCopy = jsutil.deepCopyObject(options.additional.variantOptions); gameslot .loadGamefile({ metadata, variant: null, // Not specified for custom position dateTimestamp, viewWhitePerspective: true, allowEditCoords: true, // See comment in startBoardEditor for why "editor: true" is needed additional: { ...options.additional, editor: true }, presetAnnotes: options.presetAnnotes, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); // Open the gui stuff AFTER initiating the logical stuff, // because the gui DEPENDS on the other stuff. await guipalette.initUI(); boardeditor.initBoardEditor(dirty, variantOptionsCopy, pawnDoublePush, castling); } /** * Reloads the current local or online game from the provided metadata, existing moves, and variant options. */ async function pasteGame(options: { metadata: MetaData; variant: VariantCode | null; dateTimestamp: number; additional: Additional; presetAnnotes?: PresetAnnotes; }): Promise { if (typeOfGameWeAreIn !== 'local' && typeOfGameWeAreIn !== 'online') throw Error("Can't paste a game when we're not in a local or online game."); gameLoading = true; // Has to be awaited to give the document a chance to repaint. await loadingscreen.open(); const viewWhitePerspective = gameslot.isLoadedGameViewingWhitePerspective(); // Retain the same perspective as the current loaded game. gameslot.unloadGame(); gameslot .loadGamefile({ metadata: options.metadata, variant: options.variant, dateTimestamp: options.dateTimestamp, viewWhitePerspective, allowEditCoords: guinavigation.areCoordsAllowedToBeEdited(), presetAnnotes: options.presetAnnotes, additional: options.additional, }) .then((_result: any) => onFinishedLoading()) .catch((err: Error) => onCatchLoadingError(err)); // Open the gui stuff AFTER initiating the logical stuff, // because the gui DEPENDS on the other stuff. openGameinfoBarAndConcludeGameIfOver(options.metadata, false); } /** * A function that is executed when a game is FULLY loaded (graphical, spritesheet, engine, etc.) * This hides the spinny pawn loading animation that covers the board. */ function onFinishedLoading(): void { // console.log('COMPLETELY finished loading game!'); gameLoading = false; // We can now close the loading screen. // I don't think this one has to be awaited since we're pretty much // done with loading, there's not gonna be another lag spike.. loadingscreen.close(); gameslot.startStartingTransition(); // Play the zoom-in animation at the start of games. } /** * Replaces the loading animation with the words * "ERROR. One or more resources failed to load. Please refresh." */ function onCatchLoadingError(err: Error): void { console.error(err); loadingscreen.onError(); } /** * These items must be done after the logical parts of the gamefile are fully loaded * @param metadata - The metadata of the gamefile * @param showGameControlButtons - Whether to show the practice game control buttons "Undo Move" and "Retry" */ function openGameinfoBarAndConcludeGameIfOver( metadata: MetaData, showGameControlButtons: boolean = false, ): void { guigameinfo.open(metadata, showGameControlButtons); if (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) gameslot.concludeGame(); } function unloadLogicalAndRendering(): void { gameslot.unloadGame(); perspective.disable(); boardpos.eraseMomentum(); Transition.terminate(); } function unloadGame(): void { // console.log("Game loader unloading game..."); if (typeOfGameWeAreIn === 'online') onlinegame.closeOnlineGame(); else if (typeOfGameWeAreIn === 'engine') enginegame.closeEngineGame(); else if (typeOfGameWeAreIn === 'editor') boardeditor.closeBoardEditor(); guinavigation.close(); guigameinfo.close(); guigameinfo.clearUsernameContainers(); guiboardeditor.close(); unloadLogicalAndRendering(); typeOfGameWeAreIn = undefined; gui.prepareForOpen(); } // Exports -------------------------------------------------------------------- export default { areInAGame, areInLocalGame, isItOurTurn, getOurColor, areWeLoadingGame, getTypeOfGameWeIn, update, startLocalGame, startOnlineGame, startEngineGame, startBoardEditor, startCustomLocalGame, startCustomEngineGame, startBoardEditorFromCustomPosition, pasteGame, openGameinfoBarAndConcludeGameIfOver, unloadLogicalAndRendering, unloadGame, }; ================================================ FILE: src/client/scripts/esm/game/chess/gameslot.ts ================================================ // src/client/scripts/esm/game/chess/gameslot.ts /** * Whether we're in a local game, online game, analysis board, or board editor, * what they ALL have in common is a gamefile! This script stores THAT gamefile! * * It also has the loader and unloader methods for the gamefile. */ import type { Mesh } from '../rendering/piecemodels.js'; import type { Player } from '../../../../../shared/chess/util/typeutil.js'; import type { MetaData } from '../../../../../shared/types.js'; import type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js'; import type { PresetAnnotes } from '../../../../../shared/chess/logic/icn/icnconverter.js'; import type { Additional, FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import bd from '@naviary/bigdecimal'; import clock from '../../../../../shared/chess/logic/clock.js'; import moveutil from '../../../../../shared/chess/util/moveutil.js'; import gamefile from '../../../../../shared/chess/logic/gamefile.js'; import movepiece from '../../../../../shared/chess/logic/movepiece.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import area from '../rendering/area.js'; import board from '../rendering/boardtiles.js'; import arrows from '../rendering/arrows/arrows.js'; import meshes from '../rendering/meshes.js'; import { gl } from '../rendering/webgl.js'; import boardpos from '../rendering/boardpos.js'; import guiclock from '../gui/guiclock.js'; import drawrays from '../rendering/highlights/annotations/drawrays.js'; import copygame from './copygame.js'; import miniimage from '../rendering/miniimage.js'; import pastegame from './pastegame.js'; import gamesound from '../misc/gamesound.js'; import starfield from '../rendering/starfield.js'; import imagecache from '../../chess/rendering/imagecache.js'; import Transition from '../rendering/transitions/Transition.js'; import gameloader from './gameloader.js'; import piecemodels from '../rendering/piecemodels.js'; import guigameinfo from '../gui/guigameinfo.js'; import drawsquares from '../rendering/highlights/annotations/drawsquares.js'; import perspective from '../rendering/perspective.js'; import { GameBus } from '../GameBus.js'; import preferences from '../../components/header/preferences.js'; import guipromotion from '../gui/guipromotion.js'; import movesequence from './movesequence.js'; import texturecache from '../../chess/rendering/texturecache.js'; import guinavigation from '../gui/guinavigation.js'; import { animateMove } from './graphicalchanges.js'; // Types --------------------------------------------------------------------- /** Options for loading a game. */ interface LoadOptions { /** The metadata of the game */ metadata: MetaData; /** The variant code. Pass null for custom/unknown positions. */ variant: VariantCode | null; /** The game's start timestamp in milliseconds since epoch. */ dateTimestamp: number; /** True if we should be viewing the game from white's perspective, false for black's perspective. */ viewWhitePerspective: boolean; /** Whether the coordinate field box should be editable. */ allowEditCoords: boolean; /** Preset ray overrides for the variant's rays. */ presetAnnotes?: PresetAnnotes; additional?: Additional; } // Variables --------------------------------------------------------------- /** The currently loaded game. */ let loadedGamefile: FullGame | undefined; /** The mesh of the gamefile, if it is loaded. */ let mesh: Mesh | undefined; /** The player color we are viewing the perspective of in the current loaded game. */ let youAreColor: Player; /** * The timeout id of the timer that animates the latest-played * move when rejoining a game, after a short delay */ let animateLastMoveTimeoutID: ReturnType | undefined; /** * The delay, in millis, until the latest-played * move is animated, after rejoining a game. */ const delayOfLatestMoveAnimationOnRejoinMillis = 150; // Functions --------------------------------------------------------------- /** Returns the gamefile currently loaded */ function getGamefile(): FullGame | undefined { return loadedGamefile; } /** Returns the mesh of the gamefile currently loaded */ function getMesh(): Mesh | undefined { return mesh; } function areInGame(): boolean { return loadedGamefile !== undefined; } function isLoadedGameViewingWhitePerspective(): boolean { if (!loadedGamefile) throw Error( "Cannot ask if loaded game is from white's perspective when there isn't a loaded game.", ); return youAreColor === p.WHITE; } /** * Loads a gamefile onto the board. * * This loads the logical stuff first, then returns a PROMISE that resolves * when the GRAPHICAL stuff is finished loading (such as piece textures). */ function loadGamefile(loadOptions: LoadOptions): Promise { if (loadedGamefile) throw new Error('Must unloadGame() before loading a new one.'); // console.log("Loading gamefile..."); // console.log('Started loading game...'); // The game should be considered loaded once the LOGICAL stuff is finished, // but the loading animation should only be closed when // both the LOGICAL and GRAPHICAL stuff are finished. // First load the LOGICAL stuff... loadLogical(loadOptions); // console.log('Finished loading LOGICAL game stuff.'); // Play the start game sound once LOGICAL stuff is finished loading, // so that the sound will still play in chrome, with the tab hidden, and // someone accepts your invite. (In that scenario, the graphical loading is blocked) gamesound.playGamestart(); /** * Next start loading the GRAPHICAL stuff... * * This returns a promise that resolves when it's fully loaded, * since the graphics loading is asynchronious. */ return loadGraphical(loadOptions); } /** Loads all of the logical components of a game */ function loadLogical(loadOptions: LoadOptions): void { loadedGamefile = gamefile.initFullGame( loadOptions.metadata, loadOptions.dateTimestamp, loadOptions.variant, loadOptions.additional, ); youAreColor = loadOptions.viewWhitePerspective ? p.WHITE : p.BLACK; const pieceCount = boardutil.getPieceCountOfGame(loadedGamefile.boardsim.pieces); // Disable miniimages if there's too many pieces if (pieceCount > miniimage.pieceCountToDisableMiniImages) miniimage.disable(); // Disable arrows if there's too many pieces or lines in the game if ( pieceCount > arrows.MAX_PIECES || loadedGamefile.boardsim.pieces.slides.length > arrows.MAX_LINES ) arrows.setMode(0); initCopyPastGameListeners(); // If custom preset rays are specified, initiate them in drawrays.ts if (loadOptions.presetAnnotes?.squares) drawsquares.setPresetOverrides(loadOptions.presetAnnotes.squares); if (loadOptions.presetAnnotes?.rays) drawrays.setPresetOverrides(loadOptions.presetAnnotes.rays); GameBus.dispatch('game-loaded'); } /** Loads all of the graphical components of a game */ async function loadGraphical(loadOptions: LoadOptions): Promise { // Opening the guinavigation needs to be done in gameslot.ts instead of gameloader.ts so pasting games still opens it guinavigation.open({ allowEditCoords: loadOptions.allowEditCoords }); // Editing your coords allowed in local games guiclock.set(loadedGamefile!.basegame); perspective.resetRotations(loadOptions.viewWhitePerspective); await imagecache.initImagesForGame(loadedGamefile!.boardsim); texturecache.initTexturesForGame(gl, loadedGamefile!.boardsim); // MUST BE AFTER imagecache.initImagesForGame(), as we need SVGs fetched before then. guipromotion.initUI(loadedGamefile!.basegame.gameRules.promotionsAllowed); // Rewind one move so that we can, after a short delay, animate the most recently played move. const lastmove = moveutil.getLastMove(loadedGamefile!.boardsim.moves); if (lastmove !== undefined) movepiece.applyMove(loadedGamefile!, lastmove, false); // Rewind one move // Initialize the mesh empty mesh = { offset: [0n, 0n], inverted: false, types: {}, }; // Generate the mesh of every piece type piecemodels.regenAll(loadedGamefile!.boardsim, mesh); // NEEDS TO BE AFTER generating the mesh, since this makes a graphical change. if (lastmove !== undefined) animateLastMoveTimeoutID = setTimeout(() => { // A small delay to animate the most recently played move. if (moveutil.areWeViewingLatestMove(loadedGamefile!.boardsim)) return; // Already viewing the lastest move movesequence.viewFront(loadedGamefile!, mesh!); // Updates to front even when they view different moves animateMove(lastmove.changes, true); }, delayOfLatestMoveAnimationOnRejoinMillis); // Init the star field void animation starfield.init(); } /** The canvas will no longer render the current game */ function unloadGame(): void { if (!loadedGamefile) throw Error('Should not be calling to unload game when there is no game loaded.'); // console.error("Unloading gamefile..."); loadedGamefile = undefined; mesh = undefined; removeCopyPasteGameListeners(); // Stop the timer that (animates the latest-played move when rejoining a game after a short delay) clearTimeout(animateLastMoveTimeoutID); animateLastMoveTimeoutID = undefined; GameBus.dispatch('game-unloaded'); } /** * Sets the camera to the recentered position, plus a little zoomed in. * THEN transitions to normal zoom. */ function startStartingTransition(): void { const boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare( loadedGamefile!.boardsim.startSnapshot.box, ); const centerArea = area.calculateFromUnpaddedBox(boxFloating); boardpos.setBoardPos(centerArea.coords); const INITIAL_ZOOM_MULTIPLIER = preferences.getFastTransitionsMode() ? 1.4 : 1.75; // We start 1.75x zoomed in then normal, then transition into 1x const startScale = bd.multiply(centerArea.scale, bd.fromNumber(INITIAL_ZOOM_MULTIPLIER)); boardpos.setBoardScale(startScale); guinavigation.recenter(); Transition.eraseTelHist(); } /** Called when a game is loaded, loads the event listeners for when we are in a game. */ function initCopyPastGameListeners(): void { document.addEventListener('copy', callbackCopy); document.addEventListener('paste', pastegame.callbackPaste); document.addEventListener('copy-game', callbackCopy); document.addEventListener('paste-game', pastegame.callbackPaste); } /** Called when a game is unloaded, closes the event listeners for being in a game. */ function removeCopyPasteGameListeners(): void { document.removeEventListener('copy', callbackCopy); document.removeEventListener('paste', pastegame.callbackPaste); document.removeEventListener('copy-game', callbackCopy); document.removeEventListener('paste-game', pastegame.callbackPaste); } function callbackCopy(_event: Event): void { if (document.activeElement instanceof HTMLInputElement) return; // Don't copy if the user is typing in an input field if (window.getSelection()?.toString()) return; // Don't copy if the user has text selected in the UI copygame.copyGame(false); } /** * Ends the game. Call this when the game is over by the used win condition. * Stops the clocks, darkens the board, displays who won, plays a sound effect. */ function concludeGame(): void { if (!loadedGamefile) throw Error("Cannot conclude game when there isn't one loaded"); const basegame = loadedGamefile.basegame; if (basegame.gameConclusion === undefined) throw Error("Cannot conclude game when the game hasn't ended."); clock.endGame(basegame); guiclock.stopClocks(basegame); guigameinfo.gameEnd(basegame.gameConclusion); GameBus.dispatch('game-concluded'); const victor = basegame.gameConclusion.victor; // undefined if aborted, null if draw const delayToPlayConcludeSoundSecs = 0.65; if (gameloader.areInLocalGame()) { if (victor !== null && victor !== undefined) { gamesound.playWin(delayToPlayConcludeSoundSecs); } else { gamesound.playDraw(delayToPlayConcludeSoundSecs); } } else { // In online game or engine game const ourRole = gameloader.getOurColor()!; if (victor === ourRole) gamesound.playWin(delayToPlayConcludeSoundSecs); else if (victor === null || victor === undefined) gamesound.playDraw(delayToPlayConcludeSoundSecs); else gamesound.playLoss(delayToPlayConcludeSoundSecs); } } /** Undoes the conclusion of the game. */ function unConcludeGame(): void { gamefileutility.setConclusion(loadedGamefile!.basegame, undefined); board.resetColor(); } export default { getGamefile, getMesh, areInGame, isLoadedGameViewingWhitePerspective, loadGamefile, unloadGame, startStartingTransition, concludeGame, unConcludeGame, }; export type { PresetAnnotes, Additional }; ================================================ FILE: src/client/scripts/esm/game/chess/graphicalchanges.ts ================================================ // src/client/scripts/esm/game/chess/graphicalchanges.ts /** * This script contains the functions that know what mesh changes to make, * and what animations to make, according to each action of a move's actions list. */ import type { Mesh } from '../rendering/piecemodels.js'; import type { Piece } from '../../../../../shared/chess/util/boardutil.js'; import type { Coords } from '../../../../../shared/chess/util/coordutil.js'; import type { ChangeApplication, Change, genericChangeFunc, } from '../../../../../shared/chess/logic/boardchanges.js'; import animation from '../rendering/animation.js'; import piecemodels from '../rendering/piecemodels.js'; import preferences from '../../components/header/preferences.js'; // Types ---------------------------------------------------------------------------------------------------- /** * An object mapping move changes to a function that performs the graphical mesh change for that action. */ const meshChanges: ChangeApplication> = { forward: { add: addMeshPiece, delete: deleteMeshPiece, move: moveMeshPiece, capture: deleteMeshPiece, }, backward: { delete: addMeshPiece, add: deleteMeshPiece, move: returnMeshPiece, capture: addMeshPiece, }, }; // Mesh Changes ----------------------------------------------------------------------------------------- function addMeshPiece(mesh: Mesh, change: Change): void { piecemodels.overwritebufferdata(mesh, change.piece); } function deleteMeshPiece(mesh: Mesh, change: Change): void { piecemodels.deletebufferdata(mesh, change.piece); } function moveMeshPiece(mesh: Mesh, change: Change): void { if (change.action !== 'move') throw Error(`moveMeshPiece called with non-move action: ${change.action}`); piecemodels.overwritebufferdata(mesh, { type: change.piece.type, coords: change.endCoords, index: change.piece.index, }); } function returnMeshPiece(mesh: Mesh, change: Change): void { piecemodels.overwritebufferdata(mesh, change.piece); } // Animate ----------------------------------------------------------------------------------------- /** * Animates a given set of changes to the board. * We don't use boardchanges because a custom compositor is needed. * @param moveChanges - the changes to animate * @param forward - whether this is a forward or back animation * @param animateMain - Whether the main piece targeted by the move should be animated. All secondary pieces are guaranteed animated. If this is false, the main piece animation will be instantanious, only playing the SOUND. * @param premove - Whether this animation is for a premove. * @param force_instant - Whether to FORCE instant animation, EVEN secondary pieces won't be animated. Enable when you are playing a premove in the game. */ function animateMove( moveChanges: Change[], forward = true, animateMain = true, premove = false, force_instant = false, ): void { let clearanimations = true; // The first animation of a turn should clear prev turns animation // Helper function for pushing an item to an array in a map, creating the array if it does not exist. function pushToArrayMap(map: Map, key: K, apple: V): void { let t = map.get(key); if (!t) { t = []; map.set(key, t); } t.push(apple); } let showKeyframes: Map = new Map(); let hideKeyframes: Map = new Map(); for (const change of moveChanges) { if (change.action === 'capture') { // Queue all captures to be associated with the next move pushToArrayMap(showKeyframes, change.order, change.piece); } else if (change.action === 'move') { const instant = (change.main && !animateMain) || !preferences.getAnimationsMode() || force_instant; // Whether the animation should be instantanious, only playing the SOUND. let waypoints = change.path ?? [change.piece.coords, change.endCoords]; // Put all pieces captured last in the last keyframe const last = waypoints.length - 1; const lastDef = showKeyframes.get(last); const assumeLast = showKeyframes.get(-1); showKeyframes.delete(-1); if ((lastDef === undefined) !== (assumeLast === undefined)) { showKeyframes.set(last, (lastDef ?? assumeLast)!); // Only one is defined } else if (lastDef !== undefined) { showKeyframes.set(last, [...lastDef, ...assumeLast!]); } // Don't need to do anything // Flip those being hidden and those being shown if it is a reverse move if (!forward) { waypoints = waypoints.slice().reverse(); // Helper that inverts orders at the start of the path to the end, and vice versa. // x remains the same, but y is set to the inverted x. function invert(x: Map, y: Map): void { y.clear(); x.forEach((v, k) => { y.set(last - k, v); }); } const t = new Map(); invert(showKeyframes, t); invert(hideKeyframes, showKeyframes); hideKeyframes = t; } // Prune those that will never be seen hideKeyframes.delete(0); showKeyframes.delete(0); // Convert hideKeyframes to a Coords[] array, as the animation function expects this. const newHideFrames: Map = new Map(); for (const [k, v] of hideKeyframes) newHideFrames.set( k, v.map((p) => p.coords), ); // Mutate to remove unnessacary info // Hide where the moved piece is actually pushToArrayMap(newHideFrames, last, waypoints[last]); animation.animatePiece( change.piece.type, waypoints, showKeyframes, newHideFrames, instant, clearanimations, premove, ); showKeyframes = new Map(); hideKeyframes.clear(); clearanimations = false; } } } export { animateMove, meshChanges }; ================================================ FILE: src/client/scripts/esm/game/chess/movesequence.ts ================================================ // src/client/scripts/esm/game/chess/movesequence.ts /** * This is a client-side script that executes global and local moves, * making both the logical, and graphical changes. * * We also have the animate move method here. */ import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { Edit, MoveFull, MoveTagged } from '../../../../../shared/chess/logic/movepiece.js'; import clock from '../../../../../shared/chess/logic/clock.js'; import moveutil from '../../../../../shared/chess/util/moveutil.js'; import movepiece from '../../../../../shared/chess/logic/movepiece.js'; import boardchanges from '../../../../../shared/chess/logic/boardchanges.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import stats from '../gui/stats.js'; import gameslot from './gameslot.js'; import guiclock from '../gui/guiclock.js'; import { Mesh } from '../rendering/piecemodels.js'; import premoves from './premoves.js'; import animation from '../rendering/animation.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import enginegame from '../misc/enginegame.js'; import piecemodels from '../rendering/piecemodels.js'; import guigameinfo from '../gui/guigameinfo.js'; import { GameBus } from '../GameBus.js'; import frametracker from '../rendering/frametracker.js'; import guinavigation from '../gui/guinavigation.js'; import { animateMove, meshChanges } from './graphicalchanges.js'; // Global Moving ---------------------------------------------------------------------------------------------------------- /** * Makes a global forward move in the game. * * This returns the constructed MoveFull object so that we have the option to animate it if we so choose. */ function makeMove( gamefile: FullGame, mesh: Mesh | undefined, moveTagged: MoveTagged, { doGameOverChecks = true } = {}, ): MoveFull { const { basegame, boardsim } = gamefile; const move = movepiece.generateMove(gamefile, moveTagged); movepiece.makeMove(gamefile, move); // Logical changes if (mesh) runMeshChanges(boardsim, mesh, move, true); // GUI changes updateGui(false); if (!onlinegame.areInOnlineGame() && !gamefile.basegame.untimed) { const clockStamp_ = clock.push(basegame, basegame.clocks!); guiclock.push(basegame.clocks!); // Add the clock stamp to the move if (clockStamp_ !== undefined) move.clockStamp = clockStamp_; } if (doGameOverChecks) { gamefileutility.doGameOverChecks(gamefile); // Only conclude the game if it's not an online game (in that scenario, server is boss) if (gamefileutility.isGameOver(basegame) && !onlinegame.areInOnlineGame()) gameslot.concludeGame(); } GameBus.dispatch('physical-move'); return move; } /** Convenience wrapper: Makes a global forward move then animates it if the mesh exists. */ function makeMoveAndAnimate( gamefile: FullGame, mesh: Mesh | undefined, moveTagged: MoveTagged, { doGameOverChecks = true } = {}, ): MoveFull { const move = makeMove(gamefile, mesh, moveTagged, { doGameOverChecks }); if (mesh) animateMove(move.changes, true); return move; } /** * Wrapper for performing the graphical mesh changes of an edit. * * If the newlyRegenerated flag is present, indicating the organized pieces were regenerated, * than we instead need to regenerate all piece models. * Otherwise, we run graphical changes as normal. * * We have to regenerate ALL types here, not just the ones whos type ranges * were affected, because other pieces may still need graphical changes * from the move's changes! For example, pawn deleted that promoted. */ function runMeshChanges( boardsim: FullGame['boardsim'], mesh: Mesh, edit: Edit, forward: boolean, ): void { if (boardsim.pieces.newlyRegenerated) piecemodels.regenAll(boardsim, mesh); else boardchanges.runChanges(mesh, edit.changes, meshChanges, forward); // Graphical changes frametracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes. } /** * Makes a global backward move in the game. */ function rewindMove(gamefile: FullGame, mesh: Mesh | undefined): void { // Terminate all current animations to avoid a crash when undoing moves animation.clearAnimations(); // movepiece.rewindMove() deletes the move, so we need to keep a reference here. const lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!; movepiece.rewindMove(gamefile); // Logical changes if (mesh) boardchanges.runChanges(mesh, lastMove.changes, meshChanges, false); // Graphical changes frametracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes. // Un-conclude the game if it was concluded if (gamefileutility.isGameOver(gamefile.basegame)) gameslot.unConcludeGame(); updateGui(false); // GUI changes premoves.cancelPremoves(gamefile, mesh); // Any move change invalidates all premoves. } // Local Moving ---------------------------------------------------------------------------------------------------------- /** * Apply the move to the board state and the mesh, whether forward or backward, * as if we were wanting to *view* the move, instead of making it. * * This does not change the game state, for example, whos turn it is, * what square enpassant is legal on, or the running count of checks given. * * But it does change the check state. */ function viewMove( gamefile: FullGame, mesh: Mesh | undefined, move: MoveFull, forward = true, ): void { movepiece.applyMove(gamefile, move, forward); // Apply the logical changes. if (mesh) { boardchanges.runChanges(mesh, move.changes, meshChanges, forward); // Apply the graphical changes. frametracker.onVisualChange(); // Flag the next frame to be rendered, since we ran some graphical changes. } enginegame.onViewMove(); } /** * Makes the game view a set move index * @param index the move index to goto */ function viewIndex(gamefile: FullGame, mesh: Mesh | undefined, index: number): void { movepiece.goToMove(gamefile.boardsim, index, (move: MoveFull) => viewMove(gamefile, mesh, move, index >= gamefile.boardsim.state.local.moveIndex), ); updateGui(false); } /** * Makes the game view the last move */ function viewFront(gamefile: FullGame, mesh: Mesh | undefined): void { /** Call {@link viewIndex} with the index of the last move in the game */ viewIndex(gamefile, mesh, gamefile.boardsim.moves.length - 1); } /** * Called when we hit the left/right arrows keys, * or click the rewind/forward move buttons. * * This VIEWS the next move, whether forward or backward, * makes the graphical (mesh) changes, animates it, and updates the GUI. * * ASSUMES that it is legal to navigate in the direction. */ function navigateMove(gamefile: FullGame, mesh: Mesh | undefined, forward: boolean): void { const { boardsim } = gamefile; // Determine the index of the move to apply const idx = forward ? boardsim.state.local.moveIndex + 1 : boardsim.state.local.moveIndex; // Make sure the move exists. Normally we'd never call this method // if it does, but just in case we forget to check. const move = boardsim.moves[idx]; if (move === undefined) throw Error(`Move is undefined. Should not be navigating move. forward: ${forward}`); viewMove(gamefile, mesh, move, forward); // Apply the logical + graphical changes animateMove(move.changes, forward); // Animate updateGui(true); } /** * Updates the display of whos turn it is (if it changed), * the transparency of the rewind/forward move buttons, * updates the move number below the move buttons. * @param showMoveCounter Whether to show the move counter below the move buttons in the navigation bar. */ function updateGui(showMoveCounter: boolean): void { if (showMoveCounter) stats.showMoves(); else stats.updateTextContentOfMoves(); // While we may not be OPENING the move counter, if it WAS already open we should still update the number! guinavigation.update_MoveButtons(); guigameinfo.updateWhosTurn(); } // -------------------------------------------------------------------------------------------------------------------------- export default { navigateMove, makeMove, makeMoveAndAnimate, rewindMove, viewMove, viewFront, viewIndex, runMeshChanges, }; ================================================ FILE: src/client/scripts/esm/game/chess/pastegame.ts ================================================ // src/client/scripts/esm/game/chess/pastegame.ts /** * This script handles pasting games */ import type { MetaData } from '../../../../../shared/types.js'; import type { CoordsKey } from '../../../../../shared/chess/util/coordutil.js'; import type { Additional } from '../../../../../shared/chess/logic/gamefile.js'; import type { MovePacket } from '../../../../../shared/types.js'; import type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js'; import type { MetadataKey } from '../../../../../shared/chess/util/metadatautil.js'; import type { VariantOptions } from '../../../../../shared/chess/logic/initvariant.js'; import variant from '../../../../../shared/chess/variants/variant.js'; import timeutil from '../../../../../shared/util/timeutil.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import { pieceCountToDisableCheckmate } from '../../../../../shared/chess/logic/checkmate.js'; import icnconverter, { MoveParsed, LongFormatOut, } from '../../../../../shared/chess/logic/icn/icnconverter.js'; import toast from '../gui/toast.js'; import IndexedDB from '../../util/IndexedDB.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import enginegame from '../misc/enginegame.js'; import gameloader from './gameloader.js'; import boardeditor from '../boardeditor/boardeditor.js'; import socketmessages from '../websocket/socketmessages.js'; import clientmetadatautil from './clientmetadatautil.js'; import gameslot, { PresetAnnotes } from './gameslot.js'; /** * A list of metadata properties that are retained from the current game when pasting an external game. * These will overwrite the pasted game's metadata with the current game's metadata. */ const retainMetadataWhenPasting: MetadataKey[] = [ 'White', 'Black', 'WhiteID', 'BlackID', 'WhiteElo', 'BlackElo', 'WhiteRatingDiff', 'BlackRatingDiff', 'TimeControl', 'Event', 'Site', 'Round', ]; /** The pasted game will refuse to override these unless specified explicitly. This prevents them from just being deleted. * It means if the pasted game doesn't have these properties, we fall back to the current game's properties. */ const retainIfNotOverridden: MetadataKey[] = ['UTCDate', 'UTCTime']; /** * Pastes the clipboard ICN to the current game. * This callback is called when the "Paste Game" button is pressed. * @param event - The event fired from the event listener */ async function callbackPaste(_event: Event): Promise { if (boardeditor.areInBoardEditor()) return; // Editor has its own handler if (document.activeElement instanceof HTMLInputElement) return; // Don't paste if the user is typing in an input field // Can't paste a game when the current gamefile isn't finished loading all the way. if (gameloader.areWeLoadingGame()) return toast.showPleaseWaitForTask(); // Make sure we're not in a public match if (onlinegame.areInOnlineGame()) { if (!onlinegame.getIsPrivate()) return toast.show(translations.copypaste.cannot_paste_in_public); if (onlinegame.isRated()) return toast.show(translations.copypaste.cannot_paste_in_rated); } // Make sure we're not in an engine match if (enginegame.areInEngineGame()) return toast.show(translations.copypaste.cannot_paste_in_engine); // Make sure it's legal in a private match if ( onlinegame.areInOnlineGame() && onlinegame.getIsPrivate() && gameslot.getGamefile()!.boardsim.moves.length > 0 ) return toast.show(translations.copypaste.cannot_paste_after_moves); // Do we have clipboard permission? let clipboard: string; try { clipboard = await navigator.clipboard.readText(); } catch (error) { const message: string = translations.copypaste.clipboard_denied; return toast.show(message + '\n' + error, { error: true }); } // Convert clipboard text to object let longformOut: LongFormatOut; try { longformOut = icnconverter.ShortToLong_Format(clipboard); } catch (e) { console.error(e); toast.show(translations.copypaste.clipboard_invalid, { error: true }); return; } // console.log(jsutil.deepCopyObject(longformOut)); pasteGame(longformOut); // Let the server know if we pasted a custom position in a private match if (onlinegame.areInOnlineGame() && onlinegame.getIsPrivate()) socketmessages.send('game', 'paste'); } /** * Loads a game from the provided game in longformat. * * TODO: REMOVE A LOT OF THE REDUNDANT LOGIC BETWEEN * THIS FUNCTION AND gameforulator.formulateGame()!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! * * @param longformOut - The game in longformat, or primed for copying. This is NOT the gamefile, we'll need to use the gamefile constructor. * @returns Whether the paste was successful */ function pasteGame(longformOut: LongFormatOut): void { console.log('Pasting game...'); // Create a new gamefile from the longformat... // Retain most of the existing metadata on the currently loaded gamefile const currentGamefile = gameslot.getGamefile()!; const currentGameMetadata = currentGamefile.basegame.metadata; retainMetadataWhenPasting.forEach((metadataName) => { delete longformOut.metadata[metadataName]; if (currentGameMetadata[metadataName] !== undefined) clientmetadatautil.copyMetadataField( longformOut.metadata, currentGameMetadata, metadataName, ); }); for (const metadataName of retainIfNotOverridden) { if (currentGameMetadata[metadataName] && !longformOut.metadata[metadataName]) clientmetadatautil.copyMetadataField( longformOut.metadata, currentGameMetadata, metadataName, ); } // Resolve variant code from the ICN metadata, normalizing it to the English display name. const resolvedVariantCode = variant.resolveAndNormalizeVariantInMetadata(longformOut.metadata); const timestamp = clientmetadatautil.resolveTimestampFromMetadata( longformOut.metadata.UTCDate, longformOut.metadata.UTCTime, ); const { position, specialRights } = getPositionAndSpecialRightsFromLongFormat( longformOut, resolvedVariantCode, timestamp, ); // The variant options passed into the variant loader needs to contain the following properties: // `fullMove`, `enpassant`, `moveRuleState`, `position`, `specialRights`, `gameRules`. const variantOptions: VariantOptions = { fullMove: longformOut.fullMove, gameRules: longformOut.gameRules, position, state_global: { ...longformOut.state_global, specialRights, }, }; if (onlinegame.areInOnlineGame() && onlinegame.getIsPrivate()) { // Playing a custom private game! Save the pasted position in browser // storage so that we can remember it upon refreshing. const gameID = onlinegame.getGameID(); const storageKey = onlinegame.getKeyForOnlineGameVariantOptions(gameID); const expiryMillis = timeutil.getTotalMilliseconds({ days: 3 }); IndexedDB.saveItem(storageKey, variantOptions, expiryMillis); } // What is the warning message if pasting in a private match? const privateMatchWarning: string = onlinegame.areInOnlineGame() && onlinegame.getIsPrivate() ? ` ${translations.copypaste.pasting_in_private}` : ''; const additional: Additional = { variantOptions }; if (longformOut.moves) { // Trim the excess properties from the MoveParsed type, including the comment. additional.moves = longformOut.moves.map((m: MoveParsed) => { const move: MovePacket = { token: m.token }; if (m.clockStamp !== undefined) move.clockStamp = m.clockStamp; // Potentially also transfer the pasted comments into the gamefile here in the future! // ... return move; }); } const options: { metadata: MetaData; variant: VariantCode | null; dateTimestamp: number; additional: Additional; presetAnnotes?: PresetAnnotes; } = { metadata: longformOut.metadata, variant: resolvedVariantCode, dateTimestamp: timestamp, additional, }; if (longformOut.presetAnnotes) options.presetAnnotes = longformOut.presetAnnotes; gameloader.pasteGame(options).then(() => { // This isn't accessible until gameloader.pasteGame() resolves its promise. const gamefile = gameslot.getGamefile()!; // If there's too many pieces, notify them that the win condition has changed from checkmate to royalcapture. const pieceCount = boardutil.getPieceCountOfGame(gamefile.boardsim.pieces); if (pieceCount >= pieceCountToDisableCheckmate) { // TOO MANY pieces! toast.show( `${translations.copypaste.piece_count} ${pieceCount} ${translations.copypaste.exceeded} ${pieceCountToDisableCheckmate}! ${translations.copypaste.changed_wincon}${privateMatchWarning}`, { durationMultiplier: 1.5 }, ); } else { // Only print "Loaded game from clipboard." if we haven't already shown a different toast cause of too many pieces toast.show(`${translations.copypaste.loaded_from_clipboard}${privateMatchWarning}`); } }); console.log('Loaded game from clipboard!'); } /** * Utility for extracting position and specialRights from a LongFormatOut. * @param longFormat - The parsed long format from ICN. * @param variantCode - The pre-resolved variant code (avoids re-resolving from metadata). * @param timestamp - The game's start timestamp in ms since epoch. */ function getPositionAndSpecialRightsFromLongFormat( longFormat: LongFormatOut, variantCode: VariantCode | null, timestamp: number, ): { position: Map; specialRights: Set; } { // Get relevant position and specialRights information from longformat if (longFormat.position && longFormat.state_global.specialRights) { return { position: longFormat.position, specialRights: longFormat.state_global.specialRights, }; } else if (variantCode !== null) { // No position specified in the ICN, extract from the variant return variant.getStartingPositionOfVariant(variantCode, timestamp); } else { // Empty position return { position: new Map(), specialRights: new Set() }; } } export default { callbackPaste, getPositionAndSpecialRightsFromLongFormat, }; ================================================ FILE: src/client/scripts/esm/game/chess/premoves.ts ================================================ // src/client/scripts/esm/game/chess/premoves.ts /** * This script handles the processing and execution of premoves * after the opponent's move. * * Premoves are handled client-side, not server side. */ import type { Mesh } from '../rendering/piecemodels.js'; import type { Color } from '../../../../../shared/util/math/math.js'; import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import legalmoves from '../../../../../shared/chess/logic/legalmoves.js'; import specialdetect from '../../../../../shared/chess/logic/specialdetect.js'; import movepiece, { CoordsTagged, Edit, MoveTagged, } from '../../../../../shared/chess/logic/movepiece.js'; import mouse from '../../util/mouse.js'; import boardpos from '../rendering/boardpos.js'; import gameslot from './gameslot.js'; import selection from './selection.js'; import animation from '../rendering/animation.js'; import { Mouse } from '../input.js'; import preferences from '../../components/header/preferences.js'; import { GameBus } from '../GameBus.js'; import movesequence from './movesequence.js'; import squarerendering from '../rendering/highlights/squarerendering.js'; import { animateMove } from './graphicalchanges.js'; // Types -------------------------------------------------------- interface Premove extends Edit, MoveTagged { /** The type of piece moved */ type: number; } // Variables ---------------------------------------------------- /** The list of all premoves we currently have, in order. */ let premoves: Premove[] = []; /** * Whether the premoves board and state changes have been applied to the board. * This is purely for DEBUGGING so you don't accidentally call these * methods at the wrong times. * * When premove's changes have to be reapplied, we have to recalculate all * of their changes, since for all we know they could end up capturing a * piece when they didn't when we originally premoved, or vice versa. * * THIS SHOULD ONLY TEMPORARILY ever be false!! If it is, it means we just * need to do something like calculating legal moves, then reapply the premoves. * * This can even be true when there's no premoves queued. */ let applied: boolean = true; // Events ---------------------------------------------------------------------------------- GameBus.addEventListener('game-concluded', () => { // console.error("Game ended, clearing premoves"); // Erase pending premoves, leaving the `applied` state at what it was before // so the rest of the code doesn't experience it changed randomly. const originalApplied = applied; // Save the original applied state const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); if (applied) rewindPremoves(gamefile, mesh); clearPremoves(); // Restore the original applied state, as the rest of the code will have expected it not to change. applied = originalApplied; }); GameBus.addEventListener('game-unloaded', () => { clearPremoves(); }); /** Event listener for when we change the Premoves toggle */ document.addEventListener('premoves-toggle', (_e) => { // const enabled: boolean = _e.detail; const gamefile = gameslot.getGamefile(); const mesh = gameslot.getMesh(); if (!gamefile) return; cancelPremoves(gamefile, mesh); }); // Processing Premoves --------------------------------------------------------------------- /** Gets all pending premoves. */ function hasAtleastOnePremove(): boolean { return premoves.length > 0; } /** Whether premove board changes are applied (can be true even when there's zero queued premoves) */ function arePremovesApplied(): boolean { return applied; } /** Similar to {@link movesequence.makeMove} Adds an premove and applies its changes to the board. */ function addPremove(gamefile: FullGame, mesh: Mesh | undefined, moveTagged: MoveTagged): Premove { // console.log("Adding premove"); if (!applied) throw Error("Don't addPremove when other premoves are not applied!"); const premove = generatePremove(gamefile, moveTagged); applyPremove(gamefile, mesh, premove, true); // Apply the premove to the game state premoves.push(premove); // console.log(premoves); GameBus.dispatch('physical-move'); return premove; } /** Applies a premove's changes to the board. */ function applyPremove( gamefile: FullGame, mesh: Mesh | undefined, premove: Premove, forward: boolean, ): void { // console.log(`Applying premove ${forward ? 'FORWARD' : 'BACKWARD'}:`, premove); movepiece.applyEdit(gamefile, premove, forward, true); // forward & global are true if (mesh) movesequence.runMeshChanges(gamefile.boardsim, mesh, premove, forward); } /** Similar to {@link movepiece.generateMove}, but generates the edit for a Premove. */ function generatePremove(gamefile: FullGame, moveTagged: MoveTagged): Premove { const piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, moveTagged.startCoords); if (!piece) throw Error( `Cannot generate premove because no piece exists at coords ${JSON.stringify(moveTagged.startCoords)}.`, ); // Initialize the state, and change list, as empty for now. const premove: Premove = { ...moveTagged, type: piece.type, changes: [], state: { local: [], global: [] }, }; const rawType = typeutil.getRawType(piece.type); let specialMoveMade: boolean = false; // If a special move function exists for this piece type, run it. // The actual function will return whether a special move was actually made or not. // If a special move IS made, we skip the normal move piece method. if (rawType in gamefile.boardsim.specialMoves) specialMoveMade = gamefile.boardsim.specialMoves[rawType]!( gamefile.boardsim, piece, premove, ); if (!specialMoveMade) movepiece.calcMovesChanges(gamefile.boardsim, piece, moveTagged, premove); // Move piece regularly (no special tag) // Delete all special rights that should be revoked from the move. movepiece.queueSpecialRightDeletionStateChanges(gamefile.boardsim, premove); return premove; } /** Clears all pending premoves */ function clearPremoves(): void { // console.error("Clearing premoves"); premoves = []; // Since we now have zero premoves, they are technically applied. // console.error("Setting applied to true."); applied = true; } /** Cancels all premoves */ function cancelPremoves(gamefile: FullGame, mesh?: Mesh): void { // console.log("Clearing premoves"); const hadAtleastOnePremove = hasAtleastOnePremove(); rewindPremoves(gamefile, mesh); clearPremoves(); if (selection.arePremoving()) { // Unselect in the case where the premoves are being rewound if (hadAtleastOnePremove) selection.unselectPiece(); // Reselect if we haven't actually made any premoves yet else selection.reselectPiece(); } // If there were any animations, this should ensure they're only cancelled if they are for premoves, // and not for the opponent's move. After all cancelPremoves() can be called at any time. if (hadAtleastOnePremove) animation.clearAnimations(); } /** Unapplies all pending premoves by undoing their changes on the board. */ function rewindPremoves(gamefile: FullGame, mesh?: Mesh): void { if (!applied) throw Error("Don't rewindPremoves when other premoves are not applied!"); // Reverse the original array so all changes are made in the reverse order they were added premoves .slice() .reverse() .forEach((premove) => { applyPremove(gamefile, mesh, premove, false); // Apply the premove to the game state backwards }); // console.error("Setting applied to false."); applied = false; } /** * Reapplies all pending premoves' changes onto the board. * * All premove's must be regenerated, as for all we know * their destination square could have a new piece, or lack thereof. */ function applyPremoves(gamefile: FullGame, mesh?: Mesh): void { if (applied) throw Error("Don't applyPremoves when other premoves are already applied!"); for (let i = 0; i < premoves.length; i++) { const oldPremove = premoves[i]!; // Check if the premove is still legal to premove // It might not be if the premoved piece was captured, // Or if a castling premove's rook was captured. const results = premoveIsLegal(gamefile, oldPremove, 'premove'); if (results.legal === true) { // Extract the original MoveTagged from the premove const premoveTagged: MoveTagged = { startCoords: oldPremove.startCoords, endCoords: oldPremove.endCoords, promotion: oldPremove.promotion, }; specialdetect.transferSpecialTags_FromCoordsToMove( results.endCoordsTagged, premoveTagged, ); // MUST RECALCULATE CHANGES const premove = generatePremove(gamefile, premoveTagged); premoves[i] = premove; // Update the premove with the new changes applyPremove(gamefile, mesh, premove, true); // Apply the premove to the game state } else { console.log('Premove is no longer legal:', oldPremove); // Premove is no longer legal to premove. // This could happen if it was a castling premove, and the rook was captured, // so there's no longer a valid rook to premove castle with. // Delete this premove and all following premoves premoves.splice(i, premoves.length - i); break; } } // console.error("Setting applied to true."); applied = true; GameBus.dispatch('physical-move'); } /** * Processes the premoves array after the opponent's move. * Attempts to play the first premove in the list, then applies the remaining premoves. * A. Legal => Plays it, submits it, then applies the remaining premoves. * B. Illegal => Clears all premoves. */ function processPremoves(gamefile: FullGame, mesh?: Mesh): void { // console.error("Processing premoves"); if (applied) throw Error( "Don't processPremoves when other premoves are still applied! rewindPremoves() first.", ); const premove: Premove | undefined = premoves[0]; // CAN'T EARLY EXIT if there are no premoves, as // we still need clearPremoves() to set applied to true! // Check if the move is legal const results = premoveIsLegal(gamefile, premove, 'physical'); if (premove && results.legal === true) { // console.log("Premove is legal, applying it"); // Legal, apply the premove to the real game state const moveTagged: MoveTagged = { startCoords: premove.startCoords, endCoords: premove.endCoords, promotion: premove.promotion, }; specialdetect.transferSpecialTags_FromCoordsToMove(results.endCoordsTagged, moveTagged); const move = movesequence.makeMove(gamefile, mesh, moveTagged); // Make move GameBus.dispatch('user-move-played'); premoves.shift(); // Remove premove // Only instant animate // This also immediately terminates the opponent's move animation // MUST READ the move's changes returned from movesequence.makeMove() // instead of the premove's changes, as the changes need to be regenerated! animateMove(move.changes, true, false, false, true); // true for force instant animation, even secondary pieces aren't animated! // Apply remaining premove changes & visuals, but don't make them physically on the board applyPremoves(gamefile, mesh); } else { // console.log("Premove is illegal, clearing all premoves"); // Illegal, clear all premoves (they have already been rewounded before processPremoves() was called) clearPremoves(); } } /** * Tests whether a given premove is legal to make on the board. * @param gamefile * @param premove * @param mode - Whether we should be testing if the premove is legal to make physically in the game, OR if it's still a valid premove to PREMOVE. A premove may no longer become a valid premove if for example the castling opportunity dissapears due to the opponent capturing the rook. * @returns */ function premoveIsLegal( gamefile: FullGame, premove: Premove | undefined, mode: 'physical' | 'premove', ): { legal: true; endCoordsTagged: CoordsTagged } | { legal: false } { if (!premove) return { legal: false }; const piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, premove.startCoords); if (!piece) return { legal: false }; // Can't premove nothing, could happen if your piece was captured by enpassant if (premove.type !== piece.type) return { legal: false }; // Our piece was probably captured, so it can't move anymore, thus the premove is illegal. // Check if the move is legal const premovedPieceLegalMoves = mode === 'physical' ? legalmoves.calculateAll(gamefile, piece) : legalmoves.calculateAllPremoves(gamefile, piece); const color = typeutil.getColorFromType(piece.type); // A copy of the end coords for applying the special tags too. // We have to do this because enpassant capture tags aren't // generated for normal premoves const endCoordsTagged: CoordsTagged = coordutil.copyCoords(premove.endCoords); const isLegal = legalmoves.checkIfMoveLegal( gamefile, premovedPieceLegalMoves, premove.startCoords, endCoordsTagged, color, ); if (isLegal || selection.getEditMode()) return { legal: true, endCoordsTagged }; else return { legal: false }; } /** * Called externally when its our move in the game. * * Shouldn't care whether the game is over, as all premoves should have been cleared, * and not to mention we still need applied to be set to true. * * Similar to {@link applyPremoves}, but before applying premoves, it attempts to play the first premove in the list if legal. */ function onYourMove(gamefile: FullGame, mesh?: Mesh): void { // Process the next premove, will reapply the premoves processPremoves(gamefile, mesh); } /** * Executes a callback function with all premoves rewound, so the game state is correct for any board checks. * Then depending on the return value, may attempt to physically play the next premove when re-applying them. * @param gamefile * @param mesh * @param callback - A function that returns true if we should attempt to physically play our next premove when re-applying them. */ function performWithUnapplied( gamefile: FullGame, mesh: Mesh | undefined, callback: () => boolean, ): void { // Rewind all to get the real game state rewindPremoves(gamefile, mesh); const result = callback(); if (result) { // Attempt to physically make our next premove, and re-apply the remaining. onYourMove(gamefile, mesh); } else { // Just re-apply applyPremoves(gamefile, mesh); } } // Updating Premoves ------------------------------------------------ /** Clears premoves if right mouse is down and Lingering Annotations mode is off. */ function update(gamefile: FullGame, mesh?: Mesh): void { if (preferences.getLingeringAnnotationsMode()) return; // Right mouse down doesn't clear premoves in Lingering Annotations mode if (mouse.isMouseDown(Mouse.RIGHT)) { if (!hasAtleastOnePremove()) return; // No premoves to clear. Don't claim the right mouse button. mouse.claimMouseDown(Mouse.RIGHT); // Claim the right mouse button so it doesn't propagate to arrow drawing mouse.cancelMouseClick(Mouse.RIGHT); // Prevents the up-release from registering a click later, drawing a square highlight cancelPremoves(gamefile, mesh); } } // Rendering -------------------------------------------------------- /** Renders the premoves */ function render(): void { if (premoves.length === 0) return; // No premoves to render let premoveSquares = premoves.flatMap((p) => [p.startCoords, p.endCoords]); // De-duplicate the squares premoveSquares = premoveSquares.filter((coords, index, self) => { return self.findIndex((c) => coordutil.areCoordsEqual(c, coords)) === index; }); const u_size: number = boardpos.getBoardScaleAsNumber(); const color: Color = preferences.getAnnoteSquareColor(); // Render preset squares squarerendering.genModel(premoveSquares, color).render(undefined, undefined, { u_size }); } // Exports ------------------------------------------------ export default { hasAtleastOnePremove, arePremovesApplied, addPremove, cancelPremoves, rewindPremoves, applyPremoves, onYourMove, performWithUnapplied, update, render, }; ================================================ FILE: src/client/scripts/esm/game/chess/selection.ts ================================================ // src/client/scripts/esm/game/chess/selection.ts /** * This script tests for piece selection and keeps track of the selected piece, * including the legal moves it has available. */ import type { Mesh } from '../rendering/piecemodels.js'; import type { Piece } from '../../../../../shared/chess/util/boardutil.js'; import type { LegalMoves } from '../../../../../shared/chess/logic/legalmoves.js'; import type { Game, FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { CoordsTagged, MoveTagged } from '../../../../../shared/chess/logic/movepiece.js'; import bounds from '../../../../../shared/util/math/bounds.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import moveutil from '../../../../../shared/chess/util/moveutil.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import legalmoves from '../../../../../shared/chess/logic/legalmoves.js'; import specialdetect from '../../../../../shared/chess/logic/specialdetect.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import coordutil, { Coords } from '../../../../../shared/chess/util/coordutil.js'; import { rawTypes as r, players as p } from '../../../../../shared/chess/util/typeutil.js'; import mouse from '../../util/mouse.js'; import toast from '../gui/toast.js'; import pieces from '../rendering/pieces.js'; import arrows from '../rendering/arrows/arrows.js'; import config from '../config.js'; import guipause from '../gui/guipause.js'; import gameslot from './gameslot.js'; import boardpos from '../rendering/boardpos.js'; import premoves from '../chess/premoves.js'; import keybinds from '../misc/keybinds.js'; import { Mouse } from '../input.js'; import droparrows from '../rendering/dragging/droparrows.js'; import gameloader from './gameloader.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import enginegame from '../misc/enginegame.js'; import Transition from '../rendering/transitions/Transition.js'; import normaltool from '../boardeditor/tools/normaltool.js'; import preferences from '../../components/header/preferences.js'; import boardeditor from '../boardeditor/boardeditor.js'; import perspective from '../rendering/perspective.js'; import { GameBus } from '../GameBus.js'; import movesequence from './movesequence.js'; import frametracker from '../rendering/frametracker.js'; import guipromotion from '../gui/guipromotion.js'; import draganimation from '../rendering/dragging/draganimation.js'; import { animateMove } from './graphicalchanges.js'; import { listener_document, listener_overlay } from './game.js'; // Variables ----------------------------------------------------------------------------- /** The currently selected piece, if there is one */ let pieceSelected: Piece | undefined; /** The pre-calculated legal moves of the current selected piece. */ let legalMoves: LegalMoves | undefined; /** Whether or not the piece selected belongs to the opponent. * If so, it's legal moves are rendered a different color, and you aren't allowed to move it. */ let isOpponentPiece = false; /** Whether or not the piece selected activated premove mode. * This happens when we select our own pieces, in online games, when it's not our turn. */ let isPremove = false; /** The tile the mouse is hovering over, OR the tile we just performed a simulated click over: `[x,y]` */ let hoverSquare: CoordsTagged | undefined; // Current square mouse is hovering over /** Whether the {@link hoverSquare} is legal to move the selected piece to. */ let hoverSquareLegal: boolean = false; /** If a pawn is currently promoting (waiting on the promotion UI selection), * this will be set to the square it's moving to: `[x,y]`. */ let pawnIsPromotingOn: CoordsTagged | undefined; /** When a promotion UI piece is selected, this is set to the promotion you selected. */ let promoteTo: number | undefined; /** * When enabled, allows moving pieces anywhere else on the board, disregarding whether it's legal. * Special tags however will still only be transferred if the destination is legal. */ let editMode = false; // editMode, allows moving pieces anywhere else on the board! // Events ---------------------------------------------------------------------------------------- GameBus.addEventListener('game-concluded', () => { unselectPiece(); }); GameBus.addEventListener('game-unloaded', () => { disableEditMode(); unselectPiece(); }); // Getters --------------------------------------------------------------------------------------- /** Returns the current selected piece, if there is one. */ function getPieceSelected(): Piece | undefined { return pieceSelected; } /** Returns *true* if a piece is currently selected. */ function isAPieceSelected(): boolean { return pieceSelected !== undefined; } /** Returns true if we have selected an opponents piece to view their moves */ function isOpponentPieceSelected(): boolean { return isOpponentPiece; } /** Returns true if we are in premove mode (i.e. selected our own piece in an online game, when it's not our turn) */ function arePremoving(): boolean { return isPremove; } /** Returns the pre-calculated legal moves of the selected piece. */ function getLegalMovesOfSelectedPiece(): LegalMoves | undefined { return legalMoves; } /** Returns *true* if a pawn is currently promoting (promotion UI open). */ function getSquarePawnIsCurrentlyPromotingOn(): CoordsTagged | undefined { return pawnIsPromotingOn; } /** * Marks the currently selected pawn to be promoted next frame. * Call when a choice is made on the promotion UI. */ function promoteToType(type: number): void { promoteTo = type; } function getEditMode(): boolean { return editMode; } // Toggles EDIT MODE! editMode // Called when '1' is pressed! function toggleEditMode(): void { // Make sure it's legal const legalInPrivate = onlinegame.areInOnlineGame() && onlinegame.getIsPrivate() && listener_document.isKeyHeld('Digit0'); if (onlinegame.areInOnlineGame() && !legalInPrivate) return; // Don't toggle if in an online game if (enginegame.areInEngineGame()) return; // Don't toggle if in an engine game if (boardeditor.areInBoardEditor()) return; // Don't toggle if in board editor editMode = !editMode; toast.show(`Toggled Edit Mode: ${editMode}`); } function disableEditMode(): void { editMode = false; } function enableEditMode(): void { editMode = true; } // Updating --------------------------------------------------------------------------------------------- /** Tests if we have selected a piece, or moved the currently selected piece. */ function update(): void { guipromotion.update(); if (mouse.isMouseDown(Mouse.MIDDLE)) return unselectPiece(); // Right-click deselects everything // Guard clauses... const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); if (pawnIsPromotingOn) { // Do nothing else this frame but wait for a promotion piece to be selected if (promoteTo) makePromotionMove(gamefile, mesh); return; } if ( boardpos.areZoomedOut() || gamefileutility.isGameOver(gamefile.basegame) || guipause.areWePaused() || perspective.isLookingUp() ) { // We might be zoomed way out. // If we are still dragging a piece, we still want to be able to drop it. if (draganimation.areDraggingPiece() && draganimation.hasPointerReleased()) draganimation.dropPiece(); // Drop it without moving it. return; } // Update the hover square to: // 1. The draganimation hover coords, if present. The droparrows and dragarrows features can change this. // 2. Fallback to current mouse coords. hoverSquare = draganimation.getHoveredCoords() ?? mouse.getTileMouseOver_Integer(); // Update the tile the mouse is hovering over, if any. // console.log('Hover square:', hoverSquare); updateHoverSquareLegal(gamefile); // Update whether the hover square is legal to move to. if (!hoverSquare) return; // Looking into sky // Only exit during a transition after updating hover square if (Transition.areTransitioning()) return; // What should selection.ts do? // 1. Test if we selected a new piece, or a different piece. testIfPieceSelected(gamefile, mesh); // Test this EVEN if a piece is currently selected, because we can always select a different piece. // Piece IS selected... // 2. Test if the piece was dropped. If it happened to be dropped on a legal square, then make the move. testIfPieceDropped(gamefile, mesh); // 3. Test if the piece was moved. testIfPieceMoved(gamefile, mesh); } /** * Updates the hover square, and tests if it is among * our pre-calculated legal moves for our selected piece. * * This is required to call BEFORE we test if a piece should * be selected, because if we are switching selections, but * it turns out the new piece is legal to move to, we don't want * to select it instead, but capture it. */ function updateHoverSquareLegal(gamefile: FullGame): void { if (!pieceSelected) return; if (!hoverSquare) { hoverSquareLegal = false; return; } const colorOfSelectedPiece = typeutil.getColorFromType(pieceSelected.type); // Required to pass on the special tag const legal = legalmoves.checkIfMoveLegal( gamefile, legalMoves!, pieceSelected!.coords, hoverSquare, colorOfSelectedPiece, ); hoverSquareLegal = (legal && canMovePieceType(pieceSelected!.type)) || (editMode && legalmoves.testSquareValidity( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, hoverSquare, colorOfSelectedPiece, false, false, ) <= 1) || (boardeditor.areInBoardEditor() && !coordutil.areCoordsEqual(hoverSquare, pieceSelected.coords) && (gamefile.basegame.gameRules.worldBorder === undefined || bounds.boxContainsSquare(gamefile.basegame.gameRules.worldBorder, hoverSquare))); // Allow ALL moves in board editor. } // Piece Select / Drop / Move ----------------------------------------------------------------------------- /** If a piece was clicked or dragged, this will attempt to select that piece. */ function testIfPieceSelected(gamefile: FullGame, mesh: Mesh | undefined): void { if (arrows.areHoveringAtleastOneArrow()) return; // Don't select a piece if we're hovering over an arrow const mouseKeybind = keybinds.getPieceSelectionMouseButton(); if (mouseKeybind === undefined) return; // Nothing assigned to selecting pieces currently // If we did not click, exit... const effectiveDragEnabled = keybinds.getEffectiveDragEnabled(); if ( effectiveDragEnabled && !mouse.isMouseDown(mouseKeybind) && !mouse.isMouseClicked(mouseKeybind) ) return; // If dragging is enabled, all we need is pointer down event. else if (!effectiveDragEnabled && !mouse.isMouseClicked(mouseKeybind)) return; // When dragging is off, we actually need a pointer click. if (boardpos.boardHasMomentum()) return; // Don't select a piece if the boardsim is moving // We have clicked, test if we clicked a piece... const pieceClicked = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, hoverSquare!); // if (pieceClicked) console.log(typeutil.debugType(pieceClicked?.type)); // Is the type selectable by us? (not necessarily moveable) const selectionLevel = canSelectPieceType(gamefile.basegame, pieceClicked?.type); // console.log('Selection Level:', selectionLevel); if (selectionLevel === 0) return; // Can't select this piece type else if (selectionLevel >= 1 && mouse.isMouseClicked(mouseKeybind)) { // CAN select this piece type /** Just quickly make sure that, if we already have selected a piece, * AND we just clicked a piece that's legal to MOVE to, * that we don't select it instead! */ if (pieceSelected && hoverSquareLegal) return; // Return. Don't select it, NOR make the move, let testIfPieceMoved() catch that. mouse.claimMouseClick(mouseKeybind); // Claim the mouse click so that annotations does use it to Collapse annotations. // If we are viewing past moves, forward to front instead!! if (viewFrontIfNotViewingLatestMove(gamefile, mesh)) return; // Forwarded to front, DON'T select the piece. selectPiece(gamefile, mesh, pieceClicked!, false); // Select, but don't start dragging } else if (selectionLevel === 2 && mouse.isMouseDown(mouseKeybind)) { // Can DRAG this piece type /** Just quickly make sure that, if we already have selected a piece, * AND we just clicked a piece that's legal to MOVE to, * that we don't select it instead! */ if (pieceSelected && hoverSquareLegal) return; // Return. Don't select it, NOR make the move, let testIfPieceMoved() catch that. mouse.claimMouseDown(mouseKeybind); // Claim the mouse down so board dragging doesn't use it mouse.cancelMouseClick(mouseKeybind); // Cancel the click so annotation doesn't clear when the mouse released in a few frames, simulating a click. if (viewFrontIfNotViewingLatestMove(gamefile, mesh)) return; // Forwarded to front, DON'T select the piece. selectPiece(gamefile, mesh, pieceClicked!, true); // Select, AND start dragging if that's enabled. } } /** If a piece is being dragged, this will test if it was dropped, making the move if it is legal. */ function testIfPieceDropped(gamefile: FullGame, mesh: Mesh | undefined): void { if (!pieceSelected) return; // No piece selected, can't move nor drop anything. if (!draganimation.areDraggingPiece()) return; // The selected piece is not being dragged. droparrows.updateCapturedPiece(); // Update the piece that would be captured if we were to let go of the dragged piece right now. if (!draganimation.hasPointerReleased()) return; // The pointer has not released yet, don't drop it. // The pointer has released, drop the piece. // If it was dropped on its own square, AND the parity is negative, then also deselect the piece. const droppedOnOwnSquare = coordutil.areCoordsEqual(hoverSquare!, pieceSelected!.coords); if (droppedOnOwnSquare && !draganimation.getDragParity()) unselectPiece(); else if (hoverSquareLegal) moveGamefilePiece(gamefile, mesh, hoverSquare!); // It was dropped on a legal square. Make the move. Making a move automatically deselects the piece and cancels the drag. else draganimation.dropPiece(); // Drop it without moving it. } /** If a piece is selected, and we clicked a legal square to move to, this will make the move. */ function testIfPieceMoved(gamefile: FullGame, mesh: Mesh | undefined): void { if (!pieceSelected) return; if (arrows.areHoveringAtleastOneArrow()) return; // Don't move a piece if we're hovering over an arrow const mouseKeybind = keybinds.getPieceSelectionMouseButton(); if (mouseKeybind === undefined) return; // Nothing assigned to moving pieces currently if (!mouse.isMouseClicked(mouseKeybind)) return; // Pointer did not click, couldn't have moved a piece. if (!hoverSquareLegal) return; // Don't move it moveGamefilePiece(gamefile, mesh, hoverSquare!); mouse.claimMouseClick(mouseKeybind); // Claim the mouse click so that annotations does use it to Collapse annotations. } /** Forwards to the front of the game if we're viewing history, and returns true if we did. */ function viewFrontIfNotViewingLatestMove(gamefile: FullGame, mesh: Mesh | undefined): boolean { // If we're viewing history, return. if (moveutil.areWeViewingLatestMove(gamefile.boardsim)) return false; movesequence.viewFront(gamefile, mesh); // Also animate the last move const lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!; animateMove(lastMove.changes); return true; } // Can Select/Move/Drop Piece Type --------------------------------------------------------------------------------- /** * 0 => Can't select this piece type EVER (i.e. voids, neutrals). * 1 => Can select this piece type, but not draggable. * 2 => Can select and drag this piece type. * * A piece will not be considered draggable (level 2) if the user disabled dragging. * This means more information is needed to tell if the piece is moveable by us. */ function canSelectPieceType(basegame: Game, type: number | undefined): 0 | 1 | 2 { if (type === undefined) return 0; // Can't select nothing const dragEnabled = keybinds.getEffectiveDragEnabled(); if (boardeditor.areInBoardEditor()) return dragEnabled ? 2 : 1; // In board editor, we can select and drag ANY piece type, even voids! const [raw, player] = typeutil.splitType(type); if (raw === r.VOID) return 0; // Can't select voids if (editMode && gameloader.areInLocalGame()) return dragEnabled ? 2 : 1; // Edit mode allows any piece besides voids to be selected and dragged in local games. if (player === p.NEUTRAL) return 0; // Can't select neutrals, period. if (isOpponentType(basegame, type)) return 1; // Can select opponent pieces, but not draggable.. // It is our piece type... const isOurTurn = gameloader.isItOurTurn(); if (!isOurTurn && !preferences.getPremoveEnabled()) return 1; // Can select our piece when it's not our turn, but not draggable. return dragEnabled ? 2 : 1; // Can select and move this piece type (draggable too IF THAT IS ENABLED). } /** * Returns true if the user is currently allowed to move the pieceType. It must be our piece and our turn. */ function canMovePieceType(pieceType: number): boolean { if (editMode) return true; // Edit mode allows pieces to be moved on any turn. const isOpponentPiece = isOpponentType(gameslot.getGamefile()!.basegame, pieceType); if (isOpponentPiece) return false; // Don't move opponent pieces // It is our piece type... const isOurTurn = gameloader.isItOurTurn(); if (isOurTurn) return true; // Can always move pieces on our turn return preferences.getPremoveEnabled(); // If it's not out turn, can only move if premoving is enabled. } /** Returns true if the type belongs to our opponent, no matter what kind of game we're in. */ function isOpponentType(basegame: Game, type: number): boolean { const pieceColor = typeutil.getColorFromType(type); if (boardeditor.areInBoardEditor()) return false; else if (gameloader.areInLocalGame()) return pieceColor !== basegame.whosTurn; else return pieceColor !== gameloader.getOurColor(); } // Selection & Moving --------------------------------------------------------------------------------------------- /** * Selects the provided piece. If the piece is already selected, it will be deselected. * @param gamefile * @param piece * @param drag - If true, the piece starts being dragged. This also means it won't be deselected if you clicked the selected piece again. */ function selectPiece( gamefile: FullGame, mesh: Mesh | undefined, piece: Piece, drag: boolean, ): void { hoverSquareLegal = false; // Reset the hover square legal flag so that it doesn't remain true for the remainer of the update loop. const alreadySelected = pieceSelected !== undefined && coordutil.areCoordsEqual(pieceSelected.coords, piece.coords); if (drag) { // Pick up anyway, don't unselect it if it was already selected. if (alreadySelected) { draganimation.pickUpPiece(piece, false); // Toggle the parity since it's the same piece being picked up. return; // Already selected, don't have to recalculate legal moves. } draganimation.pickUpPiece(piece, true); // Reset parity since it's a new piece being picked up. } else { // Not being dragged. If this piece is already selected, unselect it. if (alreadySelected) return unselectPiece(); } initSelectedPieceInfo(gamefile, mesh, piece); } /** * Reselects the currently selected piece by recalculating its legal moves again, * and changing the color if needed. * Typically called after our opponent makes a move while we have a piece selected. */ function reselectPiece(): void { if (!pieceSelected) return; // No piece to reselect. const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); // Test if the piece is no longer there // This will work for us long as it is impossible to capture friendly's const pieceTypeOnCoords = boardutil.getTypeFromCoords( gamefile.boardsim.pieces, pieceSelected.coords, ); if (pieceTypeOnCoords !== pieceSelected.type) { // It either moved, or was captured unselectPiece(); // Can't be reselected, unselect it instead. return; } if (gamefileutility.isGameOver(gamefile.basegame)) return; // Don't reselect, game is over // Reselect! Recalc its legal moves, and recolor. const pieceToReselect = boardutil.getPieceFromCoords( gamefile.boardsim.pieces, pieceSelected.coords, )!; initSelectedPieceInfo(gamefile, mesh, pieceToReselect); // FIXES BUG where if you premove a promotion, but leave the promotion UI open, // then your opponent moves, resulting in that promotion being illegal, // the promotion UI remains open, allowing you to make that illegal promotion, // resulting in the game being aborted from an illegal move played! // Close the promotion UI if it's open, ONLY if the square being promoted to is now illegal. if (pawnIsPromotingOn) { const colorOfSelectedPiece = typeutil.getColorFromType(pieceSelected.type); // Use a copy so special tags aren't attached to the original pawnIsPromotingOn const endCoordsCopy: Coords = coordutil.copyCoords(pawnIsPromotingOn); const legal = legalmoves.checkIfMoveLegal( gamefile, legalMoves!, pieceSelected.coords, endCoordsCopy, colorOfSelectedPiece, ); if (!legal) { // Cancel promotion (but can still leave the piece selected) pawnIsPromotingOn = undefined; promoteTo = undefined; // Just in case a promotion was selected same frame guipromotion.close(); } } } /** Unselects the currently selected piece. Cancels pawns currently promoting, closes the promotion UI. */ function unselectPiece(): void { // console.error("Unselecting piece"); if (pieceSelected === undefined) return; // No piece to unselect. pieceSelected = undefined; isOpponentPiece = false; isPremove = false; legalMoves = undefined; pawnIsPromotingOn = undefined; promoteTo = undefined; hoverSquareLegal = false; frametracker.onVisualChange(); GameBus.dispatch('piece-unselected'); } /** Initializes the selected piece, and calculates its legal moves. */ function initSelectedPieceInfo(gamefile: FullGame, mesh: Mesh | undefined, piece: Piece): void { // Initiate pieceSelected = piece; isOpponentPiece = isOpponentType(gamefile.basegame, piece.type); isPremove = !isOpponentPiece && !gameloader.isItOurTurn(); // Calculate the legal moves it has... if (isPremove && preferences.getPremoveEnabled()) { // DO NOT rewind the premoves here before calculation, // because we do need the SPECIALRIGHT state changes to still be applied! // Else if you premove a pawn onto an opponent's pawn that hasn't moved, // your premoved pawn will be able to double push again past their 8th rank. legalMoves = legalmoves.calculateAllPremoves(gamefile, piece); } else { premoves.performWithUnapplied(gamefile, mesh, () => { legalMoves = legalmoves.calculateAll(gamefile, piece); return false; // Do NOT attempt to physically play the next premove when they're re-applied }); } // console.log('Selected Legal Moves:', legalMoves); GameBus.dispatch('piece-selected', { piece: pieceSelected, legalMoves: legalMoves! }); } /** * Moves the currently selected piece to the specified coordinates, then unselects the piece. * The destination coordinates MUST contain any special move tags. * @param coords - The destination coordinates`[x,y]`. MUST contain any special move tags. */ function moveGamefilePiece(gamefile: FullGame, mesh: Mesh | undefined, coords: CoordsTagged): void { // Check if the move is a pawn promotion if (coords.promoteTrigger && !boardeditor.areInBoardEditor()) return onPromoteTrigger(coords); const strippedCoords: Coords = moveutil.stripSpecialMoveTagsFromCoords(coords); const moveTagged: MoveTagged = { startCoords: pieceSelected!.coords, endCoords: strippedCoords, }; specialdetect.transferSpecialTags_FromCoordsToMove(coords, moveTagged); // Since making a move immediately cancels the current drag, we // have to note whether it was being dragged BEFORE we move it! const wasBeingDragged = draganimation.areDraggingPiece(); const changes = boardeditor.areInBoardEditor() ? normaltool.makeMoveEdit(gamefile, mesh, moveTagged).changes : isPremove ? premoves.addPremove(gamefile, mesh, moveTagged).changes : movesequence.makeMove(gamefile, mesh, moveTagged).changes; // Not actually needed? Test it. To my knowledge, animation.ts will automatically cancel previous animations, since now it handles playing the sound for drops. // if (wasBeingDragged) animation.clearAnimations(); // We still need to clear any other animations in progress BEFORE we make the move (in case a secondary needs to be animated) // Don't animate the main piece if it's being dragged, but still animate secondary pieces affected by the move (like the rook in castling). const animateMain = !wasBeingDragged; animateMove(changes, true, animateMain, isPremove); if (!isPremove) GameBus.dispatch('user-move-played'); // Do very last, so that isPremove doesn't get reset. unselectPiece(); } /** Opens the promotion UI */ function onPromoteTrigger(coords: CoordsTagged): void { const color = typeutil.getColorFromType(pieceSelected!.type); guipromotion.open(color); perspective.unlockMouse(); pawnIsPromotingOn = coords; // Delete the promoteTrigger now delete coords.promoteTrigger; } /** Adds the promotion tag to the destination coordinates before making the move. */ function makePromotionMove(gamefile: FullGame, mesh: Mesh | undefined): void { const coords: CoordsTagged = pawnIsPromotingOn!; // Add the promoteTo tag coords.promotion = promoteTo!; moveGamefilePiece(gamefile, mesh, coords); perspective.relockMouse(); } /** If the given pointer is currently being used to drag a piece, this stops using it. */ function stealPointer(pointerIdToSteal: string): void { if (!pieceSelected || !draganimation.areDraggingPiece()) return; const pointerDraggingPiece = draganimation.getPointerIdDraggingPiece(); if (pointerDraggingPiece !== pointerIdToSteal) return; // Not the pointer dragging the piece, don't stop using it. if (draganimation.getDragParity()) return unselectPiece(); return draganimation.dropPiece(); } // Rendering --------------------------------------------------------------------------------------------------------- /** Renders the translucent piece underneath your mouse when hovering over the blue legal move fields. */ function renderGhostPiece(): void { const mouseKeybind = keybinds.getPieceSelectionMouseButton(); if (mouseKeybind === undefined) return; // Nothing assigned to selecting pieces currently, can't move piece => shouldn't render ghost piece. if ( !pieceSelected || !hoverSquare || !hoverSquareLegal || draganimation.areDraggingPiece() || listener_overlay.isMouseTouch(mouseKeybind) || config.VIDEO_MODE ) return; const rawType = typeutil.getRawType(pieceSelected.type); if (typeutil.SVGLESS_TYPES.has(rawType)) return; // No svg/texture for this piece (void), don't render the ghost image. pieces.renderGhostPiece(pieceSelected!.type, hoverSquare); } // Exports ------------------------------------------------------------------------------------ export default { isAPieceSelected, getPieceSelected, reselectPiece, unselectPiece, getLegalMovesOfSelectedPiece, getSquarePawnIsCurrentlyPromotingOn, getEditMode, toggleEditMode, disableEditMode, enableEditMode, promoteToType, update, renderGhostPiece, isOpponentPieceSelected, arePremoving, stealPointer, selectPiece, canSelectPieceType, }; ================================================ FILE: src/client/scripts/esm/game/config.ts ================================================ // src/client/scripts/esm/game/config.ts /** This script contains our game configurations. */ import docutil from '../util/docutil.js'; /** Video mode disables the rendering of some items, making making recordings more immersive. */ const VIDEO_MODE: boolean = false; /** * True if the current page is running on a local environment (localhost or local IP). * If so, some dev/debugging features are enabled. * Also, the main menu background stops moving after 2 seconds instead of 30. */ const DEV_BUILD: boolean = docutil.isLocalEnvironment(); export default { VIDEO_MODE, DEV_BUILD, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/guiclearposition.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/guiclearposition.ts /** * Manages the GUI popup window for the Clear position button of the Board Editor */ import eactions from '../../../boardeditor/actions/eactions'; import guipause from '../../guipause'; import guifloatingwindow from '../guifloatingwindow'; import { listener_document } from '../../../chess/game'; // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Start local game popup window. */ const element_clearbutton = document.getElementById('clearall')!; /** The actual window of the Game Rules popup. */ const element_window = document.getElementById('clear-position-UI')!; const element_header = document.getElementById('clear-position-UI-header')!; const element_closeButton = document.getElementById('close-clear-position-UI')!; const yesButton = document.getElementById('clear-position-yes')!; const noButton = document.getElementById('clear-position-no')!; // Create floating window ------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, onOpen, onClose, }); // Toggling --------------------------------------------- function onOpen(): void { element_clearbutton.classList.add('active'); initClearPositionUIListeners(); } function onClose(resetPositioning: boolean): void { if (resetPositioning) floatingWindow.resetPositioning(); element_clearbutton.classList.remove('active'); closeClearPositionUIListeners(); } // Gamerules-specific listeners ------------------------------------------- function initClearPositionUIListeners(): void { yesButton.addEventListener('click', onYesButtonPress); noButton.addEventListener('click', onNoButtonPress); document.addEventListener('keydown', onKeyDown); } function closeClearPositionUIListeners(): void { yesButton.removeEventListener('click', onYesButtonPress); noButton.removeEventListener('click', onNoButtonPress); document.removeEventListener('keydown', onKeyDown); } // Utilities--------------------------------------------------------------------- function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Enter') onYesButtonPress(); else if (e.key === 'Escape') { // Ensure priority when deciding who gets the escape key event if (guipause.areWePaused()) return; listener_document.claimKey('Escape'); onNoButtonPress(); } } function onYesButtonPress(): void { eactions.clearAll(); floatingWindow.close(false); } function onNoButtonPress(): void { floatingWindow.close(false); } // Exports ----------------------------------------------------------------- export default { open: floatingWindow.open, close: floatingWindow.close, isOpen: floatingWindow.isOpen, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/guigamerules.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/guigamerules.ts /** * Manages the GUI popup window for the Game Rules of the Board Editor */ import type { Edit } from '../../../../../../../shared/chess/logic/movepiece'; import type { Coords } from '../../../../../../../shared/chess/util/coordutil'; import type { UnboundedRectangle } from '../../../../../../../shared/util/math/bounds'; import type { GameruleWinCondition } from '../../../../../../../shared/chess/util/winconutil'; import bounds from '../../../../../../../shared/util/math/bounds'; import boardutil from '../../../../../../../shared/chess/util/boardutil'; import icnconverter from '../../../../../../../shared/chess/logic/icn/icnconverter'; import typeutil, { players as p, rawTypes as r, RawType, } from '../../../../../../../shared/chess/util/typeutil'; import gameslot from '../../../chess/gameslot'; import boardeditor from '../../../boardeditor/boardeditor'; import edithistory from '../../../boardeditor/edithistory'; import guifloatingwindow from '../guifloatingwindow'; import egamerules, { GameRulesGUIinfo } from '../../../boardeditor/egamerules'; // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Game Rules popup window. */ const element_gamerules = document.getElementById('gamerules')!; /** The actual window of the Game Rules popup. */ const element_window = document.getElementById('game-rules')!; const element_header = document.getElementById('game-rules-header')!; const element_closeButton = document.getElementById('close-rules')!; const element_white = document.getElementById('rules-white')! as HTMLInputElement; const element_black = document.getElementById('rules-black')! as HTMLInputElement; const element_enPassantX = document.getElementById('rules-enpassant-x')! as HTMLInputElement; const element_enPassantY = document.getElementById('rules-enpassant-y')! as HTMLInputElement; const element_moveruleCurrent = document.getElementById( 'rules-moverule-current', )! as HTMLInputElement; const element_moveruleMax = document.getElementById('rules-moverule-max')! as HTMLInputElement; const element_promotionranksWhite = document.getElementById( 'rules-promotionranks-white', )! as HTMLInputElement; const element_promotionranksBlack = document.getElementById( 'rules-promotionranks-black', )! as HTMLInputElement; const element_promotionpieces = document.getElementById( 'rules-promotionpieces', )! as HTMLInputElement; const element_checkmate = document.getElementById('rules-checkmate')! as HTMLInputElement; const element_royalcapture = document.getElementById('rules-royalcapture')! as HTMLInputElement; const element_allroyalscaptured = document.getElementById( 'rules-allroyalscaptured', )! as HTMLInputElement; const element_allpiecescaptured = document.getElementById( 'rules-allpiecescaptured', )! as HTMLInputElement; const element_pawnDoublePush = document.getElementById('rules-doublepush')! as HTMLInputElement; const element_castling = document.getElementById('rules-castling')! as HTMLInputElement; const element_borderLeft = document.getElementById('rules-border-left')! as HTMLInputElement; const element_borderRight = document.getElementById('rules-border-right')! as HTMLInputElement; const element_borderBottom = document.getElementById('rules-border-bottom')! as HTMLInputElement; const element_borderTop = document.getElementById('rules-border-top')! as HTMLInputElement; const elements_selectionList: HTMLInputElement[] = [ element_white, element_black, element_enPassantX, element_enPassantY, element_moveruleCurrent, element_moveruleMax, element_promotionranksWhite, element_promotionranksBlack, element_promotionpieces, element_checkmate, element_royalcapture, element_allroyalscaptured, element_allpiecescaptured, element_pawnDoublePush, element_castling, element_borderLeft, element_borderRight, element_borderBottom, element_borderTop, ]; // Constants -------------------------------------------------------------- /** Regexes for validating game rules input fields */ const integerRegex = new RegExp(String.raw`^${icnconverter.integerSource}$`); const promotionRanksRegex = new RegExp(String.raw`^${icnconverter.promotionRanksSource}$`); const promotionsAllowedRegex = new RegExp(String.raw`^${icnconverter.promotionsAllowedSource}$`); // Create floating window ------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, inputElList: elements_selectionList, onOpen, onClose, }); // Toggling --------------------------------------------- function onOpen(): void { element_gamerules.classList.add('active'); initGameRulesListeners(); } function onClose(resetPositioning: boolean): void { if (resetPositioning) floatingWindow.resetPositioning(); element_gamerules.classList.remove('active'); closeGameRulesListeners(); } // Gamerules-specific listeners ------------------------------------------- function initGameRulesListeners(): void { elements_selectionList.forEach((el) => { el.addEventListener('blur', readGameRules); }); } function closeGameRulesListeners(): void { elements_selectionList.forEach((el) => { el.removeEventListener('blur', readGameRules); }); } // Reading/Writing Game Rules ----------------------------------------------- /** Reads the game rules inserted into the input boxes and updates egamerules.gameRulesGUIinfo */ function readGameRules(): void { // playerToMove const playerToMove = element_white.checked ? 'white' : 'black'; // enPassant let validEnPassantCoords = 0; const enPassantX = element_enPassantX.value; if (integerRegex.test(enPassantX)) { element_enPassantX.classList.remove('invalid-input'); validEnPassantCoords++; } else if (enPassantX === '') { element_enPassantX.classList.remove('invalid-input'); } else { element_enPassantX.classList.add('invalid-input'); } const enPassantY = element_enPassantY.value; if (integerRegex.test(enPassantY)) { element_enPassantY.classList.remove('invalid-input'); validEnPassantCoords++; } else if (enPassantY === '') { element_enPassantY.classList.remove('invalid-input'); } else { element_enPassantY.classList.add('invalid-input'); } const enPassant = validEnPassantCoords === 2 ? { x: BigInt(enPassantX), y: BigInt(enPassantY) } : undefined; // moveRule let validMoveRuleInputs = 0; const moveRuleCurrent = element_moveruleCurrent.value; if (integerRegex.test(moveRuleCurrent) && Number(moveRuleCurrent) >= 0) { element_moveruleCurrent.classList.remove('invalid-input'); validMoveRuleInputs++; } else if (moveRuleCurrent === '') { element_moveruleCurrent.classList.remove('invalid-input'); } else { element_moveruleCurrent.classList.add('invalid-input'); } const moveRuleMax = element_moveruleMax.value; if (integerRegex.test(moveRuleMax) && Number(moveRuleMax) > 0) { if (validMoveRuleInputs === 1 && Number(moveRuleCurrent) > Number(moveRuleMax)) { element_moveruleMax.classList.add('invalid-input'); } else { element_moveruleMax.classList.remove('invalid-input'); validMoveRuleInputs++; } } else if (moveRuleMax === '') { element_moveruleMax.classList.remove('invalid-input'); } else { element_moveruleMax.classList.add('invalid-input'); } // prettier-ignore const moveRule = (validMoveRuleInputs === 2 ? { current: Number(moveRuleCurrent), max: Number(moveRuleMax) } : undefined); // promotionRanks let promotionRanksWhite: bigint[] = []; const promotionRanksWhiteInput = element_promotionranksWhite.value; if (promotionRanksRegex.test(promotionRanksWhiteInput)) { element_promotionranksWhite.classList.remove('invalid-input'); promotionRanksWhite = [...new Set(promotionRanksWhiteInput.split(',').map(BigInt))]; } else if (promotionRanksWhiteInput === '') { element_promotionranksWhite.classList.remove('invalid-input'); } else { element_promotionranksWhite.classList.add('invalid-input'); } let promotionRanksBlack: bigint[] = []; const promotionRanksBlackInput = element_promotionranksBlack.value; if (promotionRanksRegex.test(promotionRanksBlackInput)) { element_promotionranksBlack.classList.remove('invalid-input'); promotionRanksBlack = [...new Set(promotionRanksBlackInput.split(',').map(BigInt))]; } else if (promotionRanksBlackInput === '') { element_promotionranksBlack.classList.remove('invalid-input'); } else { element_promotionranksBlack.classList.add('invalid-input'); } // prettier-ignore const promotionRanks = (promotionRanksWhite.length === 0 && promotionRanksBlack.length === 0) ? undefined : { white: promotionRanksWhite.length === 0 ? undefined : promotionRanksWhite, black: promotionRanksBlack.length === 0 ? undefined : promotionRanksBlack }; // promotions allowed let promotionsAllowed: RawType[] | undefined = undefined; const promotionsAllowedRaw = element_promotionpieces.value; pa: if (promotionsAllowedRegex.test(promotionsAllowedRaw)) { const runningPromotionsAllowed: RawType[] = []; for (const code of promotionsAllowedRaw.split(',')) { const typeStr: string | undefined = icnconverter.piece_codes_inverted[code]; if (typeStr === undefined) { element_promotionpieces.classList.add('invalid-input'); break pa; } const type = Number(typeStr); const [rawType, color] = typeutil.splitType(type); if ( typeutil.royals.includes(rawType) || // Can't promote to royals rawType === r.PAWN || // Can't promote to pawns color === p.NEUTRAL || // Can't promote to neutrals runningPromotionsAllowed.includes(rawType) // No duplicates ) { element_promotionpieces.classList.add('invalid-input'); break pa; } runningPromotionsAllowed.push(rawType); } // All promotion pieces are valid element_promotionpieces.classList.remove('invalid-input'); promotionsAllowed = runningPromotionsAllowed; } else if (promotionsAllowedRaw === '') { element_promotionpieces.classList.remove('invalid-input'); } else { element_promotionpieces.classList.add('invalid-input'); } // win conditions const winConditions: GameruleWinCondition[] = []; if (element_checkmate.checked) winConditions.push('checkmate'); if (element_royalcapture.checked) winConditions.push('royalcapture'); if (element_allroyalscaptured.checked) winConditions.push('allroyalscaptured'); if (element_allpiecescaptured.checked) winConditions.push('allpiecescaptured'); if (winConditions.length === 0) winConditions.push(icnconverter.default_win_condition); // pawn double push let pawnDoublePush: boolean | undefined = undefined; if (!element_pawnDoublePush.indeterminate) pawnDoublePush = element_pawnDoublePush.checked; // castling with rooks let castling: boolean | undefined = undefined; if (!element_castling.indeterminate) castling = element_castling.checked; // World Border let worldBorder: UnboundedRectangle | undefined = undefined; const borderInputs = [ { el: element_borderLeft, val: element_borderLeft.value }, { el: element_borderRight, val: element_borderRight.value }, { el: element_borderBottom, val: element_borderBottom.value }, { el: element_borderTop, val: element_borderTop.value }, ]; const gamefile = gameslot.getGamefile()!; const anyBorderSet = borderInputs.some((input) => input.val !== ''); if (!anyBorderSet) { // All empty -> Valid (Undefined) borderInputs.forEach((input) => input.el.classList.remove('invalid-input')); worldBorder = undefined; } else { // Must be valid integers or empty, and must be ascending // Empty represents Infinity or -Infinity let leftValid = !element_borderLeft.value || integerRegex.test(element_borderLeft.value); let rightValid = !element_borderRight.value || (integerRegex.test(element_borderRight.value) && (!leftValid || !element_borderLeft.value || BigInt(element_borderRight.value) >= BigInt(element_borderLeft.value))); let bottomValid = !element_borderBottom.value || integerRegex.test(element_borderBottom.value); let topValid = !element_borderTop.value || (integerRegex.test(element_borderTop.value) && (!bottomValid || !element_borderBottom.value || BigInt(element_borderTop.value) >= BigInt(element_borderBottom.value))); if (leftValid && rightValid && bottomValid && topValid) { // Initial values look valid worldBorder = { left: element_borderLeft.value ? BigInt(element_borderLeft.value) : null, right: element_borderRight.value ? BigInt(element_borderRight.value) : null, bottom: element_borderBottom.value ? BigInt(element_borderBottom.value) : null, top: element_borderTop.value ? BigInt(element_borderTop.value) : null, }; if ( worldBorder.left === null && worldBorder.right === null && worldBorder.bottom === null && worldBorder.top === null ) worldBorder = undefined; // Further check if all pieces are within the border if (worldBorder) { const allCoords = boardutil.getCoordsOfAllPieces(gamefile.boardsim.pieces); if (allCoords.some((coords) => !bounds.boxContainsSquare(worldBorder!, coords))) { // One or more pieces are outside the border -> All invalid leftValid = false; rightValid = false; bottomValid = false; topValid = false; } } } // Mark invalid fields as invalid. if (!leftValid) element_borderLeft.classList.add('invalid-input'); else element_borderLeft.classList.remove('invalid-input'); if (!rightValid) element_borderRight.classList.add('invalid-input'); else element_borderRight.classList.remove('invalid-input'); if (!bottomValid) element_borderBottom.classList.add('invalid-input'); else element_borderBottom.classList.remove('invalid-input'); if (!topValid) element_borderTop.classList.add('invalid-input'); else element_borderTop.classList.remove('invalid-input'); if (!leftValid || !rightValid || !bottomValid || !topValid) worldBorder = undefined; } const gameRules: GameRulesGUIinfo = { playerToMove, enPassant, moveRule, promotionRanks, promotionsAllowed, winConditions, pawnDoublePush, castling, worldBorder, }; // Update gamefile properties for rendering purposes and correct legal move calculation // prettier-ignore const enpassantSquare: Coords | undefined = gameRules.enPassant !== undefined ? [gameRules.enPassant.x, gameRules.enPassant.y] : undefined; egamerules.updateGamefileProperties( enpassantSquare, gameRules.promotionRanks, gameRules.playerToMove, gameRules.worldBorder, ); const mesh = gameslot.getMesh()!; const edit: Edit = { changes: [], state: { local: [], global: [] } }; // Fetch previous values before updating, to skip queuing when unchanged and prevent unnecessary edit history bloat. const previousPositionDependentGameRules = egamerules.getPositionDependentGameRules(); // Update pawn double push specialrights of position, only if the value changed if ( gameRules.pawnDoublePush !== undefined && gameRules.pawnDoublePush !== previousPositionDependentGameRules.pawnDoublePush ) egamerules.queueToggleGlobalPawnDoublePush(gameRules.pawnDoublePush, edit); // Update castling with rooks specialrights of position, only if the value changed if ( gameRules.castling !== undefined && gameRules.castling !== previousPositionDependentGameRules.castling ) egamerules.queueToggleGlobalCastlingWithRooks(gameRules.castling, edit); // Upate boardeditor.gamerulesGUIinfo egamerules.updateGamerulesGUIinfo(gameRules); edithistory.runEdit(gamefile, mesh, edit, true); edithistory.addEditToHistory(edit); // Mark as dirty anyway, since edithistory.addEditToHistory() may early exit // if the edit has no changes, but gamerule changes still consider the position dirty. boardeditor.markPositionDirty(); } /** Sets the game rules in the game rules GUI according to the supplied GameRulesGUIinfo object*/ function setGameRules(gamerulesGUIinfo: GameRulesGUIinfo): void { if (gamerulesGUIinfo.playerToMove === 'white') { element_white.checked = true; element_black.checked = false; } else { element_white.checked = false; element_black.checked = true; } if (gamerulesGUIinfo.enPassant !== undefined) { element_enPassantX.value = String(gamerulesGUIinfo.enPassant.x); element_enPassantY.value = String(gamerulesGUIinfo.enPassant.y); } else { element_enPassantX.value = ''; element_enPassantY.value = ''; } if (gamerulesGUIinfo.moveRule !== undefined) { element_moveruleCurrent.value = String(gamerulesGUIinfo.moveRule.current); element_moveruleMax.value = String(gamerulesGUIinfo.moveRule.max); } else { element_moveruleCurrent.value = ''; element_moveruleMax.value = ''; } if (gamerulesGUIinfo.promotionRanks !== undefined) { if (gamerulesGUIinfo.promotionRanks.white !== undefined) { element_promotionranksWhite.value = gamerulesGUIinfo.promotionRanks.white .map((bigint) => String(bigint)) .join(','); } else element_promotionranksWhite.value = ''; if (gamerulesGUIinfo.promotionRanks.black !== undefined) { element_promotionranksBlack.value = gamerulesGUIinfo.promotionRanks.black .map((bigint) => String(bigint)) .join(','); } else element_promotionranksBlack.value = ''; } else { element_promotionranksWhite.value = ''; element_promotionranksBlack.value = ''; } if (gamerulesGUIinfo.promotionsAllowed !== undefined) { element_promotionpieces.value = gamerulesGUIinfo.promotionsAllowed .map((type) => icnconverter.piece_codes_raw[type]) .join(',') .toUpperCase(); } else element_promotionpieces.value = ''; element_checkmate.checked = gamerulesGUIinfo.winConditions.includes('checkmate'); element_royalcapture.checked = gamerulesGUIinfo.winConditions.includes('royalcapture'); element_allroyalscaptured.checked = gamerulesGUIinfo.winConditions.includes('allroyalscaptured'); element_allpiecescaptured.checked = gamerulesGUIinfo.winConditions.includes('allpiecescaptured'); if (gamerulesGUIinfo.pawnDoublePush === undefined) { element_pawnDoublePush.indeterminate = true; element_pawnDoublePush.checked = false; } else { element_pawnDoublePush.indeterminate = false; element_pawnDoublePush.checked = gamerulesGUIinfo.pawnDoublePush; } if (gamerulesGUIinfo.castling === undefined) { element_castling.indeterminate = true; element_castling.checked = false; } else { element_castling.indeterminate = false; element_castling.checked = gamerulesGUIinfo.castling; } // World Border if (gamerulesGUIinfo.worldBorder !== undefined) { element_borderLeft.value = String(gamerulesGUIinfo.worldBorder.left ?? ''); element_borderRight.value = String(gamerulesGUIinfo.worldBorder.right ?? ''); element_borderBottom.value = String(gamerulesGUIinfo.worldBorder.bottom ?? ''); element_borderTop.value = String(gamerulesGUIinfo.worldBorder.top ?? ''); } else { element_borderLeft.value = ''; element_borderRight.value = ''; element_borderBottom.value = ''; element_borderTop.value = ''; } // Since we manually set all inputs in this function, they are all valid element_enPassantX.classList.remove('invalid-input'); element_enPassantY.classList.remove('invalid-input'); element_moveruleCurrent.classList.remove('invalid-input'); element_moveruleMax.classList.remove('invalid-input'); element_promotionranksWhite.classList.remove('invalid-input'); element_promotionranksBlack.classList.remove('invalid-input'); element_promotionpieces.classList.remove('invalid-input'); element_borderLeft.classList.remove('invalid-input'); element_borderRight.classList.remove('invalid-input'); element_borderBottom.classList.remove('invalid-input'); element_borderTop.classList.remove('invalid-input'); } // Exports ----------------------------------------------------------------- export default { open: floatingWindow.open, close: floatingWindow.close, isOpen: floatingWindow.isOpen, setGameRules, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/guiresetposition.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/guiresetposition.ts /** * Manages the GUI popup window for the Reset position button of the Board Editor */ import eactions from '../../../boardeditor/actions/eactions'; import guipause from '../../guipause'; import guifloatingwindow from '../guifloatingwindow'; import { listener_document } from '../../../chess/game'; // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Start local game popup window. */ const element_resetbutton = document.getElementById('reset')!; /** The actual window of the Game Rules popup. */ const element_window = document.getElementById('reset-position-UI')!; const element_header = document.getElementById('reset-position-UI-header')!; const element_closeButton = document.getElementById('close-reset-position-UI')!; const yesButton = document.getElementById('reset-position-yes')!; const noButton = document.getElementById('reset-position-no')!; // Create floating window ------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, onOpen, onClose, }); // Toggling --------------------------------------------- function onOpen(): void { element_resetbutton.classList.add('active'); initResetPositionUIListeners(); } function onClose(resetPositioning: boolean): void { if (resetPositioning) floatingWindow.resetPositioning(); element_resetbutton.classList.remove('active'); closeResetPositionUIListeners(); } // Gamerules-specific listeners ------------------------------------------- function initResetPositionUIListeners(): void { yesButton.addEventListener('click', onYesButtonPress); noButton.addEventListener('click', onNoButtonPress); document.addEventListener('keydown', onKeyDown); } function closeResetPositionUIListeners(): void { yesButton.removeEventListener('click', onYesButtonPress); noButton.removeEventListener('click', onNoButtonPress); document.removeEventListener('keydown', onKeyDown); } // Utilities--------------------------------------------------------------------- function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Enter') onYesButtonPress(); else if (e.key === 'Escape') { // Ensure priority when deciding who gets the escape key event if (guipause.areWePaused()) return; listener_document.claimKey('Escape'); onNoButtonPress(); } } function onYesButtonPress(): void { eactions.reset(); floatingWindow.close(false); } function onNoButtonPress(): void { floatingWindow.close(false); } // Exports ----------------------------------------------------------------- export default { open: floatingWindow.open, close: floatingWindow.close, isOpen: floatingWindow.isOpen, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/guistartenginegame.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/guistartenginegame.ts /** * Manages the GUI popup window for the Start engine game button of the Board Editor */ import type { Player } from '../../../../../../../shared/chess/util/typeutil'; import type { TimeControl } from '../../../../../../../shared/types'; import icnconverter from '../../../../../../../shared/chess/logic/icn/icnconverter'; import { players as p } from '../../../../../../../shared/chess/util/typeutil'; import eactions from '../../../boardeditor/actions/eactions'; import gameslot from '../../../chess/gameslot'; import guipause from '../../guipause'; import guifloatingwindow from '../guifloatingwindow'; import { listener_document } from '../../../chess/game'; // Types ------------------------------------------------------------- interface EngineUIConfig { youAreColor: Player; timeControl: TimeControl; strengthLevel: 1 | 2 | 3; setDefaultWorldBorder: boolean; } // Constants ---------------------------------------------------------- const timeControlRegex = new RegExp( String.raw`^${icnconverter.wholeNumberSource}\+${icnconverter.wholeNumberSource}$`, ); // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Start engine game popup window. */ const element_enginegamebutton = document.getElementById('start-engine-game')!; /** The actual window of the Game Rules popup. */ const element_window = document.getElementById('engine-game-UI')!; const element_header = document.getElementById('engine-game-UI-header')!; const element_closeButton = document.getElementById('close-engine-game-UI')!; const noButton = document.getElementById('start-engine-game-no')!; const yesButton = document.getElementById('start-engine-game-yes')!; const element_white = document.getElementById('engine-game-white')! as HTMLInputElement; const element_black = document.getElementById('engine-game-black')! as HTMLInputElement; const element_timecontrol = document.getElementById('engine-game-timecontrol')! as HTMLInputElement; const element_easy = document.getElementById('engine-game-easy')! as HTMLInputElement; const element_medium = document.getElementById('engine-game-medium')! as HTMLInputElement; const element_hard = document.getElementById('engine-game-hard')! as HTMLInputElement; const element_noborder = document.getElementById('engine-game-border-no')! as HTMLInputElement; const element_yesborder = document.getElementById('engine-game-border-yes')! as HTMLInputElement; const elements_selectionList: HTMLInputElement[] = [ element_white, element_black, element_timecontrol, element_easy, element_medium, element_hard, element_noborder, element_yesborder, ]; // Create floating window ---------------------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, inputElList: elements_selectionList, onOpen, onClose, }); // Toggling ------------------------------------------------------------ function onOpen(): void { updateEngineUIcontents(); element_enginegamebutton.classList.add('active'); initEngineGameUIListeners(); } function onClose(resetPositioning = false): void { if (resetPositioning) floatingWindow.resetPositioning(); element_enginegamebutton.classList.remove('active'); closeEngineGameUIListeners(); } // Enginegame-UI-specific listeners ------------------------------------------- function initEngineGameUIListeners(): void { elements_selectionList.forEach((el) => { el.addEventListener('blur', readEngineUIConfig); }); yesButton.addEventListener('click', onYesButtonPress); noButton.addEventListener('click', onNoButtonPress); document.addEventListener('keydown', onKeyDown); } function closeEngineGameUIListeners(): void { elements_selectionList.forEach((el) => { el.removeEventListener('blur', readEngineUIConfig); }); yesButton.removeEventListener('click', onYesButtonPress); noButton.removeEventListener('click', onNoButtonPress); document.removeEventListener('keydown', onKeyDown); } // Utilities ---------------------------------------------------------------------- function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Enter' && !(e.target instanceof HTMLInputElement && e.target.type === 'text')) onYesButtonPress(); else if (e.key === 'Escape') { // Ensure priority when deciding who gets the escape key event if (guipause.areWePaused()) return; listener_document.claimKey('Escape'); onNoButtonPress(); } } function onYesButtonPress(): void { const engineUIConfig = readEngineUIConfig(); eactions.startEngineGame(engineUIConfig); } function onNoButtonPress(): void { floatingWindow.close(false); } /** Updates the engineconfig UI values when opened */ function updateEngineUIcontents(): void { const existingBorder = gameslot.getGamefile()?.basegame.gameRules.worldBorder !== undefined; element_noborder.checked = existingBorder; element_yesborder.checked = !existingBorder; } /** Constructs the engineconfig by reading the input boxes, and validating them */ function readEngineUIConfig(): EngineUIConfig { // Player color const youAreColor = element_white.checked ? p.WHITE : p.BLACK; // Time control let timeControl: TimeControl = '-'; const timeControlRaw = element_timecontrol.value; if (timeControlRaw === '-' || timeControlRaw === '') { element_timecontrol.classList.remove('invalid-input'); } else if (timeControlRegex.test(timeControlRaw)) { const [a, b] = timeControlRaw.split('+').map(Number); if (a !== undefined && b !== undefined && Number.isFinite(a) && Number.isFinite(b)) { timeControl = `${a}+${b}`; element_timecontrol.classList.remove('invalid-input'); } else { element_timecontrol.classList.add('invalid-input'); } } else { element_timecontrol.classList.add('invalid-input'); } // Strength level const strengthLevel = element_hard.checked ? 3 : element_medium.checked ? 2 : 1; // Set default world border const setDefaultWorldBorder = element_yesborder.checked ? true : false; return { youAreColor, timeControl, strengthLevel, setDefaultWorldBorder }; } // Exports ----------------------------------------------------------------- export default { open: floatingWindow.open, close: floatingWindow.close, isOpen: floatingWindow.isOpen, }; export type { EngineUIConfig }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/guistartlocalgame.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/guistartlocalgame.ts /** * Manages the GUI popup window for the Start local game button of the Board Editor */ import eactions from '../../../boardeditor/actions/eactions'; import guipause from '../../guipause'; import guifloatingwindow from '../guifloatingwindow'; import { listener_document } from '../../../chess/game'; // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Start local game popup window. */ const element_localgamebutton = document.getElementById('start-local-game')!; /** The actual window of the Game Rules popup. */ const element_window = document.getElementById('local-game-UI')!; const element_header = document.getElementById('local-game-UI-header')!; const element_closeButton = document.getElementById('close-local-game-UI')!; const yesButton = document.getElementById('start-local-game-yes')!; const noButton = document.getElementById('start-local-game-no')!; // Create floating window ------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, onOpen, onClose, }); // Toggling --------------------------------------------- function onOpen(): void { element_localgamebutton.classList.add('active'); initLocalGameUIListeners(); } function onClose(resetPositioning: boolean): void { if (resetPositioning) floatingWindow.resetPositioning(); element_localgamebutton.classList.remove('active'); closeLocalGameUIListeners(); } // Gamerules-specific listeners ------------------------------------------- function initLocalGameUIListeners(): void { yesButton.addEventListener('click', onYesButtonPress); noButton.addEventListener('click', onNoButtonPress); document.addEventListener('keydown', onKeyDown); } function closeLocalGameUIListeners(): void { yesButton.removeEventListener('click', onYesButtonPress); noButton.removeEventListener('click', onNoButtonPress); document.removeEventListener('keydown', onKeyDown); } // Utilities--------------------------------------------------------------------- function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Enter') onYesButtonPress(); else if (e.key === 'Escape') { // Ensure priority when deciding who gets the escape key event if (guipause.areWePaused()) return; listener_document.claimKey('Escape'); onNoButtonPress(); } } function onYesButtonPress(): void { eactions.startLocalGame(); } function onNoButtonPress(): void { floatingWindow.close(false); } // Exports ----------------------------------------------------------------- export default { open: floatingWindow.open, close: floatingWindow.close, isOpen: floatingWindow.isOpen, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadposition.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadposition.ts /** * Manages the GUI popup window for the Load Positions UI of the board editor. * Coordinates the floating window, save-as form, confirmation modal, and position list. */ import editorutil from '../../../../../../../../shared/util/editorutil'; import esave from '../../../../boardeditor/actions/esave'; import boardeditor from '../../../../boardeditor/boardeditor'; import guifloatingwindow from '../../guifloatingwindow'; import guiloadpositionmodal from './guiloadpositionmodal'; import guiloadpositionsavelist from './guiloadpositionsavelist'; // Elements ---------------------------------------------------------- /** The button the toggles visibility of the Load Position game popup window. */ const element_loadbutton = document.getElementById('load-position')!; /** The actual window of the Load Positions popup. */ const element_window = document.getElementById('load-position-UI')!; const element_header = document.getElementById('load-position-UI-header')!; const element_headerText = document.getElementById('load-position-UI-header-text')!; const element_closeButton = document.getElementById('close-load-position-UI')!; /** The button the toggles visibility of the Save Position As popup window. */ const element_saveasbutton = document.getElementById('save-position-as')!; /** The container for entering a new position name. */ const element_enterPositionName = document.getElementById('enter-position-name')!; /** Textbox for entering position name */ const element_saveAsPositionName = document.getElementById( 'save-as-position-name', )! as HTMLInputElement; /** "Save" button in UI */ const element_saveCurrentPositionButton = document.getElementById('save-position-button')!; // Variables ---------------------------------------------------------------- /** The current open/close mode of the Load Position UI */ let mode: 'load' | 'save-as' | undefined = undefined; // Create floating window ------------------------------------- const floatingWindow = guifloatingwindow.create({ windowEl: element_window, headerEl: element_header, closeButtonEl: element_closeButton, onOpen, onClose, }); // Toggling ------------------------------------------------ function onOpen(): void { guiloadpositionsavelist.updateSavedPositionListUI(); } function openLoadPosition(): void { element_headerText.textContent = translations.editor.load_position_header; element_enterPositionName.classList.add('hidden'); element_loadbutton.classList.add('active'); element_saveasbutton.classList.remove('active'); floatingWindow.open(); mode = 'load'; } function openSavePositionAs(): void { element_headerText.textContent = translations.editor.save_position_as_header; element_enterPositionName.classList.remove('hidden'); element_saveasbutton.classList.add('active'); element_loadbutton.classList.remove('active'); floatingWindow.open(); mode = 'save-as'; initSavePositionUIListeners(); element_saveAsPositionName.focus(); } function onClose(resetPositioning = false): void { if (resetPositioning) floatingWindow.resetPositioning(); guiloadpositionmodal.closeModal(); element_loadbutton.classList.remove('active'); element_saveasbutton.classList.remove('active'); mode = undefined; guiloadpositionsavelist.unregisterAllPositionButtonListeners(); guiloadpositionsavelist.clearSavedPositionList(); closeSavePositionUIListeners(); element_saveAsPositionName.value = ''; } /** Gets the current open/close mode of the Load Position UI */ function getMode(): typeof mode { return mode; } // Save-as form listeners ------------------------------------------- function initSavePositionUIListeners(): void { element_saveCurrentPositionButton.addEventListener('click', onSaveButtonPress); document.addEventListener('keydown', onSaveKeyDown); } function closeSavePositionUIListeners(): void { element_saveCurrentPositionButton.removeEventListener('click', onSaveButtonPress); document.removeEventListener('keydown', onSaveKeyDown); } function onSaveKeyDown(e: KeyboardEvent): void { // Only trigger save on Enter when the confirmation modal is not open if (e.key === 'Enter' && !guiloadpositionmodal.isOpen()) onSaveButtonPress(); } // Save-as form functions ------------------------------------------- /** Gets executed when the "save" button is pressed. */ async function onSaveButtonPress(): Promise { const positionname = element_saveAsPositionName.value.trim(); // Disallow pure whitespace names if (positionname === '') return; if (positionname.length > editorutil.MAX_POSITION_NAME_LENGTH) { console.error( `This should not happen, position name input box is restricted to ${editorutil.MAX_POSITION_NAME_LENGTH} chars, you submitted ${positionname.length} chars.`, ); return; } // If a local save already exists, ask to overwrite it locally if (await esave.localSaveExists(positionname)) { guiloadpositionmodal.openModal('overwrite_save', positionname, async () => { await esave.saveLocal(positionname); boardeditor.setActivePosition({ name: positionname, storage_type: 'local' }); guiloadpositionsavelist.updateSavedPositionListUI(); }); return; } // No existing save found — save locally await esave.saveLocal(positionname); boardeditor.setActivePosition({ name: positionname, storage_type: 'local' }); element_saveAsPositionName.value = ''; guiloadpositionsavelist.updateSavedPositionListUI(); } // Exports ----------------------------------------------------------------- export default { openLoadPosition, openSavePositionAs, close: floatingWindow.close, getMode, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionmodal.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionmodal.ts /** * Manages the confirmation dialog modal for the Load Position UI of the board editor. * Accepts a generic onConfirm callback so it stays decoupled from the list and save-form modules. */ import guipause from '../../../guipause'; import { listener_document } from '../../../../chess/game'; // Types ------------------------------------------------------------------------- /** Different modes for the modal confirmation dialog */ export type ModalMode = 'load' | 'delete' | 'overwrite_save'; /** Type for current config of the confirmation dialog modal */ type ModalConfig = { mode: ModalMode; position_name: string; onConfirm: () => Promise | void; }; // Elements ---------------------------------------------------------- /** Confirmation dialog modal elements */ const element_modal = document.getElementById('load-position-modal-overlay')!; const element_modalCloseButton = document.getElementById('close-load-position-modal')!; const element_modalTitle = document.getElementById('load-position-modal-title')!; const element_modalMessage = document.getElementById('load-position-modal-message')!; const element_modalNoButton = document.getElementById('load-position-modal-no')!; const element_modalYesButton = document.getElementById('load-position-modal-yes')!; // Variables ---------------------------------------------------------------- /** The current config of the Confirmation dialog modal */ let modal_config: ModalConfig | undefined = undefined; // Functions ----------------------------------------------------------------- /** * Open the confirmation modal with the given mode and callback. * @param onConfirm Called when the user presses the "Yes" button. */ function openModal( mode: ModalMode, position_name: string, onConfirm: () => Promise | void, ): void { modal_config = { mode, position_name, onConfirm }; if (modal_config.mode === 'delete') { element_modalTitle.textContent = translations.editor.delete_title; element_modalMessage.textContent = translations.editor.delete_message[0] + position_name + translations.editor.delete_message[1]; } else if (modal_config.mode === 'load') { element_modalTitle.textContent = translations.editor.load_title; element_modalMessage.textContent = translations.editor.load_message[0] + position_name + translations.editor.load_message[1]; } else if (modal_config.mode === 'overwrite_save') { element_modalTitle.textContent = translations.editor.overwrite_title; element_modalMessage.textContent = translations.editor.overwrite_message[0] + position_name + translations.editor.overwrite_message[1]; } element_modal.classList.remove('hidden'); // Blur the triggering button so that when the modal closes via keyboard (Escape/Enter), // focus doesn't snap back to it and show an unwanted blue outline. (document.activeElement as HTMLElement)?.blur(); initModalListeners(); } function closeModal(): void { modal_config = undefined; element_modal.classList.add('hidden'); closeModalListeners(); } // Listeners ------------------------------------------- function initModalListeners(): void { element_modalCloseButton.addEventListener('click', closeModal); element_modalNoButton.addEventListener('click', closeModal); element_modalYesButton.addEventListener('click', onModalYesButtonPress); document.addEventListener('keydown', onModalKeyDown); } function closeModalListeners(): void { element_modalCloseButton.removeEventListener('click', closeModal); element_modalNoButton.removeEventListener('click', closeModal); element_modalYesButton.removeEventListener('click', onModalYesButtonPress); document.removeEventListener('keydown', onModalKeyDown); } function onModalKeyDown(e: KeyboardEvent): void { if (e.key === 'Enter') { e.preventDefault(); // Prevent browser from firing a synthetic click on the focused "Save" button onModalYesButtonPress(); } else if (e.key === 'Escape') { // Ensure priority when deciding who gets the escape key event if (guipause.areWePaused()) return; listener_document.claimKey('Escape'); closeModal(); } } function onModalYesButtonPress(): void { if (modal_config === undefined) { closeModal(); return; } const { onConfirm } = modal_config; // Pull callback before clearing state closeModal(); // Close modal immediately to clear UI onConfirm(); } /** Returns true if the confirmation modal is currently open. */ function isOpen(): boolean { return modal_config !== undefined; } // Exports ----------------------------------------------------------------- export default { openModal, closeModal, isOpen, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionsavelist.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/actions/loadposition/guiloadpositionsavelist.ts /** * Manages the saved-positions list for the Load Position UI of the board editor: * rendering position rows, performing load/delete/cloud-transfer operations, * and refreshing the list from local and cloud storage. */ import type { StorageType } from '../../../../boardeditor/boardeditor'; import type { CloudSaveListRecord } from '../../../../boardeditor/actions/editorSavesAPI'; import type { EditorAbridgedSaveState } from '../../../../boardeditor/editortypes'; import esave from '../../../../boardeditor/actions/esave'; import style from '../../../style'; import ecloud from '../../../../boardeditor/actions/ecloud'; import eactions from '../../../../boardeditor/actions/eactions'; import boardeditor from '../../../../boardeditor/boardeditor'; import { GameBus } from '../../../../GameBus'; import validatorama from '../../../../../util/validatorama'; import guiloadposition from './guiloadposition'; import guiloadpositionmodal from './guiloadpositionmodal'; // Types ------------------------------------------------------------------------- /** Object to keep track of listener for position button */ type ButtonHandlerPair = { type: 'click'; handler: (e: MouseEvent) => void; }; /** A unified save entry for display, regardless of whether it's stored locally or on the cloud */ type UnifiedSave = { storage_type: StorageType } & EditorAbridgedSaveState; /** Cloud saves list returned by a mutation, used to skip a follow-up GET */ type PreloadedCloudSaves = CloudSaveListRecord[] | undefined; // Elements ---------------------------------------------------------- /** The outer container for the saved positions section. */ const element_savedPositions = document.querySelector('.saved-positions')!; /** List of saved positions */ const element_savedPositionsToLoad = document.getElementById('saved-position-list')!; /** Empty-state message shown when there are no saves */ const element_noSavesMessage = document.getElementById('saved-position-list-empty')!; /** Spinny pawn loading animation shown during in-flight API requests */ const element_loadingPawn = document.getElementById('load-position-loading-pawn')!; // Variables ---------------------------------------------------------------- /** Object to keep track of all position button listeners */ const registeredButtonListeners = new Map(); /** * A counter for tracking new position loads. Cloud load position * requests are discarded if this is different when they return. */ let load_counter = 0; /** Count of in-flight API requests — spinner is visible whenever this is > 0 */ let activeRequestCount = 0; // Load Counter ---------------------------------------------------------- GameBus.addEventListener('game-loaded', () => { load_counter++; // console.log('Incremented positionLoadEpoch'); }); // Loading animation ----------------------------------------------- /** * Runs an async API call while showing the loading spinner, hiding it when done. * @param fn The async function that performs the API call. All errors should be caught internally, this wrapper does not catch errors! */ async function withRequest(fn: () => Promise): Promise { activeRequestCount++; element_loadingPawn.classList.remove('hidden'); const result = await fn(); activeRequestCount = Math.max(0, activeRequestCount - 1); if (activeRequestCount === 0) element_loadingPawn.classList.add('hidden'); return result; } // Utilities---------------------------------------------------------------- function registerButtonClick(button: HTMLButtonElement, handler: (e: MouseEvent) => void): void { button.addEventListener('click', handler); registeredButtonListeners.set(button, { type: 'click', handler }); } function unregisterAllPositionButtonListeners(): void { for (const [button, { type, handler }] of registeredButtonListeners) { button.removeEventListener(type, handler); } registeredButtonListeners.clear(); } /** Removes all rendered rows from the saved-position list. */ function clearSavedPositionList(): void { element_savedPositionsToLoad.replaceChildren(); } // Operations --------------------------------------------------------------- /** Performs the actual load operation for a saved position, bypassing the modal. */ async function performLoad(position_name: string, storage_type: StorageType): Promise { const initialLoadCount = load_counter; const editorSaveState = storage_type === 'cloud' ? await withRequest(() => ecloud.readCloud(position_name)) : await esave.readLocal(position_name); // If the load count changed while the request was in-flight, the user already // loaded a different position — discard this stale result. if (load_counter !== initialLoadCount) { console.log(`Discarding cloud load result`); return; } if (editorSaveState !== undefined) { // Pass false to skip resetting the window's position on screen guiloadposition.close(false); await eactions.load(editorSaveState, storage_type); } } /** * Handles pressing the cloud-save button for a position row. * - If local: uploads to server and deletes local copy. * - If cloud: downloads from server, deletes from server, and saves locally. */ async function onCloudButtonPress( position_name: string, storage_type: StorageType, cloudBtn: HTMLButtonElement, ): Promise { // Disable cloud button to prevent multiple clicks while operation is in-flight cloudBtn.disabled = true; const preloadedCloudSaves = await withRequest(() => storage_type === 'local' ? ecloud.transferPositionToCloud(position_name) : ecloud.removePositionFromCloud(position_name), ); // Re-enable cloud button regardless of success or failure cloudBtn.disabled = false; updateSavedPositionListUI(preloadedCloudSaves); } /** Performs the actual delete operation for a saved position, bypassing the modal. */ async function performDelete(position_name: string, storage_type: StorageType): Promise { let preloadedCloudSaves: PreloadedCloudSaves; if (storage_type === 'cloud') { preloadedCloudSaves = await withRequest(() => ecloud.deleteCloud(position_name)); } else { await esave.deleteLocal(position_name); } // Clear active position name if the deleted position was active if (boardeditor.isActivePosition(position_name, storage_type)) boardeditor.clearActivePosition(); updateSavedPositionListUI(preloadedCloudSaves); } // Row generation --------------------------------------------------------------- /** * Update the saved positions list. * @param preloadedCloudSaves If provided, skips the cloud GET request and uses this data directly. */ async function updateSavedPositionListUI(preloadedCloudSaves?: PreloadedCloudSaves): Promise { const areLoggedIn = validatorama.areWeLoggedIn(); // Toggle CSS class to adjust header column widths for cloud button element_savedPositions.classList.toggle('with-cloud', areLoggedIn); // Build unified list (local + cloud) const allSaves: UnifiedSave[] = []; // Fetch cloud saves if logged in if (areLoggedIn) { const cloudSaves: CloudSaveListRecord[] = preloadedCloudSaves ?? (await withRequest(() => ecloud.getAllCloudSaveInfos())); cloudSaves.forEach((save) => { allSaves.push({ storage_type: 'cloud', position_name: save.name, timestamp: save.timestamp, piece_count: save.piece_count, }); }); } // Load all local saves const localSaveList = await esave.getAllLocalSaveInfos(); // Add local saves for (const abridged of localSaveList) { allSaves.push({ storage_type: 'local', ...abridged }); } // Sort by timestamp (newest first) allSaves.sort((a, b) => b.timestamp - a.timestamp); // All data is ready — unregister old listeners, generate new rows, then swap in atomically unregisterAllPositionButtonListeners(); // If there are no saves, show the "No saved positions." message; otherwise hide it const isEmpty = allSaves.length === 0; element_noSavesMessage.classList.toggle('hidden', !isEmpty); const newRows = allSaves.map((save) => generateRowForSavedPositionsElement(save, areLoggedIn)); element_savedPositionsToLoad.replaceChildren(element_noSavesMessage, ...newRows); } /** * Given a UnifiedSave entry, * generate a row for the list of saved positions. * A "row" has the following DOM structure: * *
*
POSITION_NAME
*
PIECE_COUNT
*
DATE
* * * * * * *
*/ function generateRowForSavedPositionsElement( save: UnifiedSave, showCloudButton: boolean, ): HTMLDivElement { const row = document.createElement('div'); row.classList.add('saved-position'); // Name const name_cell = document.createElement('div'); const position_name = save.position_name ?? ''; name_cell.textContent = position_name; name_cell.title = position_name; // Let browser's automatic tooltips show the full title on hover, if it's truncated via ellipsis row.appendChild(name_cell); // Piececount const piececount_cell = document.createElement('div'); piececount_cell.classList.add('piece-count'); const piece_count = String(save.piece_count); piececount_cell.textContent = piece_count; piececount_cell.title = piece_count; row.appendChild(piececount_cell); // Date const date_cell = document.createElement('div'); date_cell.classList.add('date'); const timestamp = save.timestamp; // Localize the date display to the user's locale const dateObj = new Date(timestamp); const localeDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', }); date_cell.textContent = localeDate; row.appendChild(date_cell); // Buttons // "Load" button const loadBtn = createButtonElement('#svg-load'); loadBtn.classList.add('tooltip-d'); loadBtn.dataset['tooltip'] = translations.editor.tooltip_load_position; registerButtonClick(loadBtn, () => { // Skip confirmation modal if the position has no unsaved changes if (!boardeditor.isPositionDirty()) { performLoad(position_name, save.storage_type); } else { guiloadpositionmodal.openModal('load', position_name, () => performLoad(position_name, save.storage_type), ); } }); row.appendChild(loadBtn); // "Cloud Save" button (only when logged in) if (showCloudButton) { const cloudBtn = createButtonElement('#svg-cloud-save'); cloudBtn.classList.add('cloud-save'); cloudBtn.classList.add('tooltip-d'); if (save.storage_type === 'local') { // Local save: greyed-out cloud button (not yet on cloud) cloudBtn.classList.add('local'); cloudBtn.dataset['tooltip'] = translations.editor.tooltip_save_to_cloud; } else { cloudBtn.dataset['tooltip'] = translations.editor.tooltip_remove_from_cloud; } registerButtonClick(cloudBtn, () => onCloudButtonPress(position_name, save.storage_type, cloudBtn), ); row.appendChild(cloudBtn); } // "Delete" button const deleteBtn = createButtonElement('#svg-delete'); deleteBtn.classList.add('tooltip-d'); deleteBtn.dataset['tooltip'] = translations.editor.tooltip_delete_position; registerButtonClick(deleteBtn, () => guiloadpositionmodal.openModal('delete', position_name, () => performDelete(position_name, save.storage_type), ), ); row.appendChild(deleteBtn); // Highlight row if position is active if (boardeditor.isActivePosition(position_name, save.storage_type)) row.classList.add('active-position'); return row; } /** Create a button element for one position row, with given SVG href. */ function createButtonElement(svgHref: string): HTMLButtonElement { const button = document.createElement('button'); const svg = document.createElementNS(style.SVG_NS, 'svg'); const use = document.createElementNS(style.SVG_NS, 'use'); use.setAttribute('href', svgHref); svg.appendChild(use); button.appendChild(svg); button.classList.add('btn'); button.classList.add('saved-position-btn'); return button; } // Exports ----------------------------------------------------------------- export default { registerButtonClick, unregisterAllPositionButtonListeners, clearSavedPositionList, updateSavedPositionListUI, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/guiboardeditor.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/guiboardeditor.ts /** * Manages the board editor GUI lifecycle: opening, closing, * the sidebar menu toggle, and dispatching action button events. */ import esave from '../../boardeditor/actions/esave.js'; import ecloud from '../../boardeditor/actions/ecloud.js'; import gameslot from '../../chess/gameslot.js'; import eactions from '../../boardeditor/actions/eactions.js'; import eautosave from '../../boardeditor/actions/eautosave.js'; import gameloader from '../../chess/gameloader.js'; import guitoolbar from './guitoolbar.js'; import guipalette from './guipalette.js'; import boardeditor from '../../boardeditor/boardeditor.js'; import guigamerules from './actions/guigamerules.js'; import selectiontool from '../../boardeditor/tools/selection/selectiontool.js'; import guiloadposition from './actions/loadposition/guiloadposition.js'; import stransformations from '../../boardeditor/tools/selection/stransformations.js'; import guiresetposition from './actions/guiresetposition.js'; import guiclearposition from './actions/guiclearposition.js'; import guistartlocalgame from './actions/guistartlocalgame.js'; import guistartenginegame from './actions/guistartenginegame.js'; import guiloadpositionsavelist from './actions/loadposition/guiloadpositionsavelist.js'; // Elements --------------------------------------------------------------- const element_menu = document.getElementById('editor-menu')!; const element_menuToggle = document.getElementById('editor-menu-toggle')!; const elements_actions = [ // Position document.getElementById('reset')!, document.getElementById('clearall')!, document.getElementById('load-position')!, document.getElementById('save-position-as')!, document.getElementById('save-position')!, document.getElementById('copy-notation')!, document.getElementById('paste-notation')!, document.getElementById('gamerules')!, document.getElementById('start-local-game')!, document.getElementById('start-engine-game')!, // Selection document.getElementById('select-all')!, document.getElementById('delete-selection')!, document.getElementById('copy-selection')!, document.getElementById('paste-selection')!, document.getElementById('invert-color')!, document.getElementById('rotate-left')!, document.getElementById('rotate-right')!, document.getElementById('flip-horizontal')!, document.getElementById('flip-vertical')!, // Palette document.getElementById('editor-color-select')!, ]; // State ------------------------------------------------------------------- /** Whether the board editor UI is open. */ let boardEditorOpen = false; // Functions --------------------------------------------------------------- /** * Open the board editor GUI */ async function open(): Promise { boardEditorOpen = true; element_menu.classList.remove('hidden'); window.dispatchEvent(new CustomEvent('resize')); // the screen and canvas get effectively resized when the vertical board editor bar is toggled // Try to read in autosave and initialize board editor // If there is no autosave, initialize board editor with Classical position const autoSaveState = await eautosave.loadAutosave(); if (autoSaveState === undefined) { boardeditor.clearActivePosition(); await gameloader.startBoardEditor(); } else { if (autoSaveState.active_position !== undefined) boardeditor.setActivePosition(autoSaveState.active_position); else boardeditor.clearActivePosition(); await gameloader.startBoardEditorFromCustomPosition( { additional: { variantOptions: autoSaveState.variantOptions, }, }, autoSaveState.dirty, autoSaveState.pawnDoublePush, autoSaveState.castling, ); } initListeners(); } /** Whether the board editor UI is open. */ function isOpen(): boolean { return boardEditorOpen; } function close(): void { if (!boardEditorOpen) return; closeAllFloatingWindows(true); element_menu.classList.remove('expanded'); element_menu.classList.add('hidden'); window.dispatchEvent(new CustomEvent('resize')); // The screen and canvas get effectively resized when the vertical board editor bar is toggled closeListeners(); boardEditorOpen = false; } function initListeners(): void { element_menuToggle.addEventListener('click', callback_ToggleMenu); elements_actions.forEach((element) => { element.addEventListener('click', callback_Action); }); guitoolbar.initListeners(); guipalette.initListeners(); } function closeListeners(): void { element_menuToggle.removeEventListener('click', callback_ToggleMenu); elements_actions.forEach((element) => { element.removeEventListener('click', callback_Action); }); guitoolbar.closeListeners(); guipalette.closeListeners(); } /** Close and reset the positioning and contents of all floating windows */ function closeAllFloatingWindows(resetPositioning: boolean): void { guiresetposition.close(resetPositioning); guiclearposition.close(resetPositioning); guiloadposition.close(resetPositioning); guigamerules.close(resetPositioning); guistartlocalgame.close(resetPositioning); guistartenginegame.close(resetPositioning); } // Callbacks --------------------------------------------------------------- function callback_ToggleMenu(): void { setSidebarExpanded(!element_menu.classList.contains('expanded')); } /** * Sets the sidebar expanded/collapsed state, correctly updating all related elements: * the `expanded` class on the menu, the tooltip text, and the tooltip direction classes. */ function setSidebarExpanded(expanded: boolean): void { element_menu.classList.toggle('expanded', expanded); element_menuToggle.setAttribute( 'data-tooltip', expanded ? translations.editor.collapse_sidebar : translations.editor.expand_sidebar, ); element_menuToggle.classList.toggle('tooltip-dr', !expanded); element_menuToggle.classList.toggle('tooltip-d', expanded); } function callback_Action(e: Event): void { const target = e.currentTarget as HTMLElement; const action = target.getAttribute('data-action'); // Position/Palette actions... switch (action) { // Position --------------------- case 'reset': { const wasOpen = guiresetposition.isOpen(); closeAllFloatingWindows(false); // Skip confirmation dialog if there are no unsaved changes if (!boardeditor.isPositionDirty()) eactions.reset(); else if (!wasOpen) guiresetposition.open(); return; } case 'clearall': { const wasOpen = guiclearposition.isOpen(); closeAllFloatingWindows(false); // Skip confirmation dialog if there are no unsaved changes if (!boardeditor.isPositionDirty()) eactions.clearAll(); else if (!wasOpen) guiclearposition.open(); return; } case 'load-position': { const wasOpen = guiloadposition.getMode() !== 'load'; closeAllFloatingWindows(false); if (wasOpen) guiloadposition.openLoadPosition(); return; } case 'save-position-as': { const wasOpen = guiloadposition.getMode() !== 'save-as'; closeAllFloatingWindows(false); if (wasOpen) guiloadposition.openSavePositionAs(); return; } case 'save-position': { const active_position = boardeditor.getActivePosition(); if (active_position === undefined) { // If there is no active position name, treat this the same way as "Save as" if that window is not open const wasOpen = guiloadposition.getMode() !== 'save-as'; if (wasOpen) { closeAllFloatingWindows(false); guiloadposition.openSavePositionAs(); } } else { // If there is an active position name, simply overwrite save if (active_position.storage_type === 'cloud') { // If it's a cloud save, upload to cloud (which will overwrite) ecloud.saveCloud(active_position.name); } else { // If it's a local save, simply overwrite in IndexedDB esave.saveLocal(active_position.name); } // Update UI if necessary if (guiloadposition.getMode() !== undefined) guiloadpositionsavelist.updateSavedPositionListUI(); } return; } case 'copy-notation': eactions.copy(); return; case 'paste-notation': eactions.paste(); return; case 'gamerules': { const wasOpen = guigamerules.isOpen(); closeAllFloatingWindows(false); if (!wasOpen) guigamerules.open(); return; } case 'start-local-game': { const wasOpen = guistartlocalgame.isOpen(); closeAllFloatingWindows(false); if (!wasOpen) guistartlocalgame.open(); return; } case 'start-engine-game': { const wasOpen = guistartenginegame.isOpen(); closeAllFloatingWindows(false); if (!wasOpen) guistartenginegame.open(); return; } // Selection (buttons that are always active) case 'select-all': selectiontool.selectAll(); return; // Palette --------------------- case 'color': guipalette.nextColor(); return; } // Selection actions... const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh()!; const selectionBox = selectiontool.getSelectionIntBox(); if (!selectionBox) return; // Might have clicked action button when there was no selection. switch (action) { case 'delete-selection': stransformations.Delete(gamefile, mesh, selectionBox); break; case 'copy-selection': stransformations.Copy(gamefile, selectionBox); break; case 'paste-selection': stransformations.Paste(gamefile, mesh, selectionBox); break; case 'invert-color': stransformations.InvertColor(gamefile, mesh, selectionBox); break; case 'rotate-left': stransformations.RotateLeft(gamefile, mesh, selectionBox); break; case 'rotate-right': stransformations.RotateRight(gamefile, mesh, selectionBox); break; case 'flip-horizontal': stransformations.FlipHorizontal(gamefile, mesh, selectionBox); break; case 'flip-vertical': stransformations.FlipVertical(gamefile, mesh, selectionBox); break; default: console.error(`Unknown action: ${action}`); } } // Exports ---------------------------------------------------------------- export default { open, isOpen, close, setSidebarExpanded, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/guifloatingwindow.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/guifloatingwindow.ts /** * Handles reusable floating window behavior in the board editor: * - open/close/toggle * - draggable by header (mouse + touch) * - clamped to a parent container * - remembers last position while open */ import math from '../../../../../../shared/util/math/math'; import guiboardeditor from './guiboardeditor.js'; // Elements ---------------------------------------------------------- const element_boardUI = document.getElementById('boardUI')!; const element_menu = document.getElementById('editor-menu')!; const element_menuToggle = document.getElementById('editor-menu-toggle')!; // Constants ----------------------------------------------------------- /** * The viewport width (px) below which the sidebar switches to overlay/collapsible mode. * MUST MATCH the CSS @media max-width value in play.css — CSS variables cannot * be used in media queries, so this value must be kept in sync manually. */ const NARROW_THRESHOLD = 727; // Types ------------------------------------------------------------- /** Functions that handle all floating window behavior */ interface FloatingWindowHandle { open: () => void; close: (resetPositioning: boolean) => void; resetPositioning: () => void; clampToParentBounds: () => void; isOpen: () => boolean; } /** Options for initializing a floating window in the board editor */ interface FloatingWindowOptions { /** Floating window element */ windowEl: HTMLElement; /** Header element of floating window */ headerEl: HTMLElement; /** Close button inside the floating window */ closeButtonEl: HTMLElement; /** Optional list of input elements in floating window. They will get deselected on any click outside the floating window */ inputElList?: HTMLInputElement[]; /** Called after the floating window opens (use for window-specific listeners) */ onOpen: () => void; /** Called after the floating window closes (use for window-specific listener cleanup) */ onClose: (resetPositioning: boolean) => void; } // Utilities ------------------------------------------------------------- /** Create the functions needed for the handling of a floating window in the board editor */ function create(opts: FloatingWindowOptions): FloatingWindowHandle { const { windowEl, headerEl, closeButtonEl, inputElList, onOpen, onClose } = opts; // Window Position & Dragging State let offsetX = 0; let offsetY = 0; let isDragging = false; let savedPos: { left: number; top: number } | undefined; function clampToParentBounds(): void { const parentRect = element_boardUI.getBoundingClientRect(); const elWidth = windowEl.offsetWidth; const elHeight = windowEl.offsetHeight; const newLeft = math.clamp(windowEl.offsetLeft, 0, parentRect.width - elWidth); const newTop = math.clamp(windowEl.offsetTop, 0, parentRect.height - elHeight); windowEl.style.left = `${newLeft}px`; windowEl.style.top = `${newTop}px`; savedPos = { left: newLeft, top: newTop }; } // --- Dragging --- function startDrag(clientX: number, clientY: number): void { isDragging = true; offsetX = clientX - windowEl.offsetLeft; offsetY = clientY - windowEl.offsetTop; document.body.style.userSelect = 'none'; } function startMouseDrag(e: MouseEvent): void { startDrag(e.clientX, e.clientY); } function startTouchDrag(e: TouchEvent): void { if (e.touches.length === 1) { const touch = e.touches[0]!; startDrag(touch.clientX, touch.clientY); } } function stopDrag(): void { if (isDragging) clampToParentBounds(); isDragging = false; document.body.style.userSelect = 'auto'; } function duringDrag(clientX: number, clientY: number): void { if (!isDragging) return; const parentRect = element_boardUI.getBoundingClientRect(); const elWidth = windowEl.offsetWidth; const elHeight = windowEl.offsetHeight; const newLeft = clientX - offsetX; const newTop = clientY - offsetY; const clampedLeft = math.clamp(newLeft, 0, parentRect.width - elWidth); const clampedTop = math.clamp(newTop, 0, parentRect.height - elHeight); windowEl.style.left = `${clampedLeft}px`; windowEl.style.top = `${clampedTop}px`; savedPos = { left: clampedLeft, top: clampedTop }; } function duringMouseDrag(e: MouseEvent): void { duringDrag(e.clientX, e.clientY); } function duringTouchDrag(e: TouchEvent): void { if (e.touches.length === 1) { if (isDragging) e.preventDefault(); // prevent scrolling const touch = e.touches[0]!; duringDrag(touch.clientX, touch.clientY); } } /** Deselects input boxes when pressing Enter */ function blurOnEnter(e: KeyboardEvent): void { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } } /** Deselects input boxes when clicking somewhere outside the floating window */ function blurOnClickorTouchOutside(e: MouseEvent | TouchEvent): void { if (inputElList === undefined) return; if (!windowEl.contains(e.target as Node)) { const activeEl = document.activeElement as HTMLInputElement; if (activeEl && inputElList.includes(activeEl) && activeEl.tagName === 'INPUT') { activeEl.blur(); } } } /** Initialize general floating window listeners */ function initBaseListeners(): void { headerEl.addEventListener('mousedown', startMouseDrag); document.addEventListener('mousemove', duringMouseDrag); document.addEventListener('mouseup', stopDrag); headerEl.addEventListener('touchstart', startTouchDrag, { passive: false }); document.addEventListener('touchmove', duringTouchDrag, { passive: false }); document.addEventListener('touchend', stopDrag, { passive: false }); window.addEventListener('resize', clampToParentBounds); if (closeButtonEl) closeButtonEl.addEventListener('click', callbackClose); if (inputElList) { inputElList.forEach((el) => { if (el.type === 'text') el.addEventListener('keydown', blurOnEnter); }); document.addEventListener('click', blurOnClickorTouchOutside); document.addEventListener('touchstart', blurOnClickorTouchOutside); } } /** Close general floating window listeners */ function removeBaseListeners(): void { headerEl.removeEventListener('mousedown', startMouseDrag); document.removeEventListener('mousemove', duringMouseDrag); document.removeEventListener('mouseup', stopDrag); headerEl.removeEventListener('touchstart', startTouchDrag); document.removeEventListener('touchmove', duringTouchDrag); document.removeEventListener('touchend', stopDrag); window.removeEventListener('resize', clampToParentBounds); if (closeButtonEl) closeButtonEl.removeEventListener('click', callbackClose); if (inputElList) { inputElList.forEach((el) => { if (el.type === 'text') el.removeEventListener('keydown', blurOnEnter); }); document.removeEventListener('click', blurOnClickorTouchOutside); document.removeEventListener('touchstart', blurOnClickorTouchOutside); } } function isOpen(): boolean { return !windowEl.classList.contains('hidden'); } function callbackClose(): void { close(false); } function close(resetPositioning: boolean): void { windowEl.classList.add('hidden'); onClose(resetPositioning); removeBaseListeners(); } function resetPositioning(): void { windowEl.style.left = ''; windowEl.style.top = ''; savedPos = undefined; } /** * Returns the rendered width of the window. * If currently hidden, temporarily makes it invisible-but-laid-out to measure it. */ function measureWidth(): number { if (!windowEl.classList.contains('hidden')) return windowEl.offsetWidth; windowEl.style.visibility = 'hidden'; windowEl.classList.remove('hidden'); const w = windowEl.offsetWidth; windowEl.classList.add('hidden'); windowEl.style.visibility = ''; return w; } /** * On narrow screens, computes the initial position for the floating window, placing it * to the right of the sidebar tab. Collapses the sidebar first if the window would not fit * alongside it. Returns undefined on wide screens (no special positioning needed). */ function computeNarrowInitialPos(): { left: number; top: number } | undefined { if (window.innerWidth > NARROW_THRESHOLD) return undefined; const winWidth = measureWidth(); const topPx = Math.round(element_boardUI.offsetHeight * 0.11); const sidebarWidth = element_menu.offsetWidth; const tabWidth = element_menuToggle.offsetWidth; const expandedRightEdge = sidebarWidth + tabWidth; if ( element_menu.classList.contains('expanded') && expandedRightEdge + winWidth <= window.innerWidth ) { // Sidebar is open and the window fits alongside it return { left: expandedRightEdge, top: topPx }; } else { // Place window right of the collapsed tab return { left: tabWidth, top: topPx }; } } /** * Opens the floating window, smartly positioning it if this is the first opening, * and potentially collapsing the sidebar in order for the window to be visible. */ function open(): void { let effectiveLeft: number | undefined; if (savedPos !== undefined) { // Restore previous drag position windowEl.style.left = `${savedPos.left}px`; windowEl.style.top = `${savedPos.top}px`; effectiveLeft = savedPos.left; } else { // No saved drag position - compute smart initial position const initialPos = computeNarrowInitialPos(); if (initialPos !== undefined) { windowEl.style.left = `${initialPos.left}px`; windowEl.style.top = `${initialPos.top}px`; effectiveLeft = initialPos.left; } } // On narrow screens, collapse the sidebar if it would overlap the window if ( window.innerWidth <= NARROW_THRESHOLD && effectiveLeft !== undefined && effectiveLeft < element_menu.offsetWidth + element_menuToggle.offsetWidth ) { guiboardeditor.setSidebarExpanded(false); } // Open the window windowEl.classList.remove('hidden'); // Ensure it’s inside bounds on open (and after becoming visible) clampToParentBounds(); initBaseListeners(); onOpen?.(); } return { open, close, resetPositioning, clampToParentBounds, isOpen, }; } export default { create, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/guipalette.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/guipalette.ts /** * Manages the piece palette in the board editor GUI. * Handles palette initialization, piece/color selection, and palette listener wiring. */ import type { Player } from '../../../../../../shared/chess/util/typeutil.js'; import icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import typeutil, { rawTypes as r, players as p, } from '../../../../../../shared/chess/util/typeutil.js'; import svgcache from '../../../chess/rendering/svgcache.js'; import gameslot from '../../chess/gameslot.js'; import drawingtool from '../../boardeditor/tools/drawingtool.js'; import etoolmanager from '../../boardeditor/tools/etoolmanager.js'; // Elements --------------------------------------------------------------- const element_typesContainer = document.getElementById('editor-pieceTypes')!; const element_neutralTypesContainer = document.getElementById('editor-neutralTypes')!; const element_colorSelect = document.getElementById('editor-color-select')!; /** A map of each player's element container containing their colored pieces in the Palette. */ const element_playerContainers: Map = new Map(); const element_playerTypes: Map> = new Map(); const element_neutralTypes: Array = []; // Constants ----------------------------------------------------------- /** Player pieces in the order they will appear */ const coloredTypes = [ r.KING, r.QUEEN, r.ROOK, r.BISHOP, r.KNIGHT, r.PAWN, r.CHANCELLOR, r.ARCHBISHOP, r.AMAZON, r.GUARD, r.CENTAUR, r.HAWK, r.KNIGHTRIDER, r.HUYGEN, r.ROSE, r.CAMEL, r.GIRAFFE, r.ZEBRA, r.ROYALCENTAUR, r.ROYALQUEEN, ]; /** Neutral pieces in the order they will appear (except void, which is included manually in initUI by default) */ const neutralTypes = [r.OBSTACLE]; // State ------------------------------------------------------------------- /** * Whether the UI has been initialized and all piece svgs appended to the editor menu. * Only needs to be done once. */ let initialized = false; // Functions --------------------------------------------------------------- /** * Initializes the palette UI, appending piece SVGs to the editor menu. * Only runs once; subsequent calls are no-ops. */ async function initUI(): Promise { if (initialized) return; const uniquePlayers = _getPlayersInOrder(); // Colored pieces for (const player of uniquePlayers) { const svgs = await svgcache.getSVGElements( coloredTypes.map((rawType) => { return typeutil.buildType(rawType, player); }), ); const playerPieces = document.createElement('div'); element_playerContainers.set(player, playerPieces); element_playerTypes.set(player, svgs); playerPieces.classList.add('editor-types'); if (player !== drawingtool.getColor()) playerPieces.classList.add('hidden'); // Tooltips (i.e. "Amazon (AM)") for (let i = 0; i < svgs.length; i++) { const svg = svgs[i]!; svg.classList.add('piece'); const pieceContainer = document.createElement('div'); if (i % 4 === 0) pieceContainer.classList.add('tooltip-ur'); else pieceContainer.classList.add('tooltip-u'); const localized_piece_name = // @ts-ignore translations.piecenames[typeutil.getRawTypeStr(coloredTypes[i]!)!]; const piece_abbreviation = icnconverter.piece_codes_raw[coloredTypes[i]!]; const modified_piece_abbreviation = player === p.WHITE ? piece_abbreviation.toUpperCase() : piece_abbreviation.toLowerCase(); pieceContainer.setAttribute( 'data-tooltip', `${localized_piece_name} (${modified_piece_abbreviation})`, ); pieceContainer.appendChild(svg); playerPieces.appendChild(pieceContainer); } element_typesContainer.appendChild(playerPieces); } // Neutral pieces const neutral_svgs = await svgcache.getSVGElements( neutralTypes.map((rawType) => { return typeutil.buildType(rawType, p.NEUTRAL); }), ); const neutralPieces = document.createElement('div'); neutralPieces.classList.add('editor-types'); const element_void = document.createElement('div'); element_void.classList.add('piece'); element_void.classList.add('void'); element_void.id = '0'; // Void tooltip element_void.classList.add('tooltip-ur'); // @ts-ignore const localized_void_name = translations.piecenames[typeutil.getRawTypeStr(r.VOID)!]; const void_abbreviation = icnconverter.piece_codes_raw[r.VOID]; element_void.setAttribute('data-tooltip', `${localized_void_name} (${void_abbreviation})`); element_neutralTypes.push(element_void); neutralPieces.appendChild(element_void); for (let i = 0; i < neutral_svgs.length; i++) { const neutral_svg = neutral_svgs[i]!; neutral_svg.classList.add('piece'); const pieceContainer = document.createElement('div'); // Neutral piece tooltips if (i % 4 === 3) pieceContainer.classList.add('tooltip-ur'); else if (i % 4 === 2) pieceContainer.classList.add('tooltip-ul'); else pieceContainer.classList.add('tooltip-u'); const localized_piece_name = // @ts-ignore translations.piecenames[typeutil.getRawTypeStr(neutralTypes[i]!)!]; const piece_abbreviation = icnconverter.piece_codes_raw[neutralTypes[i]!]; const modified_piece_abbreviation = piece_abbreviation.toLowerCase(); pieceContainer.setAttribute( 'data-tooltip', `${localized_piece_name} (${modified_piece_abbreviation})`, ); pieceContainer.appendChild(neutral_svg); element_neutralTypes.push(neutral_svg); neutralPieces.appendChild(pieceContainer); } element_neutralTypesContainer.appendChild(neutralPieces); initialized = true; } /** Adds/removes the 'active' class from the piece svgs in the Palette, changing their style. */ function markPiece(type: number | null): void { const placerToolActive = etoolmanager.getTool() === 'placer'; _getActivePieceElements().forEach((element) => { const element_type = Number.parseInt(element.id); if (element_type === type && placerToolActive) element.classList.add('active'); else element.classList.remove('active'); }); } /** Updates which player's element container of their colored piece svgs are visible in the Palette. */ function updatePieceColors(newColor: Player): void { if (!initialized) return; // Hide all player containers and remove their listeners for (const [player, container] of element_playerContainers.entries()) { container.classList.add('hidden'); element_playerTypes.get(player)!.forEach((element) => { element.removeEventListener('click', callback_ChangePieceType); }); } // Show the correct container and add its listeners const newPlayerContainer = element_playerContainers.get(newColor); if (newPlayerContainer) { newPlayerContainer.classList.remove('hidden'); element_playerTypes.get(newColor)!.forEach((element) => { element.addEventListener('click', callback_ChangePieceType); }); } // Update dot color and internal state element_colorSelect.style.backgroundColor = typeutil.strcolors[newColor]; drawingtool.setColor(newColor); // Update currentPieceType, if necessary if (typeutil.getColorFromType(drawingtool.getPiece()) !== p.NEUTRAL) { const currentPieceType = typeutil.buildType( typeutil.getRawType(drawingtool.getPiece()), newColor, ); drawingtool.setPiece(currentPieceType); } markPiece(drawingtool.getPiece()); } /** Swaps the color of pieces being drawn to the next color in the turn order. */ function nextColor(): void { const playersArray = _getPlayersInOrder(); const currentIndex = playersArray.indexOf(drawingtool.getColor()); const next = playersArray[(currentIndex + 1) % playersArray.length]!; updatePieceColors(next); } function initListeners(): void { _getActivePieceElements().forEach((element) => { element.addEventListener('click', callback_ChangePieceType); }); } function closeListeners(): void { _getActivePieceElements().forEach((element) => { element.removeEventListener('click', callback_ChangePieceType); }); } // Helper Functions --------------------------------------------------------- /** Helper: Returns an array of players based on the current gamefile's turn order. */ function _getPlayersInOrder(): Player[] { const gamefile = gameslot.getGamefile()!; // Using a Set removes duplicates before converting to an array return [...new Set(gamefile.basegame.gameRules.turnOrder)]; } /** Helper: Returns an array of all piece elements that are currently clickable (active color + neutral). */ function _getActivePieceElements(): Element[] { const playerElements = element_playerTypes.get(drawingtool.getColor()) ?? []; return [...playerElements, ...element_neutralTypes]; } // Callbacks --------------------------------------------------------------- function callback_ChangePieceType(e: Event): void { const target = e.currentTarget as HTMLElement; const currentPieceType = Number.parseInt(target.id); if (isNaN(currentPieceType)) return console.error(`Invalid piece type: ${currentPieceType}`); drawingtool.setPiece(currentPieceType); etoolmanager.setTool('placer'); markPiece(currentPieceType); } // Exports ---------------------------------------------------------------- export default { initUI, markPiece, updatePieceColors, nextColor, initListeners, closeListeners, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/guipositionheader.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/guipositionheader.ts /** * Manages the active position name display, the dirty indicator, * and the enabled/disabled state of selection action buttons * in the board editor GUI. */ // Elements --------------------------------------------------------------- const element_activePositionNameDisplay = document.getElementById('active-position-name-display')!; const element_dirtyIndicator = document.getElementById('position-dirty-indicator')!; /** The element containing all selection tool action buttons. */ const element_selectionActions = document.getElementsByClassName( 'selection-actions', )[0]! as HTMLElement; /** These selection action buttons are always enabled. */ const alwaysActiveSelectionActions = [document.getElementById('select-all')!]; // Functions --------------------------------------------------------------- /** Updates the displayed active position name. */ function updateActivePositionElement(positionname: string | undefined): void { if (positionname === undefined) { positionname = translations.editor.new_position; element_activePositionNameDisplay.classList.add('italic'); } else { element_activePositionNameDisplay.classList.remove('italic'); } element_activePositionNameDisplay.textContent = positionname; element_activePositionNameDisplay.title = positionname; } /** Shows or hides the dirty indicator dot next to the position name. */ function updateDirtyIndicator(dirty: boolean): void { if (dirty) element_dirtyIndicator.classList.remove('hidden'); else element_dirtyIndicator.classList.add('hidden'); } /** Un-greys selection action buttons when a selection is made. */ function onNewSelection(): void { // Remove 'disabled' class from all children Array.from(element_selectionActions.children).forEach((child) => { (child as HTMLElement).classList.remove('disabled'); }); } /** Greys out selection action buttons when the selection is cleared. */ function onClearSelection(): void { // Add 'disabled' to all children except those included in the alwaysActiveSelectionActions array Array.from(element_selectionActions.children).forEach((child) => { if (!alwaysActiveSelectionActions.includes(child as HTMLElement)) { (child as HTMLElement).classList.add('disabled'); } }); } // Exports ---------------------------------------------------------------- export default { updateActivePositionElement, updateDirtyIndicator, onNewSelection, onClearSelection, }; ================================================ FILE: src/client/scripts/esm/game/gui/boardeditor/guitoolbar.ts ================================================ // src/client/scripts/esm/game/gui/boardeditor/guitoolbar.ts /** * Manages the tool selection toolbar in the board editor GUI. * Handles marking the active tool and wiring up tool-change click listeners. */ import type { Tool } from '../../boardeditor/tools/etoolmanager.js'; import etoolmanager from '../../boardeditor/tools/etoolmanager.js'; // Elements --------------------------------------------------------------- const elements_tools = [ document.getElementById('normal')!, document.getElementById('eraser')!, document.getElementById('specialrights')!, document.getElementById('selection-tool')!, ]; // Functions --------------------------------------------------------------- /** Adds/removes the 'active' class from the tools, changing their style. */ function markTool(tool: Tool): void { elements_tools.forEach((element) => { const element_tool = element.getAttribute('data-tool'); if (element_tool === tool) element.classList.add('active'); else if (element_tool !== 'gamerules') element.classList.remove('active'); }); } function initListeners(): void { elements_tools.forEach((element) => { element.addEventListener('click', callback_ChangeTool); }); } function closeListeners(): void { elements_tools.forEach((element) => { element.removeEventListener('click', callback_ChangeTool); }); } // Callbacks --------------------------------------------------------------- function callback_ChangeTool(e: Event): void { const target = e.currentTarget as HTMLElement; const tool = target.getAttribute('data-tool'); if (tool === null) throw new Error('Tool attribute is null'); etoolmanager.setTool(tool); } // Exports ---------------------------------------------------------------- export default { markTool, initListeners, closeListeners, }; ================================================ FILE: src/client/scripts/esm/game/gui/gui.ts ================================================ // src/client/scripts/esm/game/gui/gui.ts /** * This script adds event listeners for our main overlay html element that * contains all of our gui pages. * * We also prepare the board here whenever ANY gui page (non-game) is opened. */ import bd from '@naviary/bigdecimal'; import vectors from '../../../../../shared/util/math/vectors.js'; import toast from './toast.js'; import boardpos from '../rendering/boardpos.js'; import guititle from './guititle.js'; import loadbalancer from '../misc/loadbalancer.js'; // Functions ------------------------------------------------------------------------------ /** * Call when we first load the page, or leave any game. This prepares the board * for either the title screen or lobby (any screen that's not in a game) */ function prepareForOpen(): void { // Randomize pan velocity direction for the title screen and lobby menus randomizePanVelDir(); const amount = bd.fromNumber(1.8); // Default: 1.8 boardpos.setBoardScale(amount); loadbalancer.restartAFKTimer(); } // Sets panVel to a random direction, and sets speed to titleBoardVel. Called when the title screen is initiated. function randomizePanVelDir(): void { const randTheta = Math.random() * 2 * Math.PI; const XYComponents = vectors.getXYComponents_FromAngle(randTheta); boardpos.setPanVel([XYComponents[0] * guititle.boardVel, XYComponents[1] * guititle.boardVel]); } /** Displays the status message on screen "Feature is planned". */ function displayStatus_FeaturePlanned(): void { toast.show(translations.planned_feature); } export default { prepareForOpen, displayStatus_FeaturePlanned, }; ================================================ FILE: src/client/scripts/esm/game/gui/guiclock.ts ================================================ // src/client/scripts/esm/game/gui/guiclock.ts import type { Game } from '../../../../../shared/chess/logic/gamefile.js'; import type { ClockData } from '../../../../../shared/chess/logic/clock.js'; import type { SoundObject } from '../../audio/AudioManager.js'; import type { Player, PlayerGroup } from '../../../../../shared/chess/util/typeutil.js'; import clock from '../../../../../shared/chess/logic/clock.js'; import moveutil from '../../../../../shared/chess/util/moveutil.js'; import clockutil from '../../../../../shared/chess/util/clockutil.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import gamesound from '../misc/gamesound.js'; import gameloader from '../chess/gameloader.js'; import { GameBus } from '../GameBus.js'; const element_timers: PlayerGroup<{ timer: HTMLElement }> = { [p.WHITE]: { timer: document.getElementById('timer-white')!, }, [p.BLACK]: { timer: document.getElementById('timer-black')!, }, }; /** All variables related to the lowtime tick notification at 1 minute remaining. */ const lowtimeNotif: { playersNotified: Set; timeoutID?: ReturnType; timeToStartFromEnd: number; clockMinsRequiredToUse: number; } = { /** Contains the players that have had the ticking sound play */ playersNotified: new Set(), /** The timer that, when ends, will play the lowtime ticking audio cue. */ timeoutID: undefined, /** The amount of milliseconds before losing on time at which the lowtime tick notification will be played. */ timeToStartFromEnd: 65615, /** The minimum start time required to give a lowtime notification at 1 minute remaining. */ clockMinsRequiredToUse: 2, }; /** All variables related to the 10s countdown when you're almost out of time. */ const countdown: { drum: { timeoutID?: ReturnType; }; tick: { timeoutID?: ReturnType; sound?: SoundObject; timeToStartFromEnd: number; fadeInDuration: number; fadeOutDuration: number; }; ticking: { timeoutID?: ReturnType; sound?: SoundObject; timeToStartFromEnd: number; fadeInDuration: number; fadeOutDuration: number; }; } = { drum: { timeoutID: undefined, }, tick: { /** * The current sound object, if specified, that is playing our tick sound effects right before the 10s countdown. * This can be used to stop the sound from playing. */ sound: undefined, timeoutID: undefined, timeToStartFromEnd: 15625, fadeInDuration: 300, fadeOutDuration: 100, }, ticking: { /** * The current sound object, if specified, that is playing our ticking sound effects during the 10s countdown. * This can be used to stop the sound from playing. */ sound: undefined, timeoutID: undefined, timeToStartFromEnd: 10220, fadeInDuration: 300, fadeOutDuration: 100, }, }; // Events --------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { // Clock data is unloaded with gamefile now, just need to reset gui. Not our problem ¯\_(ツ)_/¯ resetClocks(); }); // Functions ----------------------------------------------------------------------- function hideClocks(): void { for (const clockElements of Object.values(element_timers)) { clockElements.timer.classList.add('hidden'); } } function showClocks(): void { for (const clockElements of Object.values(element_timers)) { clockElements.timer.classList.remove('hidden'); } } /** * Stops clock sounds and removes all borders */ function stopClocks(basegame?: Game): void { cancelSoundEffectTimers(); if (basegame && !basegame.untimed) updateTextContent(basegame.clocks); // Do this one last time so that when we lose on time, the clock doesn't freeze at one second remaining. for (const clockElements of Object.values(element_timers)) { removeBorder(clockElements.timer); } } function cancelSoundEffectTimers(): void { // Minute Tick clearTimeout(lowtimeNotif.timeoutID); lowtimeNotif.timeoutID = undefined; // 10-second Countdown clearTimeout(countdown.ticking.timeoutID); clearTimeout(countdown.tick.timeoutID); clearTimeout(countdown.drum.timeoutID); countdown.ticking.timeoutID = undefined; countdown.tick.timeoutID = undefined; countdown.drum.timeoutID = undefined; // Stop any sounds currently playing countdown.ticking.sound?.fadeOut(countdown.ticking.fadeOutDuration); countdown.tick.sound?.fadeOut(countdown.tick.fadeOutDuration); countdown.tick.sound = undefined; countdown.ticking.sound = undefined; } /** * Resets all data so a new game can be loaded */ function resetClocks(): void { stopClocks(); lowtimeNotif.playersNotified = new Set(); } function update(basegame: Game): void { if (basegame.untimed || basegame.gameConclusion || !moveutil.isGameResignable(basegame)) return; const clocks = basegame.clocks!; // Update border color if (clocks.colorTicking !== undefined) updateBorderColor( basegame.clocks, element_timers[clocks.colorTicking]!.timer, clocks.currentTime[clocks.colorTicking]!, ); updateTextContent(basegame.clocks); } function edit(basegame: Game): void { if (basegame.untimed) return; updateTextContent(basegame.clocks); // Remove colored border for (const [playerStr, clockElements] of Object.entries(element_timers)) { const player = Number(playerStr) as Player; if (player === basegame.clocks.colorTicking) continue; removeBorder(clockElements.timer); } rescheduleSoundEffects(basegame.clocks); } function rescheduleSoundEffects(clocks: ClockData): void { cancelSoundEffectTimers(); // Clear the previous timeouts if (clocks.colorTicking === undefined) return; // Don't reschedule sound effects if no clocks are ticking if (!gameloader.areInLocalGame() && clocks.colorTicking !== gameloader.getOurColor()) return; // Don't play the sound effect for our opponent. scheduleMinuteTick(clocks); // Lowtime notif at 1 minute left scheduleCountdown(clocks); // Schedule 10s drum countdown } function removeBorder(element: HTMLElement): void { element.style.outline = ''; } /** * Changes the border color gradually */ function updateBorderColor( clocks: ClockData, element: HTMLElement, currentTimeRemain: number, ): void { const percRemain = currentTimeRemain / (clocks.startTime.minutes * 60 * 1000); // Green => Yellow => Orange => Red const perc = 1 - percRemain; let r = 0, g = 0, b = 0; if (percRemain > 1 + 1 / 3) { g = 1; b = 1; } else if (percRemain > 1) { const localPerc = (percRemain - 1) * 3; g = 1; b = localPerc; } else if (perc < 0.5) { // Green => Yellow const localPerc = perc * 2; r = localPerc; g = 1; } else if (perc < 0.75) { // Yellow => Orange const localPerc = (perc - 0.5) * 4; r = 1; g = 1 - localPerc * 0.5; } else { // Orange => Red const localPerc = (perc - 0.75) * 4; r = 1; g = 0.5 - localPerc * 0.5; } element.style.outline = `3px solid rgb(${r * 255},${g * 255},${b * 255})`; } /** * Updates the clocks' text content in the document. */ function updateTextContent(clocks: ClockData): void { for (const [playerStr, clockElements] of Object.entries(element_timers)) { const player = Number(playerStr) as Player; const text = clockutil.getTextContentFromTimeRemain(clocks.currentTime[player]!); clockElements.timer.textContent = text; } } // The lowtime notification... /** * Reschedules the timer to play the ticking sound effect at 1 minute remaining. */ function scheduleMinuteTick(clocks: ClockData): void { if (clocks.startTime.minutes < lowtimeNotif.clockMinsRequiredToUse) return; // 1 minute lowtime notif is not used in bullet games. if (lowtimeNotif.playersNotified.has(clocks.colorTicking!)) return; const timeRemainAtTurnStart = clocks.timeRemainAtTurnStart!; const timeRemain = timeRemainAtTurnStart - lowtimeNotif.timeToStartFromEnd; // Time remaining until sound it should start playing if (timeRemain < 0) return; lowtimeNotif.timeoutID = setTimeout(() => playMinuteTick(clocks.colorTicking!), timeRemain); } function playMinuteTick(color: Player): void { gamesound.playTick({ volume: 0.07 }); lowtimeNotif.playersNotified.add(color); } function set(basegame: Game): void { if (basegame.untimed) return hideClocks(); else showClocks(); updateTextContent(basegame.clocks); } // The 10s drum countdown... /** Reschedules the timer to play the 10-second countdown effect. */ function scheduleCountdown(clocks: ClockData): void { scheduleDrum(clocks); scheduleTicking(clocks); scheduleTick(clocks); } function push(clocks: ClockData): void { rescheduleSoundEffects(clocks); // Remove colored border for (const [color, clockElements] of Object.entries(element_timers)) { const player = Number(color) as Player; if (player === clocks.colorTicking) continue; removeBorder(clockElements.timer); } } function scheduleDrum(clocks: ClockData): void { // We have to use this instead of reading the current clock values // because those aren't updated every frame when the page isn't focused!! const playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!; const timeUntil10SecsRemain = playerTrueTimeRemaining - 10000; let timeNextDrum = timeUntil10SecsRemain; let secsRemaining = 10; if (timeNextDrum < 0) { const addTimeNextDrum = -Math.floor(timeNextDrum / 1000) * 1000; timeNextDrum += addTimeNextDrum; secsRemaining -= addTimeNextDrum / 1000; } // console.log("Rescheduling drum countdown in ", timeNextDrum, "ms"); countdown.drum.timeoutID = setTimeout( () => playDrumAndQueueNext(clocks, secsRemaining), timeNextDrum, ); } function scheduleTicking(clocks: ClockData): void { if (clocks.timeAtTurnStart! < 10000) return; // We have to use this instead of reading the current clock values // because those aren't updated every frame when the page isn't focused!! const playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!; const timeRemain = playerTrueTimeRemaining - countdown.ticking.timeToStartFromEnd; if (timeRemain > 0) countdown.ticking.timeoutID = setTimeout(() => playTickingEffect(0), timeRemain); else { const offset = -timeRemain; playTickingEffect(offset); } } // Tick sound effect right BEFORE 10 seconds is hit function scheduleTick(clocks: ClockData): void { // We have to use this instead of reading the current clock values // because those aren't updated every frame when the page isn't focused!! const playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!; const timeRemain = playerTrueTimeRemaining - countdown.tick.timeToStartFromEnd; if (timeRemain > 0) countdown.tick.timeoutID = setTimeout(() => playTickEffect(0), timeRemain); else { const offset = -timeRemain; playTickEffect(offset); } } function playDrumAndQueueNext(clocks: ClockData, secsRemaining: number): void { if (secsRemaining === undefined) return console.error('Cannot play drum without secsRemaining'); gamesound.playDrum(); // We have to use this instead of reading the current clock values // because those aren't updated every frame when the page isn't focused!! const playerTrueTimeRemaining = clock.getColorTickingTrueTimeRemaining(clocks)!; if (playerTrueTimeRemaining < 1500) return; // Schedule next drum... const newSecsRemaining = secsRemaining - 1; if (newSecsRemaining === 0) return; // Stop const timeUntilNextDrum = playerTrueTimeRemaining - newSecsRemaining * 1000; countdown.drum.timeoutID = setTimeout( () => playDrumAndQueueNext(clocks, newSecsRemaining), timeUntilNextDrum, ); } function playTickingEffect(offset: number): void { countdown.ticking.sound = gamesound.playTicking({ volume: 0.18, offset }); countdown.ticking.sound?.fadeIn(0.18, countdown.ticking.fadeInDuration); } function playTickEffect(offset: number): void { countdown.tick.sound = gamesound.playTick({ volume: 0, offset }); countdown.tick.sound?.fadeIn(0.07, countdown.tick.fadeInDuration); } export default { hideClocks, showClocks, set, stopClocks, edit, push, update, rescheduleSoundEffects, }; ================================================ FILE: src/client/scripts/esm/game/gui/guidrawoffer.ts ================================================ // src/client/scripts/esm/game/gui/guidrawoffer.ts /** * This script opens and closes our Draw Offer UI * on the bottom navigation bar. * * It does NOT calculate if extending an offer is legal, * nor does it keep track of our current offers! */ import gameslot from '../chess/gameslot.js'; import guiclock from './guiclock.js'; import drawoffers from '../misc/onlinegame/drawoffers.js'; import guigameinfo from './guigameinfo.js'; // Variables ------------------------------------------------------------------- const element_draw_offer_ui = document.getElementById('draw_offer_ui')!; const element_acceptDraw = document.getElementById('acceptdraw')!; const element_declineDraw = document.getElementById('declinedraw')!; const element_whosturn = document.getElementById('whosturn')!; /** Whether the player names and clocks have been hidden to give space for the draw offer UI */ let drawOfferUICramped: boolean = false; // Functions ------------------------------------------------------------------- /** Reveals the draw offer UI on the bottom navigation bar */ function open(): void { element_draw_offer_ui.classList.remove('hidden'); element_whosturn.classList.add('hidden'); initDrawOfferListeners(); // Do the names and clocks need to be hidden to make room for the draw offer UI? updateVisibilityOfNamesAndClocksWithDrawOffer(); } /** Hides the draw offer UI on the bottom navigation bar */ function close(): void { element_draw_offer_ui.classList.add('hidden'); element_whosturn.classList.remove('hidden'); closeDrawOfferListeners(); if (!drawOfferUICramped) return; // We had hid the names and clocks to make room for the UI, reveal them here! // console.log("revealing"); guigameinfo.revealPlayerNames(); guiclock.showClocks(); drawOfferUICramped = false; // Reset for next draw offer UI opening } function initDrawOfferListeners(): void { element_acceptDraw.addEventListener('click', drawoffers.callback_AcceptDraw); element_declineDraw.addEventListener('click', drawoffers.callback_declineDraw); } function closeDrawOfferListeners(): void { element_acceptDraw.removeEventListener('click', drawoffers.callback_AcceptDraw); element_declineDraw.removeEventListener('click', drawoffers.callback_declineDraw); } /** * Hides/reveals the player names and clocks depending on if the draw offer UI has * enough space to fit with them. * This is called when the UI is opened, AND on screen resize event! */ function updateVisibilityOfNamesAndClocksWithDrawOffer(): void { if (!drawoffers.areWeAcceptingDraw()) return; // No open draw offer if (isDrawOfferUICramped()) { // Hide the player names and clocks if (drawOfferUICramped) return; // Already hidden // console.log("hiding"); drawOfferUICramped = true; guigameinfo.hidePlayerNames(); guiclock.hideClocks(); } else { // We have space now, reveal them! if (!drawOfferUICramped) return; // Already revealed // console.log("revealing"); drawOfferUICramped = false; guigameinfo.revealPlayerNames(); guiclock.showClocks(); } } /** * Returns true if the screen is small enough for the * draw offer UI to not fit with everything on the header bar. */ function isDrawOfferUICramped(): boolean { if (gameslot.getGamefile()!.basegame.untimed) return false; // Clocks not visible, we definitely have room if (window.innerWidth > 560) return false; // Screen is wide, we have room return true; // Cramped } export default { open, close, updateVisibilityOfNamesAndClocksWithDrawOffer, }; ================================================ FILE: src/client/scripts/esm/game/gui/guigameinfo.ts ================================================ // src/client/scripts/esm/game/gui/guigameinfo.ts /** * This script handles the game info bar, during a game, * displaying the clocks, and whos turn it currently is. */ import type { PlayerGroup } from '../../../../../shared/chess/util/typeutil.js'; import type { GameConclusion } from '../../../../../shared/chess/util/winconutil.js'; import type { MetaData, PlayerRatingChangeInfo, Rating } from '../../../../../shared/types.js'; import type { RatingItem, UsernameContainer, UsernameItem } from '../../util/usernamecontainer.js'; import metadatautil from '../../../../../shared/chess/util/metadatautil.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import gameslot from '../chess/gameslot.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import gameloader from '../chess/gameloader.js'; import enginegame from '../misc/enginegame.js'; import boardeditor from '../boardeditor/boardeditor.js'; import frametracker from '../rendering/frametracker.js'; import usernamecontainer from '../../util/usernamecontainer.js'; import clientmetadatautil from '../chess/clientmetadatautil.js'; // Elements --------------------------------------------------- const element_gameInfoBar = document.getElementById('game-info-bar')!; const element_whosturn = document.getElementById('whosturn')!; const element_playerWhiteContainer = document.querySelector('.player-container.left')!; const element_playerBlackContainer = document.querySelector('.player-container.right')!; const element_playerWhite = document.getElementById('playerwhite')!; const element_playerBlack = document.getElementById('playerblack')!; const element_practiceButtons = document.querySelector('.practice-engine-buttons')!; const element_undoButton: HTMLButtonElement = document.getElementById( 'undobutton', )! as HTMLButtonElement; const element_restartButton: HTMLButtonElement = document.getElementById( 'restartbutton', ) as HTMLButtonElement; // Variables --------------------------------------------------- let isOpen = false; /** Whether to show the practice mode game control buttons - undo move and restart. */ let showButtons = false; // Username container objects and their respective display options: let usernamecontainer_white: UsernameContainer | undefined; let usernamecontainer_black: UsernameContainer | undefined; // Functions /** * * @param metadata - The metadata of the gamefile, with its respective White and Black player names * @param {boolean} showGameControlButtons */ function open(metadata: MetaData, showGameControlButtons?: boolean): void { // console.log("Opening game info bar"); if (showGameControlButtons) showButtons = showGameControlButtons; else showButtons = false; if (!usernamecontainer_white || !usernamecontainer_black) { // Generate username containers embedUsernameContainers(metadata); } // Else username containers already exist ("N" key toggled bar) updateWhosTurn(); element_gameInfoBar.classList.remove('hidden'); if (showButtons) { element_practiceButtons.classList.remove('hidden'); initListeners_Gamecontrol(); } else element_practiceButtons.classList.add('hidden'); isOpen = true; } function embedUsernameContainers(gameMetadata: MetaData): void { // console.log("Embedding username containers"); const { white, black, white_type, black_type } = getPlayerNamesForGame(gameMetadata); const playerRatings: PlayerGroup | undefined = onlinegame.areInOnlineGame() ? onlinegame.getPlayerRatings() : undefined; // Set white username container const username_item_white: UsernameItem = { value: white, openInNewWindow: true }; const change_white = gameMetadata.WhiteRatingDiff ? Number(gameMetadata.WhiteRatingDiff) : undefined; const rating_item_white: RatingItem | undefined = playerRatings?.[p.WHITE] ? { value: playerRatings[p.WHITE]!.value + (change_white ?? 0), confident: playerRatings[p.WHITE]!.confident, change: change_white, } : undefined; usernamecontainer_white = usernamecontainer.createUsernameContainer( white_type, username_item_white, rating_item_white, ); usernamecontainer.embedUsernameContainerDisplayIntoParent( usernamecontainer_white.element, element_playerWhite, ); // Set black username container const username_item_black: UsernameItem = { value: black, openInNewWindow: true }; const change_black = gameMetadata.BlackRatingDiff ? Number(gameMetadata.BlackRatingDiff) : undefined; const rating_item_black: RatingItem | undefined = playerRatings?.[p.BLACK] ? { value: playerRatings[p.BLACK]!.value + (change_black ?? 0), confident: playerRatings[p.BLACK]!.confident, change: change_black, } : undefined; usernamecontainer_black = usernamecontainer.createUsernameContainer( black_type, username_item_black, rating_item_black, ); usernamecontainer.embedUsernameContainerDisplayIntoParent( usernamecontainer_black.element, element_playerBlack, ); // Need to set a timer to allow the document to repaint, because we need to read the updated element widths. setTimeout(updateAlignmentUsernames, 0); } /** * Hides the game info bar. * Does NOT clear/erase the username containers. */ function close(): void { // console.log("Closing game info bar"); // Restore the whosturn marker to original content element_whosturn.textContent = ''; // Hide the whole bar element_gameInfoBar.classList.add('hidden'); // Close button listeners closeListeners_Gamecontrol(); element_practiceButtons.classList.add('hidden'); isOpen = false; } /** Erases the username containers, removing them from the document. */ function clearUsernameContainers(): void { // console.log("Clearing username containers"); // Stop any running number animations usernamecontainer_white?.animationCancels.forEach((fn) => fn()); usernamecontainer_white?.element.remove(); usernamecontainer_white = undefined; // Stop any running number animations usernamecontainer_black?.animationCancels.forEach((fn) => fn()); usernamecontainer_black?.element.remove(); usernamecontainer_black = undefined; } function initListeners_Gamecontrol(): void { element_undoButton.addEventListener('click', undoMove); element_restartButton.addEventListener('click', restartGame); // For some reason we need this in order to stop the undo button from getting focused when clicked?? element_undoButton.addEventListener('mousedown', preventFocus); } function closeListeners_Gamecontrol(): void { element_undoButton.removeEventListener('click', undoMove); element_restartButton.removeEventListener('click', restartGame); element_undoButton.removeEventListener('mousedown', preventFocus); } function undoMove(): void { const event = new Event('guigameinfo-undoMove'); document.dispatchEvent(event); } function restartGame(): void { const event = new Event('guigameinfo-restart'); document.dispatchEvent(event); } /** * Disables / Enables the "Undo Move" button */ function update_GameControlButtons(undoingIsLegal: boolean): void { if (undoingIsLegal) { element_undoButton.classList.remove('opacity-0_5'); element_undoButton.style.cursor = 'pointer'; element_undoButton.disabled = false; } else { element_undoButton.classList.add('opacity-0_5'); element_undoButton.style.cursor = 'not-allowed'; element_undoButton.disabled = true; // Disables the 'click' event from firing when it is pressed } } function preventFocus(event: Event): void { event.preventDefault(); } /** Reveales the player names. Typically called after the draw offer UI is closed */ function revealPlayerNames(): void { element_playerWhiteContainer.classList.remove('hidden'); element_playerBlackContainer.classList.remove('hidden'); } /** Hides the player names. Typically to make room for the draw offer UI */ function hidePlayerNames(): void { element_playerWhiteContainer.classList.add('hidden'); element_playerBlackContainer.classList.add('hidden'); } function toggle(): void { if (isOpen) close(); else open(gameslot.getGamefile()!.basegame.metadata, showButtons); // Flag next frame to be rendered, since the arrows indicators may change locations with the bars toggled. frametracker.onVisualChange(); } /** * Given a metadata object, determines the names of the players to be displayed, as well as the type of player, * which determines the svg of the username container, and whether it should hyperlink or not. */ function getPlayerNamesForGame(metadata: MetaData): { white: string; black: string; white_type: 'player' | 'guest' | 'engine'; black_type: 'player' | 'guest' | 'engine'; } { if (gameloader.getTypeOfGameWeIn() === 'local' || boardeditor.areInBoardEditor()) { return { white: translations.player_name_white_generic, black: translations.player_name_black_generic, white_type: 'guest', black_type: 'guest', }; } else if (onlinegame.areInOnlineGame()) { if (metadata.White === undefined || metadata.Black === undefined) throw Error( 'White or Black metadata not defined when getting player names for online game.', ); // If you are a guest, then we want your name to be "(You)" instead of "(Guest)" const whiteIsGuest = metadata['White'] === metadatautil.GUEST_NAME_ICN_METADATA || metadata['White'] === clientmetadatautil.YOU_NAME_ICN_METADATA; const blackIsGuest = metadata['Black'] === metadatautil.GUEST_NAME_ICN_METADATA || metadata['Black'] === clientmetadatautil.YOU_NAME_ICN_METADATA; const white = onlinegame.areWeColorInOnlineGame(p.WHITE) && metadata['White'] === metadatautil.GUEST_NAME_ICN_METADATA ? translations.you_indicator : whiteIsGuest ? translations.guest_indicator : metadata['White']; const black = onlinegame.areWeColorInOnlineGame(p.BLACK) && metadata['Black'] === metadatautil.GUEST_NAME_ICN_METADATA ? translations.you_indicator : blackIsGuest ? translations.guest_indicator : metadata['Black']; return { white: white, black: black, white_type: whiteIsGuest ? 'guest' : 'player', black_type: blackIsGuest ? 'guest' : 'player', }; } else if (enginegame.areInEngineGame()) { return { white: metadata.White === clientmetadatautil.YOU_NAME_ICN_METADATA ? translations.you_indicator : metadata.White!, black: metadata.Black === clientmetadatautil.YOU_NAME_ICN_METADATA ? translations.you_indicator : metadata.Black!, white_type: metadata.White === clientmetadatautil.YOU_NAME_ICN_METADATA ? 'guest' : 'engine', black_type: metadata.Black === clientmetadatautil.YOU_NAME_ICN_METADATA ? 'guest' : 'engine', }; } else throw Error( 'Cannot get player names for game when not in a local, board editor, online, or engine game.', ); } /** * Updates the text at the bottom of the screen displaying who's turn it is now. * Call this after flipping the gamefile's `whosTurn` property. */ function updateWhosTurn(): void { const { basegame } = gameslot.getGamefile()!; // In the scenario we forward the game to front after the game has adjudicated, // don't modify the game over text saying who won! if (gamefileutility.isGameOver(basegame)) return gameEnd(basegame.gameConclusion); const color = basegame.whosTurn; if (color !== p.WHITE && color !== p.BLACK) throw Error( `Cannot set the document element text showing whos turn it is when color is neither white nor black! ${color}`, ); let textContent = ''; if (!gameloader.areInLocalGame()) { const ourTurn = gameloader.isItOurTurn(); textContent = ourTurn ? translations.your_move : translations.their_move; } else textContent = color === p.WHITE ? translations.white_to_move : translations.black_to_move; element_whosturn.textContent = textContent; } /** Updates the whosTurn text to say who won! */ function gameEnd(conclusion?: GameConclusion): void { if (conclusion === undefined) throw Error("Should not call gameEnd when game isn't over."); const { victor, condition } = conclusion; const resultTranslations = translations.results; const { basegame } = gameslot.getGamefile()!; // prettier-ignore if (onlinegame.areInOnlineGame() && onlinegame.doWeHaveRole() || enginegame.areInEngineGame()) { const ourRole = gameloader.getOurColor()!; if (ourRole === victor) element_whosturn.textContent = condition === 'checkmate' ? resultTranslations.you_checkmate : condition === 'time' ? resultTranslations.you_time : condition === 'resignation' ? resultTranslations.you_resignation : condition === 'disconnect' ? resultTranslations.you_disconnect : condition === 'royalcapture' ? resultTranslations.you_royalcapture : condition === 'allroyalscaptured' ? resultTranslations.you_allroyalscaptured : condition === 'allpiecescaptured' ? resultTranslations.you_allpiecescaptured : condition === 'koth' ? resultTranslations.you_koth : resultTranslations.you_generic; else if (victor === null) element_whosturn.textContent = condition === 'stalemate' ? resultTranslations.draw_stalemate : condition === 'repetition' ? resultTranslations.draw_repetition : condition === 'moverule' ? `${resultTranslations.draw_moverule[0]}${(basegame.gameRules.moveRule! / 2)}${resultTranslations.draw_moverule[1]}` : condition === 'insuffmat' ? resultTranslations.draw_insuffmat : condition === 'agreement' ? resultTranslations.draw_agreement : resultTranslations.draw_generic; else if (condition === 'aborted') element_whosturn.textContent = resultTranslations.aborted; else /* loss */ element_whosturn.textContent = condition === 'checkmate' ? resultTranslations.opponent_checkmate : condition === 'time' ? resultTranslations.opponent_time : condition === 'resignation' ? resultTranslations.opponent_resignation : condition === 'disconnect' ? resultTranslations.opponent_disconnect : condition === 'royalcapture' ? resultTranslations.opponent_royalcapture : condition === 'allroyalscaptured' ? resultTranslations.opponent_allroyalscaptured : condition === 'allpiecescaptured' ? resultTranslations.opponent_allpiecescaptured : condition === 'koth' ? resultTranslations.opponent_koth : resultTranslations.opponent_generic; } else { // Local game, OR spectating an online game if (condition === 'checkmate') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_checkmate : victor === p.BLACK ? resultTranslations.black_checkmate : `${resultTranslations.bug_generic} Ending: checkmate`; else if (condition === 'time') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_time : victor === p.BLACK ? resultTranslations.black_time : `${resultTranslations.bug_generic} Ending: time`; else if (condition === 'resignation') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_resignation : victor === p.BLACK ? resultTranslations.black_resignation : `${resultTranslations.bug_generic} Ending: resignation`; else if (condition === 'disconnect') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_disconnect : victor === p.BLACK ? resultTranslations.black_disconnect : `${resultTranslations.bug_generic} Ending: disconnect`; else if (condition === 'royalcapture') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_royalcapture : victor === p.BLACK ? resultTranslations.black_royalcapture : `${resultTranslations.bug_generic} Ending: royalcapture`; else if (condition === 'allroyalscaptured') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_allroyalscaptured : victor === p.BLACK ? resultTranslations.black_allroyalscaptured : `${resultTranslations.bug_generic} Ending: allroyalscaptured`; else if (condition === 'allpiecescaptured') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_allpiecescaptured : victor === p.BLACK ? resultTranslations.black_allpiecescaptured : `${resultTranslations.bug_generic} Ending: allpiecescaptured`; else if (condition === 'koth') element_whosturn.textContent = victor === p.WHITE ? resultTranslations.white_koth : victor === p.BLACK ? resultTranslations.black_koth : `${resultTranslations.bug_generic} Ending: koth`; else if (condition === 'stalemate') element_whosturn.textContent = resultTranslations.draw_stalemate; else if (condition === 'repetition') element_whosturn.textContent = resultTranslations.draw_repetition; else if (condition === 'moverule') element_whosturn.textContent = `${resultTranslations.draw_moverule[0]}${basegame.gameRules.moveRule! / 2}${resultTranslations.draw_moverule[1]}`; else if (condition === 'insuffmat') element_whosturn.textContent = resultTranslations.draw_insuffmat; else if (condition === 'agreement') element_whosturn.textContent = resultTranslations.draw_agreement; else if (condition === 'aborted') element_whosturn.textContent = resultTranslations.aborted; else { element_whosturn.textContent = resultTranslations.bug_generic; console.error( `Victor: ${victor}\nCondition: ${condition}`, ); } } } /** Returns the height of the game info bar in the document, in virtual pixels. */ function getHeightOfGameInfoBar(): number { return element_gameInfoBar.getBoundingClientRect().height; } /** * Wide screen => Right-aligns black's username container * Narrow screen => Left-aligns black's username container and adds a fade effect on the right overflow * Fades either if they exceed the width of their parent. */ function updateAlignmentUsernames(): void { if (!usernamecontainer_white || !usernamecontainer_black) return; // Not in a game // Player white if (usernamecontainer_white!.element.clientWidth > element_playerWhite.clientWidth) { element_playerWhite.classList.add('fade-element'); } else { element_playerWhite.classList.remove('fade-element'); } // Player black if (usernamecontainer_black!.element.clientWidth > element_playerBlack.clientWidth) { element_playerBlack.classList.remove('justify-content-right'); element_playerBlack.classList.add('justify-content-left'); element_playerBlack.classList.add('fade-element'); } else { element_playerBlack.classList.add('justify-content-right'); element_playerBlack.classList.remove('justify-content-left'); element_playerBlack.classList.remove('fade-element'); } } /** * This gets called when the client receives a "gameratingchange" message from a websocket * Displays the rating changes from the game in the existing username containers, while keeping all display options the same */ function addRatingChangeToExistingUsernameContainers( ratingChanges: PlayerGroup, ): void { // Add the WhiteRatingDiff and BlackRatingDiff metadata to the gamefile const { basegame } = gameslot.getGamefile()!; basegame.metadata.WhiteRatingDiff = metadatautil.getWhiteBlackRatingDiff( ratingChanges[p.WHITE]!.change, ); basegame.metadata.BlackRatingDiff = metadatautil.getWhiteBlackRatingDiff( ratingChanges[p.BLACK]!.change, ); // Update username containers usernamecontainer.createEloChangeItem( usernamecontainer_white!, ratingChanges[p.WHITE]!.newRating, ratingChanges[p.WHITE]!.change, ); usernamecontainer.createEloChangeItem( usernamecontainer_black!, ratingChanges[p.BLACK]!.newRating, ratingChanges[p.BLACK]!.change, ); // Need to set a timer to allow the document to repaint, because we need to read the updated element widths. setTimeout(updateAlignmentUsernames, 0); } export default { open, close, clearUsernameContainers, update_GameControlButtons, revealPlayerNames, hidePlayerNames, toggle, updateWhosTurn, gameEnd, getHeightOfGameInfoBar, updateAlignmentUsernames, addRatingChangeToExistingUsernameContainers, }; ================================================ FILE: src/client/scripts/esm/game/gui/guiloading.ts ================================================ // src/client/scripts/esm/game/gui/guiloading.ts /** * This script hides the loading animation when the page fully loads. * */ // Loading Animation Before Page Load const element_loadingAnimation = document.getElementById('loading-animation')!; /** THIS SHOULD MATCH THE transition time declared in the css stylesheet!! */ const durationOfFadeOutMillis = 400; /** Stops the loading screen animation. */ function closeAnimation(): void { setTimeout(() => { element_loadingAnimation.classList.add('hidden'); }, durationOfFadeOutMillis); element_loadingAnimation.style.opacity = '0'; } export default { closeAnimation, }; ================================================ FILE: src/client/scripts/esm/game/gui/guinavigation.ts ================================================ // src/client/scripts/esm/game/gui/guinavigation.ts import type { BDCoords } from '../../../../../shared/chess/util/coordutil.js'; import type { BoundingBox } from '../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bimath from '../../../../../shared/util/math/bimath.js'; import moveutil from '../../../../../shared/chess/util/moveutil.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import toast from './toast.js'; import stats from './stats.js'; import mouse from '../../util/mouse.js'; import space from '../misc/space.js'; import guipause from './guipause.js'; import gameslot from '../chess/gameslot.js'; import boardpos from '../rendering/boardpos.js'; import snapping from '../rendering/highlights/snapping.js'; import premoves from '../chess/premoves.js'; import selection from '../chess/selection.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import Transition from '../rendering/transitions/Transition.js'; import annotations from '../rendering/highlights/annotations/annotations.js'; import edithistory from '../boardeditor/edithistory.js'; import { GameBus } from '../GameBus.js'; import frametracker from '../rendering/frametracker.js'; import movesequence from '../chess/movesequence.js'; import guiboardeditor from './boardeditor/guiboardeditor.js'; import { listener_document, listener_overlay } from '../chess/game.js'; /** * This script handles the navigation bar, in a game, * along the top of the screen, containing the teleporation * buttons, rewind move, forward move, and pause buttons. */ const element_Navigation = document.getElementById('navigation-bar')!; // Navigation const element_Recenter = document.getElementById('recenter')!; const element_Expand = document.getElementById('expand')!; const element_Back = document.getElementById('back')!; const element_Annotations = document.getElementById('annotations')!; const element_Erase = document.getElementById('erase')!; const element_Collapse = document.getElementById('collapse')!; // const element_AnnotationsContainer = document.querySelector('.buttoncontainer.annotations')!; const element_EraseContainer = document.querySelector('.buttoncontainer.erase')!; const element_CollapseContainer = document.querySelector('.buttoncontainer.collapse')!; const element_CoordsX = document.getElementById('x') as HTMLInputElement; const element_CoordsY = document.getElementById('y') as HTMLInputElement; const element_moveRewind = document.getElementById('move-left')!; const element_moveForward = document.getElementById('move-right')!; const element_undoEdit = document.getElementById('undo-edit')!; const element_redoEdit = document.getElementById('redo-edit')!; const element_pause = document.getElementById('pause')!; /** * A limit posed against teleporting too far. * * Don't want players to discover new zones quickly * without doing the work of zooming out :) * That would decrease the reward. * * FUTURE: I could allow teleporting up to 1e10000. * I roughly determined 1e75000 to be the bound for * no noticeable lag in websocket message size. * That would still prevent instantly exceeding that. */ const TELEPORT_LIMIT: bigint = 10n ** 30n; // 10^30 squares const timeToHoldMillis = 250; // After holding the button this long, moves will fast-rewind or edits will fast undo/redo const intervalToRepeat = 40; // Default 40. How quickly moves will fast-rewind or edits will fast undo/redo const minimumRewindOrEditIntervalMillis = 20; // Rewinding, forwarding, undoing and redoing can never be spammed faster than this let lastRewindOrEdit = 0; let leftArrowTimeoutID: ReturnType; // setTimeout to BEGIN rewinding or undoing let leftArrowIntervalID: ReturnType; // setInterval to CONTINUE rewinding or undoing let touchIsInsideLeft = false; let rightArrowTimeoutID: ReturnType; // setTimeout to BEGIN forwarding or redoing let rightArrowIntervalID: ReturnType; // setInterval to CONTINUE forwarding or redoing let touchIsInsideRight = false; let rewindIsLocked = false; const durationToLockRewindAfterMoveForwardingMillis = 750; /** Whether the navigation UI is visible (not hidden) */ let navigationOpen = true; /** * Whether the annotations button is enabled. * If so, all left click actions are treated as right clicks. */ let annotationsEnabled: boolean = false; // Events ---------------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { // Reset Annotations mode button state, without closing the Navigation Bar. annotationsEnabled = false; listener_overlay.setTreatLeftasRight(false); element_Annotations.classList.remove('enabled'); hideCollapse(); }); // ================================================================================= // Functions function isOpen(): boolean { return navigationOpen; } /** Called when we push 'N' on the keyboard */ function toggle(): void { if (navigationOpen) close(); else open({ allowEditCoords: !onlinegame.areInOnlineGame() }); // Flag next frame to be rendered, since the arrows indicators may change locations with the bars toggled. frametracker.onVisualChange(); } function open({ allowEditCoords = true }: { allowEditCoords?: boolean }): void { element_Navigation.classList.remove('hidden'); if (!guiboardeditor.isOpen()) { // Normal game => Show navigate move buttons element_moveRewind.classList.remove('hidden'); element_moveForward.classList.remove('hidden'); element_undoEdit.classList.add('hidden'); element_redoEdit.classList.add('hidden'); update_MoveButtons(); } else { // Board editor => Show undo/redo edit buttons element_moveRewind.classList.add('hidden'); element_moveForward.classList.add('hidden'); element_undoEdit.classList.remove('hidden'); element_redoEdit.classList.remove('hidden'); update_EditButtons(); } initListeners_Navigation(); initCoordinates({ allowEditCoords }); navigationOpen = true; stats.updateStatsCSS(); } function initCoordinates({ allowEditCoords }: { allowEditCoords: boolean }): void { if (allowEditCoords) { element_CoordsX.disabled = false; element_CoordsY.disabled = false; element_CoordsX.classList.remove('set-cursor-to-not-allowed'); element_CoordsY.classList.remove('set-cursor-to-not-allowed'); } else { element_CoordsX.disabled = true; element_CoordsY.disabled = true; element_CoordsX.classList.add('set-cursor-to-not-allowed'); element_CoordsY.classList.add('set-cursor-to-not-allowed'); } } function close(): void { element_Navigation.classList.add('hidden'); closeListeners_Navigation(); navigationOpen = false; stats.updateStatsCSS(); } // =============================== Coordinate Fields =============================== // Update the division on the screen displaying your current coordinates function updateElement_Coords(): void { if (isCoordinateActive()) return; // Don't update the coordinates if the user is editing them const boardPos = boardpos.getBoardPos(); const mouseTile = mouse.getTileMouseOver_Integer(); const xDisplayCoord = mouseTile ? mouseTile[0] : space.roundCoord(boardPos[0]); const yDisplayCoord = mouseTile ? mouseTile[1] : space.roundCoord(boardPos[1]); // If the number is too big to fit in the input box, display it in exponential notation instead. displayBigIntInInput(element_CoordsX, xDisplayCoord, 3); displayBigIntInInput(element_CoordsY, yDisplayCoord, 3); } /** * Displays a BigInt in an input element. If it overflows, * it's displayed in exponential notation instead. * @param inputElement The input element to display the number in. * @param bigint The BigInt value to display. * @param precision The precision for the exponential notation. */ function displayBigIntInInput( inputElement: HTMLInputElement, bigint: bigint, precision: number, ): void { // First, try to display the full number by setting the .value inputElement.value = bigint.toString(); // Check for overflow. if (inputElement.scrollWidth > inputElement.clientWidth + 1) { // Needs the +1 due to floating point stuff. Else sometimes at random font sizes this is true when it shouldn't be. // Format it and set the .value again. inputElement.value = bimath.formatBigIntExponential(bigint, precision); } } /** * Parses a string representation (either standard or e-notation) into a BigInt. * This is the inverse of {@link formatBigIntExponential}. * @param value The string to parse. Can be "12345" or "1.23e8". * @returns The resulting BigInt. */ function parseStringToBigInt(value: string): bigint { const trimmedValue = value.trim(); if (trimmedValue === '') throw Error(); // Use case-insensitive check for 'e' const eIndex = trimmedValue.toLowerCase().indexOf('e'); // Case 1: No scientific notation, just a plain integer string. if (eIndex === -1) return BigInt(trimmedValue); // Case 2: Scientific notation is present. const mantissaStr = trimmedValue.substring(0, eIndex); const exponentStr = trimmedValue.substring(eIndex + 1); if (mantissaStr === '' || exponentStr === '') throw Error(); // Malformed e-notation: missing mantissa or exponent const exponent = parseInt(exponentStr, 10); // Check if exponent is a valid integer number if (isNaN(exponent) || !Number.isInteger(exponent)) throw Error(); // Since BigInts are whole numbers, a negative exponent would result in a fraction. if (exponent < 0) throw Error(); const isNegative = mantissaStr.startsWith('-'); const absMantissaStr = isNegative ? mantissaStr.substring(1) : mantissaStr; const decimalIndex = absMantissaStr.indexOf('.'); let allDigits: string; let fractionalDigitsCount = 0; if (decimalIndex === -1) { // e.g., "123e5" allDigits = absMantissaStr; } else { // e.g., "1.23" -> allDigits = "123", fractionalDigitsCount = 2 const integerPart = absMantissaStr.substring(0, decimalIndex); const fractionalPart = absMantissaStr.substring(decimalIndex + 1); allDigits = integerPart + fractionalPart; fractionalDigitsCount = fractionalPart.length; } // The number of zeros to append is the exponent minus the number of digits // we already have after the decimal point. const zerosToAppend = exponent - fractionalDigitsCount; const zeros = '0'.repeat(zerosToAppend); const finalNumberString = `${isNegative ? '-' : ''}${allDigits}${zeros}`; return BigInt(finalNumberString); } // ================================================================================= /** * Returns true if one of the coordinate fields is active (currently editing) */ function isCoordinateActive(): boolean { return element_CoordsX === document.activeElement || element_CoordsY === document.activeElement; } function initListeners_Navigation(): void { element_Recenter.addEventListener('click', recenter); element_Expand.addEventListener('click', callback_Expand); element_Back.addEventListener('click', callback_Back); element_Annotations.addEventListener('click', callback_Annotations); element_Erase.addEventListener('click', callback__Collapse); element_Collapse.addEventListener('click', callback__Collapse); element_pause.addEventListener('click', callback_Pause); element_CoordsX.addEventListener('change', callback_CoordsXChange); element_CoordsY.addEventListener('change', callback_CoordsYChange); if (!guiboardeditor.isOpen()) { element_moveRewind.addEventListener('click', callback_MoveRewind); element_moveRewind.addEventListener('mousedown', callback_MoveRewindMouseDown); element_moveRewind.addEventListener('mouseleave', callback_MoveRewindMouseLeave); element_moveRewind.addEventListener('mouseup', callback_MoveRewindMouseUp); element_moveRewind.addEventListener('touchstart', callback_MoveRewindTouchStart); element_moveRewind.addEventListener('touchmove', callback_MoveRewindTouchMove); element_moveRewind.addEventListener('touchend', callback_MoveRewindTouchEnd); element_moveRewind.addEventListener('touchcancel', callback_MoveRewindTouchEnd); element_moveForward.addEventListener('click', callback_MoveForward); element_moveForward.addEventListener('mousedown', callback_MoveForwardMouseDown); element_moveForward.addEventListener('mouseleave', callback_MoveForwardMouseLeave); element_moveForward.addEventListener('mouseup', callback_MoveForwardMouseUp); element_moveForward.addEventListener('touchstart', callback_MoveForwardTouchStart); element_moveForward.addEventListener('touchmove', callback_MoveForwardTouchMove); element_moveForward.addEventListener('touchend', callback_MoveForwardTouchEnd); element_moveForward.addEventListener('touchcancel', callback_MoveForwardTouchEnd); } else { element_undoEdit.addEventListener('click', callback_UndoEdit); element_undoEdit.addEventListener('mousedown', callback_UndoEditMouseDown); element_undoEdit.addEventListener('mouseleave', callback_UndoEditMouseLeave); element_undoEdit.addEventListener('mouseup', callback_UndoEditMouseUp); element_undoEdit.addEventListener('touchstart', callback_UndoEditTouchStart); element_undoEdit.addEventListener('touchmove', callback_UndoEditTouchMove); element_undoEdit.addEventListener('touchend', callback_UndoEditTouchEnd); element_undoEdit.addEventListener('touchcancel', callback_UndoEditTouchEnd); element_redoEdit.addEventListener('click', callback_RedoEdit); element_redoEdit.addEventListener('mousedown', callback_RedoEditMouseDown); element_redoEdit.addEventListener('mouseleave', callback_RedoEditMouseLeave); element_redoEdit.addEventListener('mouseup', callback_RedoEditMouseUp); element_redoEdit.addEventListener('touchstart', callback_RedoEditTouchStart); element_redoEdit.addEventListener('touchmove', callback_RedoEditTouchMove); element_redoEdit.addEventListener('touchend', callback_RedoEditTouchEnd); element_redoEdit.addEventListener('touchcancel', callback_RedoEditTouchEnd); } } function closeListeners_Navigation(): void { element_Recenter.removeEventListener('click', recenter); element_Expand.removeEventListener('click', callback_Expand); element_Back.removeEventListener('click', callback_Back); element_Annotations.removeEventListener('click', callback_Annotations); element_Erase.removeEventListener('click', callback__Collapse); element_Collapse.removeEventListener('click', callback__Collapse); element_Back.removeEventListener('click', callback_Pause); element_CoordsX.removeEventListener('change', callback_CoordsXChange); element_CoordsY.removeEventListener('change', callback_CoordsYChange); if (!guiboardeditor.isOpen()) { element_moveRewind.removeEventListener('click', callback_MoveRewind); element_moveRewind.removeEventListener('mousedown', callback_MoveRewindMouseDown); element_moveRewind.removeEventListener('mouseleave', callback_MoveRewindMouseLeave); element_moveRewind.removeEventListener('mouseup', callback_MoveRewindMouseUp); element_moveRewind.removeEventListener('touchstart', callback_MoveRewindTouchStart); element_moveRewind.removeEventListener('touchmove', callback_MoveRewindTouchMove); element_moveRewind.removeEventListener('touchend', callback_MoveRewindTouchEnd); element_moveRewind.removeEventListener('touchcancel', callback_MoveRewindTouchEnd); element_moveForward.removeEventListener('click', callback_MoveForward); element_moveForward.removeEventListener('mousedown', callback_MoveForwardMouseDown); element_moveForward.removeEventListener('mouseleave', callback_MoveForwardMouseLeave); element_moveForward.removeEventListener('mouseup', callback_MoveForwardMouseUp); element_moveForward.removeEventListener('touchstart', callback_MoveForwardTouchStart); element_moveForward.removeEventListener('touchmove', callback_MoveForwardTouchMove); element_moveForward.removeEventListener('touchend', callback_MoveForwardTouchEnd); element_moveForward.removeEventListener('touchcancel', callback_MoveForwardTouchEnd); } else { element_undoEdit.removeEventListener('click', callback_UndoEdit); element_undoEdit.removeEventListener('mousedown', callback_UndoEditMouseDown); element_undoEdit.removeEventListener('mouseleave', callback_UndoEditMouseLeave); element_undoEdit.removeEventListener('mouseup', callback_UndoEditMouseUp); element_undoEdit.removeEventListener('touchstart', callback_UndoEditTouchStart); element_undoEdit.removeEventListener('touchmove', callback_UndoEditTouchMove); element_undoEdit.removeEventListener('touchend', callback_UndoEditTouchEnd); element_undoEdit.removeEventListener('touchcancel', callback_UndoEditTouchEnd); element_redoEdit.removeEventListener('click', callback_RedoEdit); element_redoEdit.removeEventListener('mousedown', callback_RedoEditMouseDown); element_redoEdit.removeEventListener('mouseleave', callback_RedoEditMouseLeave); element_redoEdit.removeEventListener('mouseup', callback_RedoEditMouseUp); element_redoEdit.removeEventListener('touchstart', callback_RedoEditTouchStart); element_redoEdit.removeEventListener('touchmove', callback_RedoEditTouchMove); element_redoEdit.removeEventListener('touchend', callback_RedoEditTouchEnd); element_redoEdit.removeEventListener('touchcancel', callback_RedoEditTouchEnd); } } /** Called when the field is FINISHED being edited, not on every keystroke. */ function callback_CoordsXChange(): void { element_CoordsX.blur(); callback_CoordsChange(0); } /** Called when the field is FINISHED being edited, not on every keystroke. */ function callback_CoordsYChange(): void { element_CoordsY.blur(); callback_CoordsChange(1); } function callback_CoordsChange(index: 0 | 1): void { const target: HTMLInputElement = index === 0 ? element_CoordsX : element_CoordsY; const boardPos = boardpos.getBoardPos(); let teleportX: BigDecimal = boardPos[0]; let teleportY: BigDecimal = boardPos[1]; let proposed: bigint; try { proposed = parseStringToBigInt(target.value); } catch (_e) { console.log(`Entered: ${target.value}`); toast.show(translations['coords-invalid'], { error: true }); return; } if (bimath.abs(proposed) > TELEPORT_LIMIT) { toast.show(translations['coords-exceeded'], { error: true }); return; } if (index === 0) teleportX = bd.fromBigInt(proposed); else teleportY = bd.fromBigInt(proposed); const newPos: BDCoords = [teleportX, teleportY]; boardpos.setBoardPos(newPos); } function callback_Back(): void { Transition.undoTransition(); } function callback_Expand(): void { const box: Partial = boardutil.getBoundingBoxOfAllPieces(gameslot.getGamefile()!.boardsim.pieces) ?? {}; // Add the square annotation highlights, too. // THIS ROUNDS RAY intersections to the nearest integer coordinate, so the resulting area may be imperfect!!!!! // I don't think it matters to much. const annoteSnapPoints = snapping .getAnnoteSnapPoints(false) .map((point) => bdcoords.coordsToBigInt(point)); // Expand the box to include all annote snap points for (const snapPoint of annoteSnapPoints) { if (box.left === undefined || snapPoint[0] < box.left) box.left = snapPoint[0]; if (box.right === undefined || snapPoint[0] > box.right) box.right = snapPoint[0]; if (box.bottom === undefined || snapPoint[1] < box.bottom) box.bottom = snapPoint[1]; if (box.top === undefined || snapPoint[1] > box.top) box.top = snapPoint[1]; } // If any sides are still undefined, set them to default values const definedBox: BoundingBox = box.left === undefined || box.right === undefined || box.bottom === undefined || box.top === undefined ? { left: 1n, right: 8n, bottom: 1n, top: 8n } : (box as BoundingBox); Transition.zoomToCoordsBox(definedBox); } function recenter(): void { Transition.zoomToCoordsBox(gameslot.getGamefile()!.boardsim.startSnapshot.box); // If you know the bounding box, you don't need a coordinate list } // Annotations Buttons ====================================== function callback_Annotations(): void { annotationsEnabled = !annotationsEnabled; listener_overlay.setTreatLeftasRight(annotationsEnabled); element_Annotations.classList.toggle('enabled'); } /** Returns whether the annotations button on the navigation bar on mobile devices is enabled (glowing RED) */ function isAnnotationsButtonEnabled(): boolean { return annotationsEnabled; } function callback__Collapse(): void { annotations.Collapse(); } document.addEventListener('ray-count-change', (e) => { const rayCount = e.detail; if (rayCount > 0) showCollapse(); else hideCollapse(); }); /** Replaces eraser svg with collapse svg. */ function showCollapse(): void { element_EraseContainer.classList.add('hidden'); element_CollapseContainer.classList.remove('hidden'); } /** Replaces collapse svg with eraser svg. */ function hideCollapse(): void { element_EraseContainer.classList.remove('hidden'); element_CollapseContainer.classList.add('hidden'); } // ===================================================================== /** * Returns true if the coords input box is currently not allowed to be edited. * This was set at the time they were opened. */ function areCoordsAllowedToBeEdited(): boolean { return !element_CoordsX.disabled; } /** Returns the height of the navigation bar in the document, in virtual pixels. */ function getHeightOfNavBar(): number { return element_Navigation.getBoundingClientRect().height; } function callback_Pause(): void { guipause.open(); } /** Tests if the arrow keys have been pressed outisde of the board editor, signaling to rewind/forward the game. */ function update(): void { if (!guiboardeditor.isOpen()) { testIfRewindMove(); testIfForwardMove(); } else { testIfUndoEdit(); testIfRedoEdit(); } } // Move Buttons ===================================================== function callback_MoveRewind(): void { if (rewindIsLocked) return; if (!isItOkayToRewindOrForward()) return; lastRewindOrEdit = Date.now(); rewindMove(); } function callback_MoveForward(): void { if (!isItOkayToRewindOrForward()) return; lastRewindOrEdit = Date.now(); forwardMove(); } function isItOkayToRewindOrForward(): boolean { const timeSincelastRewindOrEdit = Date.now() - lastRewindOrEdit; return timeSincelastRewindOrEdit >= minimumRewindOrEditIntervalMillis; // True if enough time has passed! } /** * Makes the rewind/forward move buttons transparent if we're at * the very beginning or end of the game. */ function update_MoveButtons(): void { const gamefile = gameslot.getGamefile()!; const decrementingLegal = moveutil.isDecrementingLegal(gamefile.boardsim); const incrementingLegal = moveutil.isIncrementingLegal(gamefile.boardsim); if (decrementingLegal) element_moveRewind.classList.remove('opacity-0_5'); else element_moveRewind.classList.add('opacity-0_5'); if (incrementingLegal) element_moveForward.classList.remove('opacity-0_5'); else element_moveForward.classList.add('opacity-0_5'); } // Mouse function callback_MoveRewindMouseDown(): void { leftArrowTimeoutID = setTimeout(() => { leftArrowIntervalID = setInterval(() => { callback_MoveRewind(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_MoveRewindMouseLeave(): void { clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_MoveRewindMouseUp(): void { clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_MoveForwardMouseDown(): void { rightArrowTimeoutID = setTimeout(() => { rightArrowIntervalID = setInterval(() => { callback_MoveForward(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_MoveForwardMouseLeave(): void { clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } function callback_MoveForwardMouseUp(): void { clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } // Fingers function callback_MoveRewindTouchStart(): void { touchIsInsideLeft = true; leftArrowTimeoutID = setTimeout(() => { if (!touchIsInsideLeft) return; leftArrowIntervalID = setInterval(() => { callback_MoveRewind(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_MoveRewindTouchMove(event: TouchEvent): void { if (!touchIsInsideLeft) return; const touch = event.touches[0]!; const rect = element_moveRewind.getBoundingClientRect(); if ( touch.clientX > rect.left && touch.clientX < rect.right && touch.clientY > rect.top && touch.clientY < rect.bottom ) return; touchIsInsideLeft = false; clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_MoveRewindTouchEnd(): void { touchIsInsideLeft = false; clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_MoveForwardTouchStart(): void { touchIsInsideRight = true; rightArrowTimeoutID = setTimeout(() => { if (!touchIsInsideRight) return; rightArrowIntervalID = setInterval(() => { callback_MoveForward(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_MoveForwardTouchMove(event: TouchEvent): void { event = event || window.event; if (!touchIsInsideRight) return; const touch = event.touches[0]!; const rect = element_moveForward.getBoundingClientRect(); if ( touch.clientX > rect.left && touch.clientX < rect.right && touch.clientY > rect.top && touch.clientY < rect.bottom ) return; touchIsInsideRight = false; clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } function callback_MoveForwardTouchEnd(): void { touchIsInsideRight = false; clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } /** * Locks the rewind button for a brief moment. Typically called after forwarding the moves to the front. * This is so if our opponent moves while we're rewinding, there's a brief pause. */ function lockRewind(): void { rewindIsLocked = true; lockLayers++; setTimeout(() => { lockLayers--; if (lockLayers > 0) return; rewindIsLocked = false; }, durationToLockRewindAfterMoveForwardingMillis); } let lockLayers = 0; /** Tests if the left arrow key has been pressed, signaling to rewind the game. */ function testIfRewindMove(): void { if (!listener_document.isKeyDown('ArrowLeft')) return; if (rewindIsLocked) return; rewindMove(); } /** Tests if the right arrow key has been pressed, signaling to forward the game. */ function testIfForwardMove(): void { if (!listener_document.isKeyDown('ArrowRight')) return; forwardMove(); } /** Rewinds the currently-loaded gamefile by 1 move. Unselects any piece, updates the rewind/forward move buttons. */ function rewindMove(): void { const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); const hadAtleastOnePremove = premoves.hasAtleastOnePremove(); premoves.cancelPremoves(gamefile, mesh); // If we had premoves to cancel, just cancel them, don't rewind a move this time. if (hadAtleastOnePremove) return; if (!moveutil.isDecrementingLegal(gamefile.boardsim)) return stats.showMoves(); frametracker.onVisualChange(); movesequence.navigateMove(gamefile, mesh, false); selection.unselectPiece(); } /** Forwards the currently-loaded gamefile by 1 move. Unselects any piece, updates the rewind/forward move buttons. */ function forwardMove(): void { const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); premoves.cancelPremoves(gamefile, mesh); if (!moveutil.isIncrementingLegal(gamefile.boardsim)) return stats.showMoves(); movesequence.navigateMove(gamefile, mesh, true); } // Edit Buttons ===================================================== function isItOkayToUndoEditOrRedoEdit(): boolean { const timeSincelastRewindOrEdit = Date.now() - lastRewindOrEdit; return timeSincelastRewindOrEdit >= minimumRewindOrEditIntervalMillis; // True if enough time has passed! } /** * Makes the undo/redo move buttons transparent if we're at * the very beginning or end of the edits. */ function update_EditButtons(): void { if (edithistory.canUndo()) element_undoEdit.classList.remove('opacity-0_5'); else element_undoEdit.classList.add('opacity-0_5'); if (edithistory.canRedo()) element_redoEdit.classList.remove('opacity-0_5'); else element_redoEdit.classList.add('opacity-0_5'); } // Mouse function callback_UndoEditMouseDown(): void { leftArrowTimeoutID = setTimeout(() => { leftArrowIntervalID = setInterval(() => { callback_UndoEdit(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_UndoEditMouseLeave(): void { clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_UndoEditMouseUp(): void { clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_RedoEditMouseDown(): void { rightArrowTimeoutID = setTimeout(() => { rightArrowIntervalID = setInterval(() => { callback_RedoEdit(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_RedoEditMouseLeave(): void { clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } function callback_RedoEditMouseUp(): void { clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } // Fingers function callback_UndoEditTouchStart(): void { touchIsInsideLeft = true; leftArrowTimeoutID = setTimeout(() => { if (!touchIsInsideLeft) return; leftArrowIntervalID = setInterval(() => { callback_UndoEdit(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_UndoEditTouchMove(event: TouchEvent): void { if (!touchIsInsideLeft) return; const touch = event.touches[0]!; const rect = element_moveRewind.getBoundingClientRect(); if ( touch.clientX > rect.left && touch.clientX < rect.right && touch.clientY > rect.top && touch.clientY < rect.bottom ) return; touchIsInsideLeft = false; clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_UndoEditTouchEnd(): void { touchIsInsideLeft = false; clearTimeout(leftArrowTimeoutID); clearInterval(leftArrowIntervalID); } function callback_RedoEditTouchStart(): void { touchIsInsideRight = true; rightArrowTimeoutID = setTimeout(() => { if (!touchIsInsideRight) return; rightArrowIntervalID = setInterval(() => { callback_RedoEdit(); }, intervalToRepeat); }, timeToHoldMillis); } function callback_RedoEditTouchMove(event: TouchEvent): void { event = event || window.event; if (!touchIsInsideRight) return; const touch = event.touches[0]!; const rect = element_moveForward.getBoundingClientRect(); if ( touch.clientX > rect.left && touch.clientX < rect.right && touch.clientY > rect.top && touch.clientY < rect.bottom ) return; touchIsInsideRight = false; clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } function callback_RedoEditTouchEnd(): void { touchIsInsideRight = false; clearTimeout(rightArrowTimeoutID); clearInterval(rightArrowIntervalID); } /** Tests if the left arrow key has been pressed, signaling to undo an edit. */ function testIfUndoEdit(): void { if (!listener_document.isKeyDown('ArrowLeft')) return; callback_UndoEdit(); } /** Tests if the right arrow key has been pressed, signaling to redo and edit. */ function testIfRedoEdit(): void { if (!listener_document.isKeyDown('ArrowRight')) return; callback_RedoEdit(); } /** Undoes one edit */ function callback_UndoEdit(): void { if (!isItOkayToUndoEditOrRedoEdit()) return; lastRewindOrEdit = Date.now(); edithistory.undo(); } /** Redoes one edit. */ function callback_RedoEdit(): void { if (!isItOkayToUndoEditOrRedoEdit()) return; lastRewindOrEdit = Date.now(); edithistory.redo(); } export default { isOpen, open, close, updateElement_Coords, update_MoveButtons, update_EditButtons, callback_Pause, callback_Expand, lockRewind, update, isCoordinateActive, recenter, toggle, isAnnotationsButtonEnabled, areCoordsAllowedToBeEdited, getHeightOfNavBar, }; ================================================ FILE: src/client/scripts/esm/game/gui/guipause.ts ================================================ // src/client/scripts/esm/game/gui/guipause.ts /** * This script handles our Pause menu. */ import moveutil from '../../../../../shared/chess/util/moveutil.js'; import toast from './toast.js'; import arrows from '../rendering/arrows/arrows.js'; import docutil from '../../util/docutil.js'; import guititle from './guititle.js'; import gameslot from '../chess/gameslot.js'; import boardpos from '../rendering/boardpos.js'; import boarddrag from '../rendering/boarddrag.js'; import { Mouse } from '../input.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import drawoffers from '../misc/onlinegame/drawoffers.js'; import gameloader from '../chess/gameloader.js'; import perspective from '../rendering/perspective.js'; import guipractice from './guipractice.js'; import { GameBus } from '../GameBus.js'; import frametracker from '../rendering/frametracker.js'; import draganimation from '../rendering/dragging/draganimation.js'; import checkmatepractice from '../chess/checkmatepractice.js'; import { listener_document } from '../chess/game.js'; // Elements ------------------------------------------------------------------------------ const element_pauseUI: HTMLElement = document.getElementById('pauseUI')!; const element_resume: HTMLElement = document.getElementById('resume')!; const element_pointers: HTMLElement = document.getElementById('togglepointers')!; const element_copygame: HTMLElement = document.getElementById('copygame')!; const element_pastegame: HTMLElement = document.getElementById('pastegame')!; const element_mainmenu: HTMLButtonElement = document.getElementById( 'mainmenu', ) as HTMLButtonElement; const element_practicemenu: HTMLElement = document.getElementById('practicemenu')!; const element_offerDraw: HTMLElement = document.getElementById('offerdraw')!; const element_perspective: HTMLElement = document.getElementById('toggleperspective')!; // Constants --------------------------------------------------------------------------- /** Amount of milliseconds to freeze the Main Menu button after the text on it changes */ const MAIN_MENU_BUTTON_CHANGE_FREEZE_DURATION_MILLIS: number = 1000; // Variables ----------------------------------------------------------------------------- // Pause UI let isPaused: boolean = false; /** This is true if the main menu button says "Resign Game" or "Abort Game". In all other cases, this is false. */ let is_main_menu_button_used_as_resign_or_abort_button: boolean = false; // Events ----------------------------------------------------------------------------------- GameBus.addEventListener('game-concluded', () => { updateTextOfMainMenuButton(true); }); // Functions -------------------------------------------------------------------------------- /** Returns *true* if the game is currently paused. */ function areWePaused(): boolean { return isPaused; } /** Returns the perspective toggle button element. */ function getelement_perspective(): HTMLElement { return element_perspective; } /** Opens the pause menu. */ function open(): void { isPaused = true; updatePerspectiveButtonTransparency(); updateTextOfMainMenuButton(); updatePasteButtonTransparency(); if (checkmatepractice.areInCheckmatePractice()) { // Hide the draw offer button and show the Practice Menu button element_offerDraw.classList.add('hidden'); element_practicemenu.classList.remove('hidden'); } else { // Show the draw offer button and hide the Practice Menu button element_offerDraw.classList.remove('hidden'); element_practicemenu.classList.add('hidden'); updateDrawOfferButton(); } element_pauseUI.classList.remove('hidden'); initListeners(); boardpos.eraseMomentum(); boarddrag.cancelBoardDrag(); draganimation.dropPiece(); } /** Toggles the pause menu open or closed. */ function toggle(): void { if (!isPaused) open(); else callback_Resume(); } /** Updates the paste button's transparency depending on whether pasting is legal. */ function updatePasteButtonTransparency(): void { const gamefile = gameslot.getGamefile(); if (!gamefile) return; const moves = gamefile.boardsim.moves; const legalInPrivateMatch = onlinegame.areInOnlineGame() && onlinegame.getIsPrivate() && moves.length === 0; if (onlinegame.areInOnlineGame() && !legalInPrivateMatch) element_pastegame.classList.add('opacity-0_5'); else element_pastegame.classList.remove('opacity-0_5'); } /** Updates the perspective button's transparency depending on whether a mouse is supported. */ function updatePerspectiveButtonTransparency(): void { if (docutil.isMouseSupported()) element_perspective.classList.remove('opacity-0_5'); else element_perspective.classList.add('opacity-0_5'); } /** * Update the draw offer button's text content to either say "Offer Draw" * or "Accept Draw", and update its transparency depending on whether it's legal. */ function updateDrawOfferButton(): void { if (!isPaused) return; // Not paused, no point in updating button, because it's updated as soon as we pause the game // Should it say "offer draw" or "accept draw"? if (drawoffers.areWeAcceptingDraw()) { element_offerDraw.innerText = translations.accept_draw; // "Accept Draw" element_offerDraw.classList.remove('opacity-0_5'); return; } else element_offerDraw.innerText = translations.offer_draw; // "Offer Draw" // Update transparency if (drawoffers.isOfferingDrawLegal()) element_offerDraw.classList.remove('opacity-0_5'); else element_offerDraw.classList.add('opacity-0_5'); } /** Called when we receive an opponent's move, to update the pause menu buttons. */ function onReceiveOpponentsMove(): void { updateTextOfMainMenuButton(true); updateDrawOfferButton(); } /** * Updates the text content of the Main Menu button to either say "Main Menu", * "Abort Game", or "Resign Game", whichever is relevant in the situation. * @param freezeMainMenuButtonUponChange - If true, and the main menu changes from "Abort" to "Resign" or from "Resign"/"Abort" to "Main Menu", * we will disable it and grey it out for 1 second so the player doesn't accidentally click resign when they wanted to abort or "Main Menu" when they wanted to resign. * This should only be true when called from onReceiveOpponentsMove() or onReceiveGameConclusion(), not on open() */ function updateTextOfMainMenuButton(freezeMainMenuButtonUponChange?: true): void { if (!isPaused) return; const gamefile = gameslot.getGamefile(); if (!gamefile) return; if ( !onlinegame.areInOnlineGame() || onlinegame.hasServerConcludedGame() || onlinegame.hasPlayerPressedAbortOrResignButton() ) { // If the text currently says "Abort Game" or "Resign Game", freeze the button for 1 second in case the user clicked it RIGHT after it switched text! They may have tried to abort or resign and actually not want to exit to main menu. if ( freezeMainMenuButtonUponChange && element_mainmenu.textContent !== translations.main_menu ) freezeMainMenuButton(); element_mainmenu.textContent = translations.main_menu; is_main_menu_button_used_as_resign_or_abort_button = false; return; } is_main_menu_button_used_as_resign_or_abort_button = true; if (moveutil.isGameResignable(gamefile.basegame)) { // If the text currently says "Abort Game", freeze the button for 1 second in case the user clicked it RIGHT after it switched text! They may have tried to abort and actually not want to resign. if ( freezeMainMenuButtonUponChange && element_mainmenu.textContent !== translations.resign_game ) freezeMainMenuButton(); element_mainmenu.textContent = translations.resign_game; return; } element_mainmenu.textContent = translations.abort_game; } /** Temporarily disable the main menu button for a certain number of milliseconds */ function freezeMainMenuButton(): void { element_mainmenu.disabled = true; element_mainmenu.classList.add('opacity-0_5'); setTimeout(() => { element_mainmenu.disabled = false; element_mainmenu.classList.remove('opacity-0_5'); }, MAIN_MENU_BUTTON_CHANGE_FREEZE_DURATION_MILLIS); } /** Initializes event listeners for the pause menu buttons. */ function initListeners(): void { element_resume.addEventListener('click', callback_Resume); element_pointers.addEventListener('click', callback_ToggleArrows); element_copygame.addEventListener('click', callback_CopyGame); element_pastegame.addEventListener('click', callback_PasteGame); element_mainmenu.addEventListener('click', callback_MainMenu); element_practicemenu.addEventListener('click', callback_PracticeMenu); element_offerDraw.addEventListener('click', callback_OfferDraw); element_perspective.addEventListener('click', callback_Perspective); } /** Removes event listeners for the pause menu buttons. */ function closeListeners(): void { element_resume.removeEventListener('click', callback_Resume); element_pointers.removeEventListener('click', callback_ToggleArrows); element_copygame.removeEventListener('click', callback_CopyGame); element_pastegame.removeEventListener('click', callback_PasteGame); element_mainmenu.removeEventListener('click', callback_MainMenu); element_practicemenu.removeEventListener('click', callback_PracticeMenu); element_offerDraw.removeEventListener('click', callback_OfferDraw); element_perspective.removeEventListener('click', callback_Perspective); } /** Called when the copy game button is clicked. */ function callback_CopyGame(_event: Event): void { document.dispatchEvent(new Event('copy-game')); } /** Called when the paste game button is clicked. */ function callback_PasteGame(_event: Event): void { document.dispatchEvent(new Event('paste-game')); } /** Called when the resume button is clicked. */ function callback_Resume(): void { if (!isPaused) return; isPaused = false; element_pauseUI.classList.add('hidden'); closeListeners(); frametracker.onVisualChange(); } /** Called when the main menu button is clicked. */ function callback_MainMenu(): void { callback_Resume(); if (is_main_menu_button_used_as_resign_or_abort_button) onlinegame.onAbortOrResignButtonPress(); // Unload and exit game immediately if the button text says "Main Menu" else { // Let the onlinegame script know that the player willingly presses the "Main Menu" button. // This can happen if the server has informed him that game has ended or if the player has already pressed the "Resign" or "Abort" during this game. if (onlinegame.areInOnlineGame()) onlinegame.onMainMenuButtonPress(); gameloader.unloadGame(); guititle.open(); } } /** Called when the practice menu button is clicked. */ function callback_PracticeMenu(): void { callback_Resume(); gameloader.unloadGame(); guipractice.open(); } /** Called when the Offer Draw button is clicked in the pause menu */ function callback_OfferDraw(): void { // Are we accepting a draw? if (drawoffers.areWeAcceptingDraw()) { drawoffers.callback_AcceptDraw(); callback_Resume(); return; } // Not accepting. Is it legal to extend, then? if (drawoffers.isOfferingDrawLegal()) { drawoffers.extendOffer(); callback_Resume(); return; } toast.show("Can't offer draw."); } /** Called when the toggle arrows button is clicked. */ function callback_ToggleArrows(): void { arrows.toggleArrows(); const mode = arrows.getMode(); // prettier-ignore const text = mode === 0 ? translations.arrows_off : mode === 1 ? translations.arrows_defense : mode === 2 ? translations.arrows_all : translations.arrows_all_hippogonals; element_pointers.textContent = text; if (!isPaused) toast.show(translations.toggled + ' ' + text); } /** Called when the perspective button is clicked. */ function callback_Perspective(): void { // This prevents toggling perspective ON in the pause menu immediately erasing all annotations. listener_document.claimMouseClick(Mouse.LEFT); perspective.toggle(); } // Exports --------------------------------------------------------------------------------- export default { areWePaused, getelement_perspective, open, toggle, updateDrawOfferButton, onReceiveOpponentsMove, callback_Resume, callback_ToggleArrows, }; ================================================ FILE: src/client/scripts/esm/game/gui/guiplay.ts ================================================ // src/client/scripts/esm/game/gui/guiplay.ts /** * This script handles our Play page, containing our invite creation menu. */ import type { TimeControl } from '../../../../../shared/types.js'; import type { InviteOptions } from '../misc/invites.js'; import variant from '../../../../../shared/chess/variants/variant.js'; import timeutil from '../../../../../shared/util/timeutil.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import { VariantLeaderboards } from '../../../../../shared/chess/variants/validleaderboard.js'; import toast from './toast.js'; import invites from '../misc/invites.js'; import docutil from '../../util/docutil.js'; import guititle from './guititle.js'; import gameloader from '../chess/gameloader.js'; import LocalStorage from '../../util/LocalStorage.js'; import hydrochess_card from '../chess/engines/enginecards/hydrochess_card.js'; import usernamecontainer from '../../util/usernamecontainer.js'; import { engineDictionary } from '../chess/engines/engine.js'; // Elements -------------------------------------------------------------------- const element_menuExternalLinks = document.getElementById('menu-external-links')!; const element_PlaySelection = document.getElementById('play-selection')!; const element_playName = document.getElementById('play-name')!; const element_playBack = document.getElementById('play-back')!; const element_online = document.getElementById('online')!; const element_local = document.getElementById('local')!; const element_computer = document.getElementById('computer')!; const element_createInvite = document.getElementById('create-invite') as HTMLButtonElement; const element_optionCardColor = document.getElementById('option-card-color')!; const element_optionCardPrivate = document.getElementById('option-card-private')!; const element_optionCardRated = document.getElementById('option-card-rated')!; const element_optionCardClock = document.getElementById('option-card-clock')!; const element_optionVariant = document.getElementById('option-variant') as HTMLSelectElement; const element_optionClock = document.getElementById('option-clock') as HTMLSelectElement; const element_optionColor = document.getElementById('option-color') as HTMLSelectElement; const element_optionPrivate = document.getElementById('option-private') as HTMLSelectElement; const element_optionRated = document.getElementById('option-rated') as HTMLSelectElement; const element_optionRatedYes = document.getElementById('option-rated-yes') as HTMLOptionElement; const element_optionCardStrength = document.getElementById('option-card-strength'); const element_optionDifficulty = document.getElementById('option-difficulty') as HTMLSelectElement; const element_joinPrivate = document.getElementById('join-private')!; const element_inviteCode = document.getElementById('invite-code')!; const element_copyInviteCode = document.getElementById('copy-button')!; const element_joinPrivateMatch = document.getElementById('join-button') as HTMLButtonElement; const element_textboxPrivate = document.getElementById('textbox-private') as HTMLInputElement; // Constants -------------------------------------------------------------------- /** Selection option indices for some time controls. */ const TIME_CONTROL_IDXS = { '10M': 5, INFINITE: 12, } as const; // Variables -------------------------------------------------------------------- /** Whether the play screen is open */ let pageIsOpen: boolean = false; /** Whether we've selected "online", "local", or a "computer" game. */ let modeSelected: 'online' | 'local' | 'computer'; /** * Whether the create invite button is currently locked. * When we create an invite, the button is disabled until we hear back from the server. */ let createInviteButtonIsLocked: boolean = false; /** * Whether the *virtual* accept invite button is currently locked. * When we click invites to accept them. We have to temporarily disable * accepting invites so that we have spam protection and don't get the * "You are already in a game" server error. */ let acceptInviteButtonIsLocked: boolean = false; // Events -------------------------------------------------------------------------------- document.addEventListener('socket-closed', () => { /** * This unlocks the create invite and *virtual* accept invite buttons, * because we can't hope to receive their reply anytime soon, which * replyto number is what we look for to unlock these buttons, * we would never be able to click them again otherwise. */ unlockCreateInviteButton(); unlockAcceptInviteButton(); }); // Functions -------------------------------------------------------------------------------- /** Whether or not the play page is currently open, and the invites are visible. */ function isOpen(): boolean { return pageIsOpen; } /** Returns whether we've selected "online", "local", or a "computer" game. */ function getModeSelected(): typeof modeSelected { return modeSelected; } function hideElement_joinPrivate(): void { element_joinPrivate.classList.add('hidden'); } function showElement_joinPrivate(): void { element_joinPrivate.classList.remove('hidden'); } function hideElement_inviteCode(): void { element_inviteCode.classList.add('hidden'); } function showElement_inviteCode(): void { element_inviteCode.classList.remove('hidden'); } function open(): void { pageIsOpen = true; element_PlaySelection.classList.remove('hidden'); element_menuExternalLinks.classList.remove('hidden'); changePlayMode('online'); initListeners(); invites.subscribeToInvites(); // Subscribe to the invites list subscription service! } function close(): void { pageIsOpen = false; element_PlaySelection.classList.add('hidden'); element_menuExternalLinks.classList.add('hidden'); element_textboxPrivate.value = ''; // clear invite code hideElement_inviteCode(); closeListeners(); // This will auto-cancel our existing invite // IT ALSO clears the existing invites in the document! invites.unsubFromInvites(); } function initListeners(): void { element_playBack.addEventListener('click', callback_playBack); element_online.addEventListener('click', callback_online); element_local.addEventListener('click', callback_local); element_computer.addEventListener('click', callback_computer); element_createInvite.addEventListener('click', callback_createInvite); element_optionVariant.addEventListener('change', callback_updateOptions); element_optionColor.addEventListener('change', callback_updateOptions); element_optionClock.addEventListener('change', callback_updateOptions); element_optionPrivate.addEventListener('change', callback_updateOptions); element_optionRated.addEventListener('change', callback_updateOptions); element_joinPrivateMatch.addEventListener('click', callback_joinPrivate); element_copyInviteCode.addEventListener('click', callback_copyInviteCode); element_textboxPrivate.addEventListener('keyup', callback_textboxPrivateEnter); } function closeListeners(): void { element_playBack.removeEventListener('click', callback_playBack); element_online.removeEventListener('click', callback_online); element_local.removeEventListener('click', callback_local); element_computer.removeEventListener('click', callback_computer); element_createInvite.removeEventListener('click', callback_createInvite); element_optionVariant.removeEventListener('change', callback_updateOptions); element_optionColor.removeEventListener('change', callback_updateOptions); element_optionClock.removeEventListener('change', callback_updateOptions); element_optionPrivate.removeEventListener('change', callback_updateOptions); element_optionRated.removeEventListener('change', callback_updateOptions); element_joinPrivateMatch.removeEventListener('click', callback_joinPrivate); element_copyInviteCode.removeEventListener('click', callback_copyInviteCode); element_textboxPrivate.removeEventListener('keyup', callback_textboxPrivateEnter); } function changePlayMode(mode: typeof modeSelected): void { if (modeSelected === mode) return; // No change // online / local / computer if (mode === 'online' && createInviteButtonIsLocked) disableCreateInviteButton(); // Disable it immediately, it's still locked from the last time we clicked it (we quickly clicked "Local" then "Online" again before we heard back from the server) if (mode !== 'online' && invites.doWeHave()) element_createInvite.click(); // Simulate clicking to cancel our invite, BEFORE we switch modes (because if the mode is local it will just start the game) modeSelected = mode; if (mode === 'online') { element_playName.textContent = translations.menu_online; element_online.classList.add('selected'); element_local.classList.remove('selected'); element_online.classList.remove('not-selected'); element_local.classList.add('not-selected'); element_computer.classList.remove('selected'); element_computer.classList.add('not-selected'); element_createInvite.textContent = translations.invites.create_invite; element_optionCardColor.classList.remove('hidden'); element_optionCardRated.classList.remove('hidden'); element_optionCardPrivate.classList.remove('hidden'); // Patches bugs on some browsers where invite creations are sometimes sent with a blank "" private field. if (!element_optionPrivate.value) element_optionPrivate.value = 'public'; const localStorageClock = LocalStorage.loadItem('preferred_online_clock_invite_value'); element_optionCardClock.classList.remove('hidden'); element_optionClock.selectedIndex = localStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS['10M']; // 10m+4s element_joinPrivate.classList.remove('hidden'); const localStorageRated = LocalStorage.loadItem('preferred_rated_invite_value'); element_optionRated.value = localStorageRated !== undefined ? localStorageRated : 'casual'; // Casual callback_updateOptions(); // update displayed dropdown options, e.g. disable ranked if necessary if (element_optionCardStrength) element_optionCardStrength.classList.add('hidden'); // In non-engine modes, all variants remain available. for (const option of element_optionVariant.options) { option.hidden = false; } } else if (mode === 'local') { // Enabling the button doesn't necessarily unlock it. It's enabled for "local" so that we // can click "Start Game" at any point. But it will be re-disabled if we click "online" rapidly, // because it was still locked from us still waiting for the server's repsponse to our last create/cancel command. // add choose col enableCreateInviteButton(); element_playName.textContent = translations.menu_local; element_online.classList.remove('selected'); element_local.classList.add('selected'); element_online.classList.add('not-selected'); element_local.classList.remove('not-selected'); element_computer.classList.remove('selected'); element_computer.classList.add('not-selected'); element_createInvite.textContent = translations.invites.start_game; element_optionCardColor.classList.add('hidden'); element_optionCardRated.classList.add('hidden'); element_optionCardPrivate.classList.add('hidden'); element_optionCardClock.classList.remove('hidden'); const localStorageClock = LocalStorage.loadItem('preferred_local_clock_invite_value'); element_optionClock.selectedIndex = localStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS.INFINITE; // Infinite Time element_joinPrivate.classList.add('hidden'); element_inviteCode.classList.add('hidden'); if (element_optionCardStrength) element_optionCardStrength.classList.add('hidden'); // In non-engine modes, all variants remain available. for (const option of element_optionVariant.options) { option.hidden = false; } } else if (mode === 'computer') { // For now, until engines become stronger, time is not customizable. enableCreateInviteButton(); element_playName.textContent = translations.menu_computer; element_online.classList.remove('selected'); element_local.classList.remove('selected'); element_online.classList.add('not-selected'); element_local.classList.add('not-selected'); element_computer.classList.add('selected'); element_computer.classList.remove('not-selected'); element_createInvite.textContent = translations.invites.start_game; element_optionCardColor.classList.remove('hidden'); element_optionCardRated.classList.add('hidden'); element_optionCardPrivate.classList.add('hidden'); element_optionCardClock.classList.remove('hidden'); const localStorageClock = LocalStorage.loadItem('preferred_computer_clock_invite_value'); element_optionClock.selectedIndex = localStorageClock !== undefined ? localStorageClock : TIME_CONTROL_IDXS.INFINITE; // Infinite Time element_joinPrivate.classList.add('hidden'); element_inviteCode.classList.add('hidden'); if (element_optionCardStrength) element_optionCardStrength.classList.remove('hidden'); // Restrict the variant dropdown to the variants that HydroChess officially supports. for (const option of element_optionVariant.options) { // Keep options whose value is in the supported set; hide the rest. option.hidden = !hydrochess_card.SUPPORTED_VARIANTS.has(option.value); } const selectedVariant = element_optionVariant.value; if (!hydrochess_card.SUPPORTED_VARIANTS.has(selectedVariant)) { element_optionVariant.value = 'Classical'; } } } function callback_playBack(): void { close(); guititle.open(); } function callback_online(): void { changePlayMode('online'); } function callback_local(): void { changePlayMode('local'); } function callback_computer(): void { changePlayMode('computer'); } // Also starts local games function callback_createInvite(): void { const inviteOptions = getInviteOptions(); if (modeSelected === 'local') { close(); // Close the invite creation screen // Actually load the game gameloader.startLocalGame({ variant: inviteOptions.variant, timeControl: inviteOptions.clock, }); } else if (modeSelected === 'online') { if (invites.doWeHave()) invites.cancel(); else invites.create(inviteOptions); } else if (modeSelected === 'computer') { close(); // Close the invite creation screen const variantName = variant.getVariantName(inviteOptions.variant); const ourColor = inviteOptions.color ?? (Math.random() > 0.5 ? p.WHITE : p.BLACK); const { strengthLevel } = getEngineDifficultyConfig(); const currentEngine = 'hydrochess'; gameloader.startEngineGame({ event: `Casual computer ${variantName} infinite chess game`, timeControl: inviteOptions.clock, variant: inviteOptions.variant, youAreColor: ourColor, currentEngine, engineConfig: { engineTimeLimitPerMoveMillis: engineDictionary[currentEngine].defaultTimeLimitPerMoveMillis, strengthLevel, }, }); } } /** * Returns an object containing the values of each of * the invite options on the invite creation screen. */ function getInviteOptions(): InviteOptions { const strcolor = element_optionColor.value; const color = strcolor === 'White' ? p.WHITE : strcolor === 'Black' ? p.BLACK : null; const selectedVariant = element_optionVariant.value; if (!variant.isVariantValid(selectedVariant)) throw Error(`Invite option variant "${selectedVariant}" is not a valid variant.`); return { variant: selectedVariant, clock: element_optionClock.value as TimeControl, color, private: element_optionPrivate.value as 'public' | 'private', rated: element_optionRated.value as 'casual' | 'rated', }; } function getEngineDifficultyConfig(): { strengthLevel: number } { if (!element_optionDifficulty) { return { strengthLevel: 3 }; } const value = element_optionDifficulty.value; switch (value) { case 'easy': return { strengthLevel: 1 }; case 'medium': return { strengthLevel: 2 }; case 'hard': default: return { strengthLevel: 3 }; } } // Call whenever the Variant, Clock, Color or Private inputs change, or play mode changes function callback_updateOptions(): void { // save prefered clock option savePreferredClockOption(element_optionClock.selectedIndex); savePreferredRatedOption(element_optionRated.value); // check if rated games should be enabled in online mode if (modeSelected !== 'online') return; const variantValue = element_optionVariant.value; const clockValue = element_optionClock.value; const colorValue = element_optionColor.value; const privateValue = element_optionPrivate.value; // conditions for enabling Rated games: if ( variantValue in VariantLeaderboards && clockValue !== '-' && (colorValue === 'Random' || privateValue === 'private') ) { element_optionRatedYes.disabled = false; } else { element_optionRated.value = 'casual'; element_optionRatedYes.disabled = true; } } function savePreferredClockOption(clockIndex: number): void { const localOrOnline = modeSelected; // For search results: preferred_local_clock_invite_value preferred_online_clock_invite_value LocalStorage.saveItem( `preferred_${localOrOnline}_clock_invite_value`, clockIndex, timeutil.getTotalMilliseconds({ days: 7 }), ); } function savePreferredRatedOption(ratedValue: string): void { LocalStorage.saveItem( `preferred_rated_invite_value`, ratedValue, timeutil.getTotalMilliseconds({ years: 1 }), ); } function callback_joinPrivate(): void { const code = element_textboxPrivate.value.toLowerCase(); if (code.length !== 5) return toast.show(translations.invite_error_digits); element_joinPrivateMatch.disabled = true; // Re-enable when the code is changed const isPrivate = true; invites.accept(code, isPrivate); } function callback_textboxPrivateEnter(event: KeyboardEvent): void { // 13 is the key code for Enter key if (event.keyCode === 13) { if (!element_joinPrivateMatch.disabled) callback_joinPrivate(); } else element_joinPrivateMatch.disabled = false; // Re-enable when the code is changed } function callback_copyInviteCode(): void { if (!modeSelected.includes('online')) return; if (!invites.doWeHave()) return; // Copy our private invite code. const code = invites.gelement_iCodeCode().textContent; docutil.copyToClipboard(code); toast.show(translations.invite_copied); } function initListeners_Invites(): void { const invites = document.querySelectorAll('.invite'); invites.forEach((element) => { element.addEventListener('mouseenter', callback_inviteMouseEnter); element.addEventListener('mouseleave', callback_inviteMouseLeave); element.addEventListener('click', callback_inviteClicked); }); } function closeListeners_Invites(): void { const invites = document.querySelectorAll('.invite'); invites.forEach((element) => { element.removeEventListener('mouseenter', callback_inviteMouseEnter); element.removeEventListener('mouseleave', callback_inviteMouseLeave); element.removeEventListener('click', callback_inviteClicked); }); } function callback_inviteMouseEnter(event: Event): void { (event.target as HTMLElement).classList.add('hover'); } function callback_inviteMouseLeave(event: Event): void { (event.target as HTMLElement).classList.remove('hover'); } function callback_inviteClicked(event: Event): void { if (usernamecontainer.wasEventClickInsideUsernameContainer(event as MouseEvent)) { // console.log('Clicked on a username embed, ignoring click'); return; } invites.click((event as MouseEvent).currentTarget as HTMLElement); } /** * Locks the create invite button to disable it. * When we hear the response from the server, we will re-enable it. */ function lockCreateInviteButton(): void { createInviteButtonIsLocked = true; // ONLY ACTUALLY disabled the button if we're on the "online" screen if (modeSelected !== 'online') return; element_createInvite.disabled = true; // console.log('Locked create invite button.'); } /** * Unlocks the create invite button to re-enable it. * We have heard a response from the server, and are allowed * to try to cancel/create an invite again. */ function unlockCreateInviteButton(): void { createInviteButtonIsLocked = false; element_createInvite.disabled = false; // console.log('Unlocked create invite button.'); } function disableCreateInviteButton(): void { element_createInvite.disabled = true; } function enableCreateInviteButton(): void { element_createInvite.disabled = false; } function setElement_CreateInviteTextContent(text: string): void { element_createInvite.textContent = text; } /** Whether the Create Invite button is locked. */ function isCreateInviteButtonLocked(): boolean { return createInviteButtonIsLocked; } /** * Locks the *virtual* accept invite button to disable clicking other people's invites. * When we hear the response from the server, we will re-enable this. */ function lockAcceptInviteButton(): void { acceptInviteButtonIsLocked = true; // console.log('Locked accept invite button.'); } /** * Unlocks the accept invite button to re-enable it. * We have heard a response from the server, and are allowed * to try to cancel/create an invite again. */ function unlockAcceptInviteButton(): void { acceptInviteButtonIsLocked = false; // console.log('Unlocked accept invite button.'); } /** * Whether the *virtual* Accept Invite button is locked. * If it's locked, this means we temporarily cannot click other people's invites. */ function isAcceptInviteButtonLocked(): boolean { return acceptInviteButtonIsLocked; } // Exports ------------------------------------------------------------ export default { isOpen, hideElement_joinPrivate, showElement_joinPrivate, hideElement_inviteCode, showElement_inviteCode, getModeSelected, open, close, setElement_CreateInviteTextContent, initListeners_Invites, closeListeners_Invites, lockCreateInviteButton, unlockCreateInviteButton, isCreateInviteButtonLocked, lockAcceptInviteButton, unlockAcceptInviteButton, isAcceptInviteButtonLocked, }; ================================================ FILE: src/client/scripts/esm/game/gui/guipractice.ts ================================================ // src/client/scripts/esm/game/gui/guipractice.ts /* * This script handles our Practice page, containing * our practice selection menu. */ import typeutil from '../../../../../shared/chess/util/typeutil.js'; import icnconverter from '../../../../../shared/chess/logic/icn/icnconverter.js'; import validcheckmates from '../../../../../shared/chess/util/validcheckmates.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import style from './style.js'; import guititle from './guititle.js'; import svgcache from '../../chess/rendering/svgcache.js'; import validatorama from '../../util/validatorama.js'; import checkmatepractice from '../chess/checkmatepractice.js'; // Variables ---------------------------------------------------------------------------- const element_menuExternalLinks: HTMLElement = document.getElementById('menu-external-links')!; const element_practiceSelection: HTMLElement = document.getElementById('practice-selection')!; const element_practiceBack: HTMLElement = document.getElementById('practice-back')!; const element_practicePlay: HTMLElement = document.getElementById('practice-play')!; const element_progress: HTMLElement = document.querySelector('.checkmate-progress')!; const element_progressBar: HTMLElement = document.querySelector('.checkmate-progress-bar')!; const element_checkmateList: HTMLElement = document.querySelector('.checkmate-list')!; const element_checkmates: HTMLElement = document.getElementById('checkmates')!; const element_checkmateBadgeBronze = document.getElementById('checkmate-badge-bronze'); const element_checkmateBadgeBronzeImage = document.querySelector( '#checkmate-badge-bronze img', ) as HTMLElement; const elements_checkmateBadgeBronzeShine = document.querySelectorAll( '#checkmate-badge-bronze .shine-clockwise, #checkmate-badge-bronze .shine-anticlockwise', ); const element_checkmateBadgeSilver = document.getElementById('checkmate-badge-silver'); const element_checkmateBadgeSilverImage = document.querySelector( '#checkmate-badge-silver img', ) as HTMLElement; const elements_checkmateBadgeSilverShine = document.querySelectorAll( '#checkmate-badge-silver .shine-clockwise, #checkmate-badge-silver .shine-anticlockwise', ); const element_checkmateBadgeGold = document.getElementById('checkmate-badge-gold'); const element_checkmateBadgeGoldImage = document.querySelector( '#checkmate-badge-gold img', ) as HTMLElement; const elements_checkmateBadgeGoldShine = document.querySelectorAll( '#checkmate-badge-gold .shine-clockwise, #checkmate-badge-gold .shine-anticlockwise', ); let checkmateSelectedID: string = validcheckmates.validCheckmates.easy[0]!; // id of selected checkmate let indexSelected: number = 0; // index of selected checkmate among its brothers and sisters let generatedHTML: boolean = false; /** Whether the svgs of all the pieces in the checkmates list have been appended to the doc */ let generatedIcons: boolean = false; /** Variables for controlling the scrolling of the checkmate list */ const SCROLL: { mouseIsDown: boolean; mouseMovedAfterClick: boolean; scrollTop: number; startY: number; lastY: number; velocity: number; momentumInterval: ReturnType | undefined; friction: number; } = { mouseIsDown: false, mouseMovedAfterClick: true, scrollTop: 0, startY: 0, lastY: 0, velocity: 0, momentumInterval: undefined, friction: 0.9, }; /** Whether the practice page is open */ let isOpen: boolean = false; // Functions ------------------------------------------------------------------------ // Set an event listener, for when the theme changes, to re-generate the icons, as their color may change document.addEventListener('theme-change', () => { removePieceIcons(); // Remove the existing icons if (isOpen) addPieceIcons(); // Regenerate the icons so they can update their color, if the new theme has different color arguments }); /** * Returns the last selected checkmate practce. Useful * for knowing which one we just beat. */ function getCheckmateSelectedID(): string { return checkmateSelectedID; } function open(): void { isOpen = true; element_practiceSelection.classList.remove('hidden'); element_menuExternalLinks.classList.remove('hidden'); if (!generatedHTML) createPracticeHTML(); if (!generatedIcons) addPieceIcons(); changeCheckmateSelected(checkmateSelectedID); checkmatepractice.updateCompletedCheckmates(); initListeners(); } function close(): void { isOpen = false; clearScrollMomentumInterval(); element_practiceSelection.classList.add('hidden'); element_menuExternalLinks.classList.add('hidden'); closeListeners(); } /** * On first practice page load, generate list of checkmate HTML elements to be shown on page */ function createPracticeHTML(): void { for (const [difficulty, checkmates] of Object.entries(validcheckmates.validCheckmates)) { checkmates.forEach((checkmateID: string) => { const piecelist: RegExpMatchArray | null = checkmateID.match(/[0-9]+[a-zA-Z]+/g); if (!piecelist) return; const checkmatePuzzle = document.createElement('div'); checkmatePuzzle.className = 'checkmate unselectable'; checkmatePuzzle.id = checkmateID; const completionMark = document.createElement('div'); completionMark.className = 'completion-mark'; const piecelistW = document.createElement('div'); piecelistW.className = 'piecelistW'; const versusText = document.createElement('div'); versusText.className = 'checkmate-child versus'; versusText.textContent = translations.versus; const piecelistB = document.createElement('div'); piecelistB.className = 'piecelistB'; const checkmateDifficulty = document.createElement('div'); checkmateDifficulty.className = 'checkmate-difficulty'; // @ts-ignore checkmateDifficulty.textContent = translations[difficulty]; for (const entry of piecelist) { const amount: number = parseInt(entry.match(/[0-9]+/)![0]); // number of pieces to be placed const shortPiece: string = entry.match(/[a-zA-Z]+/)![0]; // piecetype to be placed const longPiece = icnconverter.getTypeFromAbbr(shortPiece); for (let j = 0; j < amount; j++) { const pieceDiv = document.createElement('div'); pieceDiv.className = `checkmatepiece ${longPiece}`; const containerDiv = document.createElement('div'); // prettier-ignore const collation = (j === 0 ? "" : (shortPiece === "Q" || shortPiece === "AM" ? " collated" : " collated-strong")); containerDiv.className = `checkmate-child checkmatepiececontainer${collation}`; containerDiv.appendChild(pieceDiv); if (typeutil.getColorFromType(longPiece) === p.WHITE) piecelistW.appendChild(containerDiv); else piecelistB.appendChild(containerDiv); } } checkmatePuzzle.appendChild(completionMark); checkmatePuzzle.appendChild(piecelistW); checkmatePuzzle.appendChild(versusText); checkmatePuzzle.appendChild(piecelistB); checkmatePuzzle.appendChild(checkmateDifficulty); element_checkmates.appendChild(checkmatePuzzle); }); } generatedHTML = true; } async function addPieceIcons(): Promise { // let sprites = await svgcache.getSVGElements(); const spritenames = new Set(); const sprites: { [pieceType: string]: SVGElement } = {}; for (const checkmate of element_checkmates.children) { for (const piece of checkmate .getElementsByClassName('piecelistW')[0]! .getElementsByClassName('checkmatepiececontainer')) { const actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!; spritenames.add(Number(actualpiece.className.split(' ')[1]!)); } const pieceBlack = checkmate .getElementsByClassName('piecelistB')[0]! .getElementsByClassName('checkmatepiececontainer')[0]!; const actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!; spritenames.add(Number(actualpieceBlack.className.split(' ')[1]!)); } const spriteSVGs = await svgcache.getSVGElements([...spritenames]); for (const svg of spriteSVGs) { sprites[svg.id] = svg; } for (const checkmate of element_checkmates.children) { for (const piece of checkmate .getElementsByClassName('piecelistW')[0]! .getElementsByClassName('checkmatepiececontainer')) { const actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!; actualpiece.appendChild(sprites[actualpiece.className.split(' ')[1]!]!.cloneNode(true)); } const pieceBlack = checkmate .getElementsByClassName('piecelistB')[0]! .getElementsByClassName('checkmatepiececontainer')[0]!; const actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!; const spriteBlack = sprites[actualpieceBlack.className.split(' ')[1]!]!.cloneNode(true); actualpieceBlack.appendChild(spriteBlack); } generatedIcons = true; } /** * Removes the piece icons from the checkmate lists. * Called when the theme changes. */ function removePieceIcons(): void { for (const checkmate of element_checkmates.children) { for (const piece of checkmate .getElementsByClassName('piecelistW')[0]! .getElementsByClassName('checkmatepiececontainer')) { const actualpiece = piece.getElementsByClassName('checkmatepiece')[0]!; while (actualpiece.firstChild) { actualpiece.removeChild(actualpiece.firstChild); } } const pieceBlack = checkmate .getElementsByClassName('piecelistB')[0]! .getElementsByClassName('checkmatepiececontainer')[0]!; const actualpieceBlack = pieceBlack.getElementsByClassName('checkmatepiece')[0]!; while (actualpieceBlack.firstChild) { actualpieceBlack.removeChild(actualpieceBlack.firstChild); } } generatedIcons = false; // Reset the icon generation flag } function initListeners(): void { element_practiceBack.addEventListener('click', callback_practiceBack); element_practicePlay.addEventListener('click', callback_practicePlay); document.addEventListener('keydown', callback_keyPress); document.addEventListener('mouseup', callback_mouseUp); document.addEventListener('mousemove', callback_mouseMove); element_checkmateList.addEventListener('mousedown', callback_mouseDown); for (const element of element_checkmates.children) { (element as HTMLElement).addEventListener('mouseup', callback_mouseUp); element.addEventListener('dblclick', callback_practicePlay); // Simulate clicking "Play" } } function closeListeners(): void { element_practiceBack.removeEventListener('click', callback_practiceBack); element_practicePlay.removeEventListener('click', callback_practicePlay); document.removeEventListener('keydown', callback_keyPress); document.removeEventListener('mouseup', callback_mouseUp); document.removeEventListener('mousemove', callback_mouseMove); element_checkmateList.removeEventListener('mousedown', callback_mouseDown); for (const element of element_checkmates.children) { (element as HTMLElement).removeEventListener('mouseup', callback_mouseUp); element.removeEventListener('dblclick', callback_practicePlay); // Simulate clicking "Play" } } // Scrolling list with the left mouse button ------------------------------------------------ function callback_mouseDown(event: MouseEvent): void { SCROLL.mouseIsDown = true; SCROLL.mouseMovedAfterClick = false; SCROLL.startY = event.pageY - element_checkmateList.offsetTop; SCROLL.scrollTop = element_checkmateList.scrollTop; SCROLL.velocity = 0; clearScrollMomentumInterval(); } function callback_mouseUp(event: MouseEvent): void { SCROLL.mouseIsDown = false; if (!(event.currentTarget as HTMLElement).id) return; // mouse not on checkmate target if (SCROLL.mouseMovedAfterClick) { applyMomentum(); return; } changeCheckmateSelected((event.currentTarget as HTMLElement).id); indexSelected = style.getElementIndexWithinItsParent(event.currentTarget as HTMLElement); } function callback_mouseMove(event: MouseEvent): void { SCROLL.mouseMovedAfterClick = true; if (!SCROLL.mouseIsDown) return; event.preventDefault(); const y = event.pageY - element_checkmateList.offsetTop; const walkY = y - SCROLL.startY; element_checkmateList.scrollTop = SCROLL.scrollTop - walkY; SCROLL.velocity = event.pageY - SCROLL.lastY; SCROLL.lastY = event.pageY; } function applyMomentum(): void { SCROLL.momentumInterval = setInterval(() => { if (Math.abs(SCROLL.velocity) < 0.5) { clearScrollMomentumInterval(); return; } element_checkmateList.scrollTop -= SCROLL.velocity; SCROLL.velocity *= SCROLL.friction; }, 16); // Approx. 60fps } function clearScrollMomentumInterval(): void { clearInterval(SCROLL.momentumInterval); SCROLL.momentumInterval = undefined; } // End of scrolling --------------------------------------------------------------------- function changeCheckmateSelected(checkmateid: string): void { for (const element of element_checkmates.children) { if (checkmateid === element.id) { element.classList.add('selected'); checkmateSelectedID = checkmateid; element.scrollIntoView({ behavior: 'instant', block: 'nearest' }); } else { element.classList.remove('selected'); } } } /** * Updates each checkmate practice element's 'beaten' class, along with the progress bar on top. * Checkmates that have the 'beaten' class are green with a checkmark on the left. * @param completedCheckmates - A list of checkmate strings we have beaten: `[ "2Q-1k", "3R-1k", "2CH-1k"]` */ function updateCheckmatesBeaten(completedCheckmates: string[]): void { let numCompleted = 0; for (const element of element_checkmates.children) { // What is the id string of this checkmate? const id_string = element.id; // "2Q-1k" // If this id is inside our list of beaten checkmates, add the beaten class if (completedCheckmates.includes(id_string)) { element.classList.add('beaten'); numCompleted++; } else element.classList.remove('beaten'); } // Update the progress and progress bar const numTotal = Object.values(validcheckmates.validCheckmates).flat().length; element_progress.textContent = `${numCompleted} / ${numTotal}`; const percentageBeaten = (100 * numCompleted) / numTotal; element_progressBar.style.background = `linear-gradient(to right, rgba(0, 163, 0, 0.3) ${percentageBeaten}%, transparent ${percentageBeaten}%)`; // Update the badges updateBadges(numCompleted, numTotal); } /** * Updates the styling of the badges on the progress bar, * to grey-out the unearned ones and shine the earned ones. * And also update their tooltips. * @param numCompleted - Number of checkmates completed * @param numTotal - Total number of checkmates */ function updateBadges(numCompleted: number, numTotal: number): void { const areLoggedIn = validatorama.areWeLoggedIn(); // Configuration for each badge type const badgeConfigs = [ { element: element_checkmateBadgeBronze, image: element_checkmateBadgeBronzeImage, shines: elements_checkmateBadgeBronzeShine, threshold: 0.5, earnedKey: 'checkmate_bronze', unearnedKey: 'checkmate_bronze_unearned', }, { element: element_checkmateBadgeSilver, image: element_checkmateBadgeSilverImage, shines: elements_checkmateBadgeSilverShine, threshold: 0.75, earnedKey: 'checkmate_silver', unearnedKey: 'checkmate_silver_unearned', }, { element: element_checkmateBadgeGold, image: element_checkmateBadgeGoldImage, shines: elements_checkmateBadgeGoldShine, threshold: 1, earnedKey: 'checkmate_gold', unearnedKey: 'checkmate_gold_unearned', }, ] as const; badgeConfigs.forEach((config) => { if (!config.element || !config.image) return; const isEarned = numCompleted >= config.threshold * numTotal && areLoggedIn; const tooltip = isEarned ? translations[config.earnedKey] : areLoggedIn ? translations[config.unearnedKey] : translations.checkmate_logged_out; config.element.setAttribute('data-tooltip', tooltip); // Update tooltip config.image.classList.toggle('unearned', !isEarned); // Update badge appearance config.shines?.forEach((shine) => shine.classList.toggle('hidden', !isEarned)); // Update shine elements }); } function callback_practiceBack(_event: Event): void { close(); guititle.open(); } function callback_practicePlay(): void { close(); checkmatepractice.startCheckmatePractice(checkmateSelectedID); } /** If enter is pressed, click Play. Or if arrow keys are pressed, move up and down selection */ function callback_keyPress(event: KeyboardEvent): void { if (event.key === 'Enter') callback_practicePlay(); else if (event.key === 'ArrowDown') moveDownSelection(event); else if (event.key === 'ArrowUp') moveUpSelection(event); } function moveDownSelection(event: Event): void { event.preventDefault(); if (indexSelected >= element_checkmates.children.length - 1) return; clearScrollMomentumInterval(); indexSelected++; const newSelectionElement = element_checkmates.children[indexSelected]!; changeCheckmateSelected(newSelectionElement.id); } function moveUpSelection(event: Event): void { event.preventDefault(); if (indexSelected <= 0) return; clearScrollMomentumInterval(); indexSelected--; const newSelectionElement = element_checkmates.children[indexSelected]!; changeCheckmateSelected(newSelectionElement.id); } // Exports ------------------------------------------------------------------------ export default { getCheckmateSelectedID, open, updateCheckmatesBeaten, }; ================================================ FILE: src/client/scripts/esm/game/gui/guipromotion.ts ================================================ // src/client/scripts/esm/game/gui/guipromotion.ts /** * This script handles our promotion menu, when * pawns reach the promotion line. */ import type { Player, PlayerGroup, RawType } from '../../../../../shared/chess/util/typeutil.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import svgcache from '../../chess/rendering/svgcache.js'; import selection from '../chess/selection.js'; import { Mouse } from '../input.js'; import { GameBus } from '../GameBus.js'; import { listener_overlay } from '../chess/game.js'; // Variables -------------------------------------------------------------------- const PromotionGUI: { base: HTMLElement; players: PlayerGroup; } = { base: document.getElementById('promote')!, players: { [p.WHITE]: document.getElementById('promotewhite')!, [p.BLACK]: document.getElementById('promoteblack')!, }, }; let selectionOpen = false; // True when promotion GUI visible. Do not listen to navigational controls in the mean time // Events ----------------------------------------------------------------------- GameBus.addEventListener('piece-unselected', () => { close(); }); GameBus.addEventListener('game-unloaded', () => { resetUI(); }); // Functions -------------------------------------------------------------------- // Prevent right-clicking on the promotion UI PromotionGUI.base.addEventListener('contextmenu', (event) => event.preventDefault()); function isUIOpen(): boolean { return selectionOpen; } function open(color: Player): void { selectionOpen = true; PromotionGUI.base.classList.remove('hidden'); if (!(color in PromotionGUI.players)) throw new Error(`Promotion UI does not support color "${color}"`); PromotionGUI.players[color]!.classList.remove('hidden'); } /** Closes the promotion UI */ function close(): void { // console.error('Closing promotion UI'); selectionOpen = false; for (const element of Object.values(PromotionGUI.players)) { element.classList.add('hidden'); } PromotionGUI.base.classList.add('hidden'); } /** * Inits the promotion UI. Hides promotions not allowed, reveals promotions allowed. * @param promotionsAllowed - An object that contains the information about what promotions are allowed. * It contains 2 properties, `white` and `black`, both of which are arrays which may look like `['queens', 'bishops']`. */ async function initUI(promotionsAllowed: PlayerGroup | undefined): Promise { if (promotionsAllowed === undefined) return; if (Object.values(PromotionGUI.players).some((element) => element.childElementCount > 0)) { throw new Error( 'Must reset promotion UI before initiating it, or promotions leftover from the previous game will bleed through.', ); } for (const [playerString, rawtypes] of Object.entries(promotionsAllowed)) { const player = Number(playerString) as Player; if (!(player in PromotionGUI.players)) { console.error(`Player ${player} has a promotion but not promotion UI`); continue; } const svgs = await svgcache.getSVGElements( rawtypes.map((rawPromotion) => typeutil.buildType(rawPromotion, player)), ); svgs.forEach((svg) => { svg.classList.add('promotepiece'); svg.addEventListener('click', callback_promote); PromotionGUI.players[player]!.appendChild(svg); }); } } /** Resets the promotion UI by clearing all promotion options. */ function resetUI(): void { for (const playerPromo of Object.values(PromotionGUI.players)) { while (playerPromo.firstChild) { const svg = playerPromo.firstChild; svg.removeEventListener('click', callback_promote); playerPromo.removeChild(svg); } } } function callback_promote(event: Event): void { const type = Number((event.currentTarget as HTMLElement).id); // TODO: Dispatch a custom 'promote-selected' event! // That way this script doesn't depend on selection.js selection.promoteToType(type); close(); } /** Closes the UI if the mouse clicks outside it. */ function update(): void { if (!selectionOpen) return; if ( !listener_overlay.isMouseDown(Mouse.LEFT) && !listener_overlay.isMouseDown(Mouse.RIGHT) && !listener_overlay.isMouseDown(Mouse.MIDDLE) ) return; // Atleast one mouse button was clicked-down OUTSIDE of the promotion UI selection.unselectPiece(); // Already closes } export default { isUIOpen, open, close, initUI, resetUI, update, }; ================================================ FILE: src/client/scripts/esm/game/gui/guititle.ts ================================================ // src/client/scripts/esm/game/gui/guititle.ts /** * This script handles our Title Screen */ import guiplay from './guiplay.js'; import guipractice from './guipractice.js'; import guiboardeditor from './boardeditor/guiboardeditor.js'; import languagedropdown from '../../components/header/dropdowns/languagedropdown.js'; // Variables ---------------------------------------------------------------------------- // Title Screen const boardVel = 0.6; // Speed at which board slowly moves while on title screen const titleElement = document.getElementById('title')!; // Visible when on the title screen const element_play = document.getElementById('play')!; const element_practice = document.getElementById('practice')!; const element_guide = document.getElementById('rules')!; const element_boardEditor = document.getElementById('board-editor')!; const element_menuExternalLinks = document.getElementById('menu-external-links')!; // Functions ---------------------------------------------------------------------------- // Call when title screen is loaded function open(): void { titleElement.classList.remove('hidden'); element_menuExternalLinks.classList.remove('hidden'); initListeners(); } function close(): void { titleElement.classList.add('hidden'); element_menuExternalLinks.classList.add('hidden'); closeListeners(); } function initListeners(): void { element_play.addEventListener('click', callback_Play); element_practice.addEventListener('click', callback_Practice); element_guide.addEventListener('click', callback_Guide); // element_boardEditor.addEventListener('click', gui.displayStatus_FeaturePlanned); // ENABLE WHEN board editor is ready element_boardEditor.addEventListener('click', callback_BoardEditor); } function closeListeners(): void { element_play.removeEventListener('click', callback_Play); element_practice.removeEventListener('click', callback_Practice); element_guide.removeEventListener('click', callback_Guide); // element_boardEditor.removeEventListener('click', gui.displayStatus_FeaturePlanned); // ENABLE WHEN board editor is ready element_boardEditor.removeEventListener('click', callback_BoardEditor); } function callback_Play(_event: Event): void { close(); guiplay.open(); } function callback_Practice(_event: Event): void { close(); guipractice.open(); } function callback_Guide(_event: Event): void { // Navigate to the guide page window.location.href = languagedropdown.addLngQueryParamToLink(`/guide`); } function callback_BoardEditor(_event: Event): void { close(); guiboardeditor.open(); } export default { boardVel, open, close, }; ================================================ FILE: src/client/scripts/esm/game/gui/loadingscreen.ts ================================================ // src/client/scripts/esm/game/gui/loadingscreen.ts /** * This script manages the spinny pawn loading animation * while a game is loading both the LOGICAL and * GRAPHICAL (spritesheet) aspects. */ import themes from '../../../../../shared/components/header/themes.js'; import style from './style.js'; import thread from '../../util/thread.js'; import preferences from '../../components/header/preferences.js'; const loadingScreen: HTMLElement = document.querySelector('.game-loading-screen') as HTMLElement; /** Lower = loading checkerboard closer to black */ const darknessLevel = 0.22; /** Percentage of the viewport minimum. 0-100 */ const widthOfTiles = 16; const element_spinnyPawn = document.querySelector('.game-loading-screen .spinny-pawn'); const element_loadingError = document.querySelector('.game-loading-screen .loading-error'); const element_loadingErrorText = document.querySelector('.game-loading-screen .loading-error-text'); (function init(): void { initColorOfLoadingBackground(); document.addEventListener('theme-change', initColorOfLoadingBackground); })(); function initColorOfLoadingBackground(): void { const theme = preferences.getTheme(); const lightTiles = themes.getPropertyOfTheme(theme, 'lightTiles'); lightTiles[3] = 1; const darkTiles = themes.getPropertyOfTheme(theme, 'darkTiles'); darkTiles[3] = 1; for (let i = 0; i < 3; i++) { // Darken the color lightTiles[i]! *= darknessLevel; darkTiles[i]! *= darknessLevel; } const lightTilesCSS = style.arrayToCssColor(lightTiles); const darkTilesCSS = style.arrayToCssColor(darkTiles); loadingScreen!.style.background = `repeating-conic-gradient(${darkTilesCSS} 0% 25%, ${lightTilesCSS} 0% 50%) 50% / ${widthOfTiles}vmin ${widthOfTiles}vmin`; } async function open(): Promise { loadingScreen.classList.remove('transparent'); // This gives the document a chance to repaint, as otherwise our javascript // will continue to run until the next animation frame, which could be a long time. // FOR SOME REASON sometimes it occasionally still doesn't repaint unless this is ~10??? Idk why await thread.sleep(10); } async function close(): Promise { loadingScreen.classList.add('transparent'); // Hide the error text and show the spinny pawn element_spinnyPawn!.classList.remove('hidden'); element_loadingError!.classList.add('hidden'); // This gives the document a chance to repaint, as otherwise our javascript // will continue to run until the next animation frame, which could be a long time. await thread.sleep(0); } async function onError(): Promise { // const type = event.type; // Event type: "error"/"abort" // const target = event.target; // Element that triggered the event // const elementType = target?.tagName.toLowerCase(); // const sourceURL = target?.src || target?.href; // URL of the resource that failed to load // console.error(`Event ${type} ocurred loading ${elementType} at ${sourceURL}.`); element_spinnyPawn!.classList.add('hidden'); // Show the ERROR text element_loadingError!.classList.remove('hidden'); // const lostNetwork = !navigator.onLine; // element_loadingErrorText!.textContent = lostNetwork ? translations['lost_network'] : translations['failed_to_load']; element_loadingErrorText!.textContent = translations.failed_to_load; // This gives the document a chance to repaint, as otherwise our javascript // will continue to run until the next animation frame, which could be a long time. await thread.sleep(0); } export default { open, close, onError, }; ================================================ FILE: src/client/scripts/esm/game/gui/stats.ts ================================================ // src/client/scripts/esm/game/gui/stats.ts /** * This script renders the stats in the corner of the screen (Similar to Minecraft's f3 menu): * * Move number * FPS */ import moveutil from '../../../../../shared/chess/util/moveutil.js'; import config from '../config.js'; import gameslot from '../chess/gameslot.js'; import guinavigation from './guinavigation.js'; // Elements ------------------------------------------------------------- /** The entire stats element container. */ const element_Statuses = document.getElementById('stats')!; /** The FPS text element. */ const elementStatusFPS = document.getElementById('status-fps')!; /** The Move Number text element. */ const elementStatusMoves = document.getElementById('status-moves')!; // Variables ------------------------------------------------------------- /** * Weight of visibility for the move number stat. * When it is 0, the move number is hidden. */ let visibilityWeight = 0; /** Whether FPS display is enabled. */ let fps = false; // Move Number ------------------------------------------------------------- /** * Temporarily displays the move number in the corner of the screen. * @param [durationSecs] The duration to show the move number. Default: 2.5 */ function showMoves(durationSecs: number = 2.5): void { if (config.VIDEO_MODE) return; visibilityWeight++; updateTextContentOfMoves(); setTimeout(hideMoves, durationSecs * 1000); if (visibilityWeight === 1) elementStatusMoves.classList.remove('hidden'); } function hideMoves(): void { visibilityWeight--; if (visibilityWeight === 0) elementStatusMoves.classList.add('hidden'); } function updateTextContentOfMoves(): void { const currentPly = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1; const totalPlyCount = moveutil.getPlyCount(gameslot.getGamefile()!.boardsim.moves); elementStatusMoves.textContent = `${translations.move_counter} ${currentPly}/${totalPlyCount}`; } function updateStatsCSS(): void { element_Statuses.style = `top: ${guinavigation.getHeightOfNavBar()}px`; } // FPS ---------------------------------------------------------------------- function toggleFPS(): void { fps = !fps; if (fps) showFPS(); else hideFPS(); } function showFPS(): void { if (config.VIDEO_MODE) return; elementStatusFPS.classList.remove('hidden'); } function hideFPS(): void { elementStatusFPS.classList.add('hidden'); } function updateFPS(fps: number): void { if (!fps) return; const truncated = fps | 0; // Bitwise operation that quickly rounds towards zero elementStatusFPS.textContent = `FPS: ${truncated}`; } // Exports ------------------------------------------------------------------ export default { showMoves, updateStatsCSS, toggleFPS, updateFPS, updateTextContentOfMoves, }; ================================================ FILE: src/client/scripts/esm/game/gui/style.ts ================================================ // src/client/scripts/esm/game/gui/style.ts /** * Utility function for html elements and styles. * * It also keeps track of our javascript-inserted css in the style element of the html document * for things like the color of the navigation bar when theme changes. */ import type { Color } from '../../../../../shared/util/math/math'; // Types ------------------------------------------------------------- /** HSL Color representation */ interface HSLColor { /** Hue (0 - 360) */ h: number; /** Saturation (0.0 - 1.0) */ s: number; /** Lightness (0.0 - 1.0) */ l: number; } // Constants ------------------------------------------------------------- /** SVG default namespace */ const SVG_NS = 'http://www.w3.org/2000/svg'; // Elements ------------------------------------------------------------- const element_style = document.getElementById('style')!; // The in-html-doc style element containing css stylings // Variables ------------------------------------------------------------- // What things require styling that our javascript changes? // * The navigation bar, when the theme changes. let navigationStyle: string; // Functions ------------------------------------------------------------- function setNavStyle(cssStyle: string): void { navigationStyle = cssStyle; // Update the style element element_style.innerHTML = navigationStyle; // Other styles can be appended here later } /** * Finds the index of an element within its parent. * @param element - The element to find the index of. * @returns - The index of the element within its parent, or -1 if not found. */ function getElementIndexWithinItsParent(element: Element): number { if (!element || !element.parentNode) return -1; // Get the parent node const parent = element.parentNode; // Convert the parent's children to an array and find the index of the element const children = Array.prototype.slice.call(parent.children); return children.indexOf(element); } /** * Gets the child element at the specified index of a parent element. * @param parent - The parent element. * @param index - The index of the child element. * @returns The child element at the specified index, or null if not found. */ function getChildByIndexInParent(parent: Element, index: number): Element | null { if (parent && parent.children && index >= 0 && index < parent.children.length) { return parent.children[index]!; } return null; } /** * Converts an array of [r, g, b, a], range 0-1, into a valid CSS rgba color string. * @param colorArray - An array containing [r, g, b, a] values, where r, g, b are in the range [0, 1]. * @returns A CSS rgba color string. */ function arrayToCssColor(colorArray: Color): string { if (colorArray.length !== 4) throw new Error('Array must have exactly 4 elements: [r, g, b, a].'); const [r, g, b, a] = colorArray.map((value, index) => { if (index < 3) { if (value < 0 || value > 1) throw new Error('RGB values must be between 0 and 1.'); return Math.round(value * 255); } else { if (value < 0 || value > 1) throw new Error('Alpha value must be between 0 and 1.'); return value; } }); return `rgba(${r}, ${g}, ${b}, ${a})`; } /** * Converts RGB components to an HSL Color. * @param r - Red (0-255) * @param g - Green (0-255) * @param b - Blue (0-255) * @returns HSLColor object */ function rgbToHsl(r: number, g: number, b: number): HSLColor { const rN = r / 255; const gN = g / 255; const bN = b / 255; const max = Math.max(rN, gN, bN); const min = Math.min(rN, gN, bN); let h = 0; let s = 0; const l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case rN: h = (gN - bN) / d + (gN < bN ? 6 : 0); break; case gN: h = (bN - rN) / d + 2; break; case bN: h = (rN - gN) / d + 4; break; } h /= 6; } return { h: h * 360, s, l }; } /** * Converts numeric RGB components into a CSS rgb() color string. * @param r - Red channel (0-255) * @param g - Green channel (0-255) * @param b - Blue channel (0-255) * @returns A CSS color string, e.g., "rgb(255, 100, 50)" */ function rgbToCssString(r: number, g: number, b: number): string { return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; } /** * Converts an HSLColor object into a CSS hsl() color string. * @param hsl The HSLColor object to convert. * @returns A CSS color string, e.g., "hsl(360, 100%, 50%)" */ function hslToCssString(hsl: HSLColor): string { const h = Math.round(hsl.h); const s = Math.round(hsl.s * 100); const l = Math.round(hsl.l * 100); return `hsl(${h}, ${s}%, ${l}%)`; } export default { SVG_NS, setNavStyle, arrayToCssColor, getElementIndexWithinItsParent, getChildByIndexInParent, rgbToHsl, rgbToCssString, hslToCssString, }; ================================================ FILE: src/client/scripts/esm/game/gui/toast.ts ================================================ // src/client/scripts/esm/game/gui/toast.ts /** * This script displays the toast (status message) on the bottom of the page. */ // Types -------------------------------------------------------- interface ToastOptions { /** Whether the toast indicates an error. The backdrop will be red. */ error?: boolean; /** Overrides the default duration of the toast. */ durationMillis?: number; /** Multiplies the duration of the toast. */ durationMultiplier?: number; } // Elements ---------------------------------------------------------- const statusMessage = document.getElementById('toastmessage')!; const statusText = document.getElementById('toast')!; // Constants --------------------------------------------------------- /** Base duration for toasts, in milliseconds. */ const DURATION_BASE = 900; /** Duration multiplier per character in toasts, in milliseconds. */ const DURATION_MULTIPLIER = 45; /** Duration of the toasts' fade-out animation, in milliseconds. */ const FADE_DURATION = 1000; // Variables --------------------------------------------------------- /** * Weight of visibility for the toast. * When it is 0, it is hidden. */ let visibilityWeight = 0; // Functions --------------------------------------------------------- function show(text: string, options: ToastOptions = {}): void { // Safety net in case `text` was provided by an undefined translation of the `any` type: if (typeof text !== 'string') { console.warn('Unable to show toast: Not a string.'); return; } const { error = false, durationMillis, durationMultiplier = 1 } = options; const duration = durationMillis ?? (DURATION_BASE + text.length * DURATION_MULTIPLIER) * durationMultiplier; visibilityWeight++; fadeAfter(duration); statusText.textContent = text; statusText.classList.remove('fade-out-1s'); statusMessage.classList.remove('hidden'); if (error) { statusText.classList.remove('ok'); statusText.classList.add('error'); console.error(text); } else { statusText.classList.remove('error'); statusText.classList.add('ok'); } } /** * Fades the current toast after the provided time, * if no new messages have been displayed in the meantime. */ function fadeAfter(ms: number): void { setTimeout(() => { if (visibilityWeight === 1) { statusText.classList.add('fade-out-1s'); hideAfter(FADE_DURATION); } else visibilityWeight--; // This layer has been overwritten! }, ms); } /** * Hides the current toast after the provided time, * if no new messages have been displayed in the meantime. */ function hideAfter(ms: number): void { setTimeout(() => { visibilityWeight--; if (visibilityWeight > 0) return; // Only one left, hide! statusMessage.classList.add('hidden'); statusText.classList.remove('fade-out-1s'); }, ms); } /** Shows a toast message stating to please wait to perform this task. */ function showPleaseWaitForTask(): void { show(translations.please_wait, { durationMultiplier: 0.5 }); } // Exports ----------------------------------------------------------- export default { show, showPleaseWaitForTask, }; ================================================ FILE: src/client/scripts/esm/game/input.ts ================================================ // src/client/scripts/esm/game/input.ts /** * This script can attach input listeners to individual elements. * * Types of inputs it can hear: Keyboard, mouse, touch. * * It also can detect simulated mouse clicks via the mouse or finger, * and simulated double click drags! */ import type { DoubleCoords } from '../../../../shared/chess/util/coordutil.js'; import docutil from '../util/docutil.js'; /** * A list of all keyboard shortcuts that don't have a built in event in javascript. * This includes: Undo, Redo, Select All * This does NOT include: Copy, Cut, Paste (these have built in events). */ const manual_shortcuts: string[] = ['KeyZ', 'KeyY', 'KeyA']; const Mouse = { LEFT: 0, MIDDLE: 1, RIGHT: 2, } as const; // Maps buttons to string names const MouseNames = { [Mouse.LEFT]: 'Left', [Mouse.MIDDLE]: 'Middle', [Mouse.RIGHT]: 'Right', } as const; type MouseButton = (typeof Mouse)[keyof typeof Mouse]; /** Information about a key that was pressed down this frame. */ interface KeyDownInfo { /** The key code that was pressed. */ keyCode: string; /** Whether a meta key (Ctrl or Cmd) was held when the key was pressed. */ metaKey: boolean; /** Whether the Shift key was held when the key was pressed. */ shiftKey: boolean; } interface InputListener { /** Whether this input listener has experience at least one input event the past frame. */ atleastOneInput: () => boolean; /** Whether the given mouse button experienced a click-down this frame. */ isMouseDown(_button: MouseButton): boolean; /** Removes the mouse down so that other scripts don't also use it. Also removes the pointer down. */ claimMouseDown(_button: MouseButton): void; /** Removes the pointer down so that other scripts don't also use it. */ claimPointerDown(_pointerId: string): void; /** Removes the simulated mouse click so that other scripts don't also use it. */ claimMouseClick(_button: MouseButton): void; /** * Resets the simulated mouse click on mouse-down so that * when it released it DOESN'T count as a click. */ cancelMouseClick(_button: MouseButton): void; /** Whether the given mouse button is currently held down. */ isMouseHeld(_button: MouseButton): boolean; /** Returns true if the most recent pointer for a specific mouse button action is a touch (not mouse). */ isMouseTouch(_button: MouseButton): boolean; /** Returns true if the given pointer is a touch (not mouse). */ isPointerTouch(_pointerId: string): boolean; /** Returns the id of the LOGICAL pointer that most recently performed an action on the specified mouse button. */ getMouseId(_button: MouseButton): string | undefined; /** Returns the id of the PHYSICAL pointer that most recently performed an action on the specified mouse button. */ getMousePhysicalId(_button: MouseButton): string | undefined; /** Returns the last known pointer position that trigerred a simulated event for the given mouse button. */ getMousePosition(_button: MouseButton): DoubleCoords | undefined; /** Whether the given mouse button simulated a full CLICK this frame. */ isMouseClicked(_button: MouseButton): boolean; /** Whether the given mouse button experience a double-click-down this frame. */ isMouseDoubleClickDragged(_button: MouseButton): boolean; /** * Toggles all-left click actions being treated as right-click actions. * This is useful for allowing fingers to right click. */ setTreatLeftasRight(_value: boolean): void; /** Returns the position of the given LOGICAL pointer id, if it still exists. */ getPointerPos(_pointerId?: string): DoubleCoords | undefined; /** Returns the position of the given PHYSICAL pointer id, if it still exists. */ getPhysicalPointerPos(_pointerId?: string): DoubleCoords | undefined; /** Returns the PHYSICAL pointer id this pointer is attached to. */ getPhysicalPointerIdOfPointer(_pointerId: string): string | undefined; /** * Returns the delta movement of the given PHYSICAL pointer id over the * past frame, if it still exists. The mouse pointer's id is 'mouse'. */ getPhysicalPointerDelta(_physicalPointerId: string): DoubleCoords | undefined; /** * Returns undefined if the pointer doesn't exist (finger has since lifted), or mouse isn't supported. * The mouse pointer's id is 'mouse'. */ getPointerVel(_pointerId: string): DoubleCoords | undefined; /** Returns the ids of all existing LOGICAL pointers for the given button action. */ getAllPointers(_button: MouseButton): string[]; /** Returns the ids of all existing touch LOGICAL pointers, regardless of what button action they were for. */ getAllTouchPointers(): string[]; /** Returns the ids of all existing PHYSICAL pointers. */ getAllPhysicalPointers(): string[]; /** * Whether the given LOGICAL pointer is currently being held down. * Which also happens to be true if the pointer still EXISTS. */ isPointerHeld(_pointerId: string): boolean; /** Whether the given LOGICAL pointer still exists (held down). */ pointerExists(_pointerId: string): boolean; /** Returns a list of all LOGICAL pointers that were pressed down this frame for the given button action. */ getPointersDown(_button: MouseButton): string[]; /** Returns a list of all touch LOGICAL pointers that were pressed down this frame, regardless of what button action they were for. */ getTouchPointersDown(): string[]; /** Returns the number of pointers that were pressed down this frame. */ getPointersDownCount(): number; /** Returns whether the provided LOGICAL pointer belongs to the provided PHYSICAL pointer. */ doesPointerBelongToPhysicalPointer( _logicalPointerId: string, _physicalPointerId: string, ): boolean; /** Returns how much the wheel has scrolled this frame. */ getWheelDelta(): number; /** * Whether the provided keyboard key was pressed down this frame. * @param keyCode - The key code to check * @param requireMetaKey - If true, only returns true if a meta key (Ctrl/Cmd) was also held. * @param requireShiftKey - If true, only returns true if the Shift key was also held. */ isKeyDown(_keyCode: string, _requireMetaKey?: boolean, _requireShiftKey?: boolean): boolean; /** Whether the provided keyboard key is currently being held down. */ isKeyHeld(_keyCode: string): boolean; /** Removes the key-down event for the given key code so that other scripts don't also use it. */ claimKey(_keyCode: string): void; /** Call when done with the input listener. This closes all its event listeners. */ removeEventListeners(): void; /** The element this input listener is attached to. */ element: HTMLElement | typeof document; } type PointerHistory = { pos: DoubleCoords; time: number }[]; /** Options for simulated clicks */ const CLICK_THRESHOLDS = { MOUSE: { /** The maximum distance the mouse can move before a click is not registered. */ MOVE_VPIXELS: 6, // Default: 8 /** The maximum time the mouse can be held down before a click is not registered. */ TIME_MILLIS: 400, // Default: 400 /** The maximum time between first click down and second click up to register a double click drag. */ DOUBLE_CLICK_TIME_MILLIS: 450, // Default: 500 }, TOUCH: { /** {@link CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS}, but for fingers (less strict, the 2nd tap can be further away) */ MOVE_VPIXELS: 17, // Default: 20 /** {@link CLICK_THRESHOLDS.MOUSE.TIME_MILLIS}, but for fingers (more strict, they must lift quicker) */ TIME_MILLIS: 120, /** {@link CLICK_THRESHOLDS.MOUSE.DOUBLE_CLICK_TIME_MILLIS}, but for fingers (more strict, they must lift quicker) */ DOUBLE_CLICK_TIME_MILLIS: 250, // Default: 220 }, } as const; /** The window of milliseconds to store mouse position history for velocity calculations. */ const MOUSE_POS_HISTORY_WINDOW_MILLIS = 80; /** * Physical Pointers are assigned one trackable POSITION. * But they may be linked to multiple Logical Pointers if * it supports multiple mouse buttons (touches do not). */ type PhysicalPointer = { /** The unique id of the pointer. */ id: string; /** Whether the Physical Pointer is derived from a touch (finger). */ isTouch: boolean; /** The position of the pointer relative to the top-left corner of the listener's element. */ position: DoubleCoords; /** How many pixels the pointer has moved since last frame. */ delta: DoubleCoords; /** Used for calculating velocity */ positionHistory: PointerHistory; velocity: DoubleCoords; }; /** * Logical Pointers represent one BUTTON ACTION continuously held down. * Multiple Logical Pointers may be attached to one Physical Pointer. * These are deleted as soon as the button is lifted up. */ type LogicalPointer = { /** The unique id of the pointer. May be identical to its Physical Pointer's id if it's a touch pointer. */ id: string; /** The Physical Pointer it's linked to. */ physical: PhysicalPointer; /** What button action this is for. */ button: MouseButton; }; /** * Keeps track of the recent down position of mouse buttons. * Allowing us to perform simulated clicks or double click drags with any of them. */ interface ClickInfo { /** The id of the LOGICAL pointer that most recently pressed this mouse button. */ pointerId?: string; /** The id of the PHYSICAL pointer tied to the logical pointer that most recently pressed this mouse button. */ physicalId?: string; /** Whether the last action for this Button was from a touch. */ isTouch: boolean; /** Whether this mouse button was pushed down THIS FRAME */ isDown: boolean; /** Whether this mouse button is currently being held down. */ isHeld: boolean; /** * Whether this mouse button has been a simulated click or not. * Clicks are registered if the mouse goes up within a small window * after going down, and the mouse has not moved beyond a certain threshold. */ clicked: boolean; /** The time the mouse button was pressed down. */ timeDownMillisHistory: number[]; /** * The last known position the mouse button was pressed down. * * Also Used for calculating simulated clicks, when touch events * don't provide delta from lift to down. */ posDown?: DoubleCoords; /** * How much the mouse has ABSOLUTELY moved since the last click down. * ONLY USED FOR CALCULATING SIMULATED CLICKS AND DOUBLE CLICK DRAGS, * as if the pointer has moved too far, we don't register the click. * * We use delta instead of remembering the position down, because when * the mouse is locked in perspective mode, the position is not updated. * * This can only be positive, not negative. */ deltaSinceDown: DoubleCoords; /** * The last known position of the last active pointer for this mouse button. * UPDATES ON DOWN AND UP, NOT ON MOVE. */ position?: DoubleCoords; /** Whether this frame incurred the start of a double click drag */ doubleClickDrag: boolean; } /** * Creates an input listener that listens to mouse and keyboard events on the given element. * * EVERY FRAME you need to dispatch the 'reset-listener-events' event on the document * to reset the state of the input listener. * @param element - The HTML element to listen for events on. * @returns An object with methods to check the state of mouse and keyboard inputs. */ function CreateInputListener( element: HTMLElement | typeof document, { keyboard = true, mouse = true }: { keyboard?: boolean; mouse?: boolean } = {}, ): InputListener { const keyDowns: KeyDownInfo[] = []; const keyHelds: string[] = []; /** The amount the scroll wheel has scrolled this frame. */ let wheelDelta: number = 0; /** Tracks the physical input sources. Only one entry for 'mouse'. */ const physicalPointers: Record = {}; /** Tracks the virtual pointers, one for each button action (left/right/middle). */ const logicalPointers: Record = {}; /** A list of all LOGICAL pointer id's that were pressed down this frame. */ const pointersDown: string[] = []; /** * Whether to treat all left click actions as right click actions. * This is useful for allowing fingers to right click. */ let treatLeftAsRight = false; // console.log("Mouse supported: ", docutil.isMouseSupported()); // Immediately add the mouse pointer if the doc supports it if (docutil.isMouseSupported()) { physicalPointers['mouse'] = { isTouch: false, id: 'mouse', position: [0, 0], delta: [0, 0], positionHistory: [], velocity: [0, 0], }; } /** Whether there has been any input this frame. */ let atleastOneInputThisFrame = false; const clickInfo: Record = { [Mouse.LEFT]: { isTouch: false, isDown: false, isHeld: false, clicked: false, doubleClickDrag: false, timeDownMillisHistory: [], deltaSinceDown: [0, 0], }, [Mouse.MIDDLE]: { isTouch: false, isDown: false, isHeld: false, clicked: false, doubleClickDrag: false, timeDownMillisHistory: [], deltaSinceDown: [0, 0], }, [Mouse.RIGHT]: { isTouch: false, isDown: false, isHeld: false, clicked: false, doubleClickDrag: false, timeDownMillisHistory: [], deltaSinceDown: [0, 0], }, }; const eventHandlers: Record = {}; // Helper Functions --------------------------------------------------------------------------- function addListener(target: EventTarget, eventType: string, handler: EventListener): void { target.addEventListener(eventType, handler); eventHandlers[eventType] = { target, handler }; } /** Reset the input events for the next frame. Fire 'reset-listener-events' event at the very end of EVERY frame. */ document.addEventListener('reset-listener-events', () => { // console.log("Resetting events"); // We can continuously hold a key without triggering more events, so held keys should still count as an input that frame. // atleastOneInputThisFrame = keyHelds.length > 0 || Object.values(clickInfo).some(clickInfo => clickInfo.isHeld); atleastOneInputThisFrame = keyHelds.length > 0; // console.log("Atleast one input this frame: ", atleastOneInputThisFrame); // For each mouse button, reset its state for (const button of Object.values(clickInfo)) { button.isDown = false; button.clicked = false; button.doubleClickDrag = false; // Trim their timeDownMillisHistory of old mouse downs button.timeDownMillisHistory = button.timeDownMillisHistory.filter( (time) => time > Date.now() - 3000, ); } // For each pointer, reset its state const now = Date.now(); for (const pointer of Object.values(physicalPointers)) { pointer.delta = [0, 0]; pointer.positionHistory = pointer.positionHistory.filter( (entry) => entry.time > Date.now() - MOUSE_POS_HISTORY_WINDOW_MILLIS, ); recalcPointerVel(pointer, now); } keyDowns.length = 0; pointersDown.length = 0; wheelDelta = 0; }); /** Calculates the mouse velocity based on recent mouse positions. */ function recalcPointerVel(pointer: PhysicalPointer, now: number): void { // Remove old entries, stop once we encounter recent enough data const timeToRemoveEntriesBefore = now - MOUSE_POS_HISTORY_WINDOW_MILLIS; while ( pointer.positionHistory.length > 0 && pointer.positionHistory[0]!.time < timeToRemoveEntriesBefore ) pointer.positionHistory.shift(); // Calculate velocity if there are at least two positions if (pointer.positionHistory.length >= 2) { const latestMousePosEntry = pointer.positionHistory[pointer.positionHistory.length - 1]!; const firstMousePosEntry = pointer.positionHistory[0]!; // { mousePos, time } const timeDiffBetwFirstAndLastEntryMillis = latestMousePosEntry.time - firstMousePosEntry.time; const mVX = (latestMousePosEntry.pos[0] - firstMousePosEntry.pos[0]) / timeDiffBetwFirstAndLastEntryMillis; const mVY = (latestMousePosEntry.pos[1] - firstMousePosEntry.pos[1]) / timeDiffBetwFirstAndLastEntryMillis; pointer.velocity = [mVX, mVY]; } else pointer.velocity = [0, 0]; } // Simulated Click Events (either mouse or finger) ------------------------------------------------------------ function updateClickInfoDown(targetButton: MouseButton, e: MouseEvent | Touch): void { // console.log("Mouse down: ", MouseNames[targetButton]); const targetButtonInfo = clickInfo[targetButton]; if (targetButtonInfo === undefined) return; // Invalid button (some mice have extra buttons) // This makes it so the coordinate input fields are unfocused when clicking the canvas. const prev = document.activeElement; if (element instanceof HTMLElement && prev !== element && prev instanceof HTMLElement) prev.blur(); // Generate a unique logical ID for the action. const logicalId = getLogicalPointerId(e, targetButton); const physicalId = getPhysicalPointerId(e); targetButtonInfo.pointerId = logicalId; targetButtonInfo.physicalId = physicalId; targetButtonInfo.isTouch = !(e instanceof MouseEvent); // CAN'T USE instanceof Touch because it's not defined in Safari! targetButtonInfo.isDown = true; targetButtonInfo.isHeld = true; const relativeMousePos = getRelativeMousePosition([e.clientX, e.clientY], element); targetButtonInfo.position = [...relativeMousePos]; // if (targetButton === Mouse.LEFT) pointersDown.push(targetButtonInfo.pointerId!); // Push them down anyway no matter which type of click. // So that you can still pinch the board when fingers act as right clicks. pointersDown.push(logicalId); // Create LOGICAL pointer, which automatically means it's held down. logicalPointers[logicalId] = { id: logicalId, physical: physicalPointers[physicalId]!, button: targetButton, }; // Update click ------------ const previousTimeDown = targetButtonInfo.timeDownMillisHistory[ targetButtonInfo.timeDownMillisHistory.length - 1 ]; const now = Date.now(); targetButtonInfo.timeDownMillisHistory.push(now); // Update double click draw ---------- const DOUBLE_CLICK_TIME_MILLIS = e instanceof MouseEvent ? CLICK_THRESHOLDS.MOUSE.DOUBLE_CLICK_TIME_MILLIS : CLICK_THRESHOLDS.TOUCH.DOUBLE_CLICK_TIME_MILLIS; // CAN'T USE instanceof Touch because it's not defined in Safari! if (previousTimeDown && now - previousTimeDown < DOUBLE_CLICK_TIME_MILLIS) { // Mouse has been down at least once before. // Now we now posDown will be defined, so we can calculate the distance to that last click down. // Works for 2D mode, desktop & mobile const posDown = targetButtonInfo.posDown; const distMoved = posDown ? Math.max( Math.abs(posDown[0] - relativeMousePos[0]), Math.abs(posDown[1] - relativeMousePos[1]), ) : 0; // Works for 3D mode, desktop (mouse is locked in place then) const delta = Math.max( targetButtonInfo.deltaSinceDown[0], targetButtonInfo.deltaSinceDown[1], ); // console.log("Mouse delta:", delta); const MOVE_VPIXELS = e instanceof MouseEvent ? CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS : CLICK_THRESHOLDS.TOUCH.MOVE_VPIXELS; // CAN'T USE instanceof Touch because it's not defined in Safari! if (distMoved < MOVE_VPIXELS && delta < MOVE_VPIXELS) { // Only register the double click drag if the mouse hasn't moved too far from its last click down. targetButtonInfo.doubleClickDrag = true; // console.log("Mouse double click dragged: ", MouseNames[targetButton]); } // else console.log("Mouse double click MOVED TOO FAR: ", MouseNames[targetButton]); } // ---------------- // Now we can update the last click down after checking for its distance to the last one. targetButtonInfo.posDown = [...relativeMousePos]; targetButtonInfo.deltaSinceDown = [0, 0]; // Reset the delta since down } function updateClickInfoUp(targetButton: MouseButton, e: MouseEvent | Touch): void { // console.log("Mouse up: ", MouseNames[targetButton]); const targetButtonInfo = clickInfo[targetButton]; if (targetButtonInfo === undefined) return; // Invalid button (some mice have extra buttons) const logicalId = getLogicalPointerId(e, targetButton); const physicalId = getPhysicalPointerId(e); targetButtonInfo.pointerId = logicalId; targetButtonInfo.physicalId = physicalId; targetButtonInfo.isTouch = !(e instanceof MouseEvent); // CAN'T USE instanceof Touch because it's not defined in Safari! targetButtonInfo.isDown = false; targetButtonInfo.isHeld = false; const relativeMousePos = getRelativeMousePosition([e.clientX, e.clientY], element); targetButtonInfo.position = [...relativeMousePos]; // Remove the pointer from the list of pointers down too, if it's in there. // This can happen if it was added & removed in a single frame. const index = pointersDown.indexOf(targetButtonInfo.pointerId!); if (index !== -1) pointersDown.splice(index, 1); // Mark the LOGICAL pointer as no longer held. // We have to delete it so that it doesn't inflate the pointer count. delete logicalPointers[logicalId]; // Update click -------------- const mouseHistory = targetButtonInfo.timeDownMillisHistory; const timePassed = Date.now() - (mouseHistory[mouseHistory.length - 1] ?? 0); // Since the latest click const TIME_MILLIS = e instanceof MouseEvent ? CLICK_THRESHOLDS.MOUSE.TIME_MILLIS : CLICK_THRESHOLDS.TOUCH.TIME_MILLIS; // CAN'T USE instanceof Touch because it's not defined in Safari! if (timePassed < TIME_MILLIS) { // Works for 2D mode, desktop & mobile const posDown = targetButtonInfo.posDown; const distMoved = posDown ? Math.max( Math.abs(posDown[0] - relativeMousePos[0]), Math.abs(posDown[1] - relativeMousePos[1]), ) : 0; // No click down to compare to. This can happen if you click down offscreen. // Works for 3D mode, desktop (mouse is locked in place then) const delta = Math.max( targetButtonInfo.deltaSinceDown[0], targetButtonInfo.deltaSinceDown[1], ); // console.log("Mouse delta: ", delta); const MOVE_VPIXELS = e instanceof MouseEvent ? CLICK_THRESHOLDS.MOUSE.MOVE_VPIXELS : CLICK_THRESHOLDS.TOUCH.MOVE_VPIXELS; // CAN'T USE instanceof Touch because it's not defined in Safari! if (distMoved < MOVE_VPIXELS && delta < MOVE_VPIXELS) { targetButtonInfo.clicked = true; // console.log("Mouse clicked: ", MouseNames[targetButton]); } } // -------------- } /** * On pointer move. This updates the deltaSinceDown for the * clickInfo of the mouse button whos most recent action * was from the pointerId. * * If the pointer moves too much, don't simulate a click. */ function updateDeltaSinceDownForPointer(physicalPointerId: string, delta: DoubleCoords): void { // Update the delta (deltaSinceDown) for simulated mouse clicks Object.values(Mouse).forEach((targetButton) => { const targetButtonInfo = clickInfo[targetButton]; // Only update the click info's delta since down if the physical pointer that most recently performed that click action matches if (targetButtonInfo.physicalId !== physicalPointerId) return; // Update the delta since down targetButtonInfo.deltaSinceDown[0] += Math.abs(delta[0]); targetButtonInfo.deltaSinceDown[1] += Math.abs(delta[1]); }); } if (mouse) { // Mouse Events --------------------------------------------------------------------------- addListener(element, 'mousedown', ((e: MouseEvent): void => { if (element instanceof HTMLElement) { if (e.target !== element) return; // Ignore events triggered on CHILDREN of the element. // Prevents dragging the board also selecting/highlighting text in Coordinates container // We can't prevent default the document input listener tho or dropdown selections can't be opened. e.preventDefault(); } const targetPointer = physicalPointers['mouse']; if (!targetPointer) return; // Sometimes the 'mousedown' event is fired from touch events, even though the mouse pointer does not exist. atleastOneInputThisFrame = true; const eventButton = e.button as MouseButton; // If alt is held, right click instead const button = (e.altKey || treatLeftAsRight) && eventButton === Mouse.LEFT ? Mouse.RIGHT : eventButton; updateClickInfoDown(button, e); }) as EventListener); // This listener is placed on the document so we don't miss mouseup events if the user lifts their mouse off the element. addListener(document, 'mouseup', ((e: MouseEvent): void => { atleastOneInputThisFrame = true; const eventButton = e.button as MouseButton; // If alt is held, right click instead const button = (e.altKey || treatLeftAsRight) && eventButton === Mouse.LEFT ? Mouse.RIGHT : eventButton; updateClickInfoUp(button, e); }) as EventListener); // Mouse position tracking addListener(element, 'mousemove', ((e: MouseEvent): void => { atleastOneInputThisFrame = true; const physicalPointer = physicalPointers['mouse']; if (!physicalPointer) return; // Sometimes the 'mousemove' event is fired from touch events, even though the mouse pointer does not exist. physicalPointer.position = getRelativeMousePosition([e.clientX, e.clientY], element); // console.log(`Updated pointer ${targetPointer.id} position:`, targetPointer.position); // Update delta (Note: e.movementX/Y are relative to the document, it should be fine) // Add to the current delta, in case this event is triggered multiple times in a frame. physicalPointer.delta[0] += e.movementX; physicalPointer.delta[1] += e.movementY; // Update the delta (deltaSinceDown) for simulated mouse clicks updateDeltaSinceDownForPointer(physicalPointer.id, physicalPointer.delta); // console.log("Mouse delta: ", targetPointer.delta); // Update velocity const now = Date.now(); physicalPointer.positionHistory.push({ pos: [...physicalPointer.position], time: now }); // Deep copy the mouse position to avoid modifying the original recalcPointerVel(physicalPointer, now); // console.log("Mouse relative position: ", targetPointer.position); }) as EventListener); // Scroll wheel tracking addListener(element, 'wheel', ((e: WheelEvent): void => { if (element instanceof HTMLElement && e.target !== element) return; // Ignore events triggered on CHILDREN of the element. atleastOneInputThisFrame = true; wheelDelta = e.deltaY; // console.log("Scroll wheel: ", wheelDelta); }) as EventListener); // Prevent the context menu on right click addListener(element, 'contextmenu', ((e: MouseEvent): void => { if (element instanceof Document || e.target !== element) return; // Allow context menu outside the element, or inside as long as the target isn't the element. atleastOneInputThisFrame = true; // console.log("Context menu"); e.preventDefault(); }) as EventListener); // Finger Events --------------------------------------------------------------------------- addListener(element, 'touchstart', ((e: TouchEvent): void => { if (e.target !== element) return; // Ignore events triggered on CHILDREN of the element. atleastOneInputThisFrame = true; // Prevent default behavior of touch events // Stops fingers from also triggering mouse events, // and prevents chrome swipe gestures. // This still allows the touchstart to perform default actions // if we interacted with an element INSIDE the element. if (e.target instanceof HTMLElement && e.target === element) e.preventDefault(); for (let i = 0; i < e.changedTouches.length; i++) { const touch: Touch = e.changedTouches[i]!; const position = getRelativeMousePosition([touch.clientX, touch.clientY], element); const physicalId = getPhysicalPointerId(touch); // 1. Create the Physical Pointer physicalPointers[physicalId] = { isTouch: true, id: physicalId, position, delta: [0, 0], positionHistory: [{ pos: [...position], time: Date.now() }], velocity: [0, 0], }; // console.log("Touch start: ", touch.identifier); // Treat fingers as the left mouse button by default const button = treatLeftAsRight ? Mouse.RIGHT : Mouse.LEFT; updateClickInfoDown(button, touch); } }) as EventListener); addListener(element, 'touchmove', ((e: TouchEvent): void => { atleastOneInputThisFrame = true; for (let i = 0; i < e.changedTouches.length; i++) { const touch: Touch = e.changedTouches[i]!; const touchId = getPhysicalPointerId(touch); const physicalPointer = physicalPointers[touchId]; if (!physicalPointer) continue; // This touch likely started outside the element, so we ignored adding it. const relativeTouchPos = getRelativeMousePosition( [touch.clientX, touch.clientY], element, ); // Update delta physicalPointer.delta[0] += relativeTouchPos[0] - physicalPointer.position[0]; physicalPointer.delta[1] += relativeTouchPos[1] - physicalPointer.position[1]; // Position physicalPointer.position = relativeTouchPos; // Update the delta (deltaSinceDown) for simulated mouse clicks updateDeltaSinceDownForPointer(physicalPointer.id, physicalPointer.delta); // Update velocity const now = Date.now(); physicalPointer.positionHistory.push({ pos: [...physicalPointer.position], time: now, }); // Deep copy the touch position to avoid modifying the original recalcPointerVel(physicalPointer, now); // console.log("Touch position: ", targetPointer.position); } }) as EventListener); // This listeners are placed on the document so we don't miss touchend events if the user lifts their finger off the element. addListener(document, 'touchend', touchEndCallback as EventListener); addListener(document, 'touchcancel', touchEndCallback as EventListener); function touchEndCallback(e: TouchEvent): void { atleastOneInputThisFrame = true; for (let i = 0; i < e.changedTouches.length; i++) { const touch: Touch = e.changedTouches[i]!; // console.log("Touch end/cancel: ", touch.identifier); const physicalId = getPhysicalPointerId(touch); // Destroy both pointers since it's a touch delete logicalPointers[physicalId]; delete physicalPointers[physicalId]; // Treat fingers as the left mouse button by default const button = treatLeftAsRight ? Mouse.RIGHT : Mouse.LEFT; updateClickInfoUp(button, touch); } } } // Keyboard Events --------------------------------------------------------------------------- if (keyboard) { addListener(element, 'keydown', ((e: KeyboardEvent): void => { // If spacebar pressed when checkbox focused => Prevent default. // Prevents pushing spacebar in the board editor game rules UI after // toggling a checkbox from toggling it again when you intend to zoom. if ( e.code === 'Space' && document.activeElement instanceof HTMLInputElement && document.activeElement.type === 'checkbox' ) e.preventDefault(); // if (e.target !== element) return; // Ignore events triggered on CHILDREN of the element. if (document.activeElement instanceof HTMLInputElement) return; // Ignore events when the user is typing in a text box. // console.log("Key down: ", e.code); atleastOneInputThisFrame = true; if (!keyDowns.some((keyInfo) => keyInfo.keyCode === e.code)) { keyDowns.push({ keyCode: e.code, metaKey: e.ctrlKey || e.metaKey, shiftKey: e.shiftKey, }); } // Only add to keyHelds if no meta key was held, unless the key IS a meta key itself. // Prevents stuff like `Ctrl > A` from panning the board. const isMetaKey = e.code === 'ControlLeft' || e.code === 'ControlRight' || e.code === 'MetaLeft' || e.code === 'MetaRight' || e.code === 'AltLeft' || e.code === 'AltRight'; if (!keyHelds.includes(e.code) && (isMetaKey || !(e.ctrlKey || e.metaKey || e.altKey))) keyHelds.push(e.code); // Prevent default behavior for shortcuts without a built in event. // This still allows copy & paste events to bubble through to our listeners, // but for example it prevents Ctrl+A from selecting all text on the page. if (manual_shortcuts.includes(e.code) && (e.ctrlKey || e.metaKey)) e.preventDefault(); if (e.key === 'Tab') e.preventDefault(); // Prevents the default tabbing behavior of cycling through elements on the page. }) as EventListener); // This listener is placed on the document so we don't miss mouseup events if the user lifts their mouse off the element. addListener(element, 'keyup', ((e: KeyboardEvent): void => { // console.log("Key up: ", e.code); atleastOneInputThisFrame = true; const downIndex = keyDowns.findIndex((keyInfo) => keyInfo.keyCode === e.code); if (downIndex !== -1) keyDowns.splice(downIndex, 1); const heldIndex = keyHelds.indexOf(e.code); if (heldIndex !== -1) keyHelds.splice(heldIndex, 1); }) as EventListener); window.addEventListener('blur', function () { // Clear all keys being held, as when the window isn't in focus, we don't hear the key-up events. // So if we held down the shift key, then click off, then let go, // the game would CONTINUOUSLY keep zooming in without you pushing anything, // and you'd have to push the shift again to cancel it. keyHelds.length = 0; }); } // Return the InputListener object --------------------------------------------------------------------------- return { element, atleastOneInput: (): boolean => atleastOneInputThisFrame, isMouseDown: (button: MouseButton): boolean => clickInfo[button].isDown ?? false, claimMouseDown: (button: MouseButton): void => { // console.error("Claiming mouse down: ", MouseNames[button]); clickInfo[button].isDown = false; // Also remove the pointer from the list of pointers down this frame. const pointerId = clickInfo[button].pointerId; const index = pointersDown.indexOf(pointerId!); // console.error("Claiming pointer down1: ", pointerId); if (index !== -1) pointersDown.splice(index, 1); }, claimPointerDown: (pointerId: string): void => { // console.error("Claiming pointer down: ", pointerId); const index = pointersDown.indexOf(pointerId); if (index === -1) throw Error("Can't claim pointer down. Already claimed, or is not down."); // console.error("Claiming pointer down2: ", pointerId); pointersDown.splice(index, 1); // Also claim the mouse down if this pointer is the most recent pointer that performed that action. Object.values(clickInfo).forEach((buttonInfo) => { if (buttonInfo.pointerId === pointerId) buttonInfo.isDown = false; }); }, claimMouseClick: (button: MouseButton): void => { // console.error("Claiming mouse click: ", MouseNames[button]); clickInfo[button].clicked = false; // console.error("Claiming mouse click: ", MouseNames[button]); }, cancelMouseClick: (button: MouseButton): number => (clickInfo[button].timeDownMillisHistory.length = 0), isMouseHeld: (button: MouseButton): boolean => clickInfo[button].isHeld ?? false, isMouseTouch: (button: MouseButton): boolean => clickInfo[button].isTouch, isPointerTouch: (pointerId: string): boolean => logicalPointers[pointerId]?.physical.isTouch ?? false, getMouseId: (button: MouseButton): string | undefined => clickInfo[button].pointerId, getMousePhysicalId: (button: MouseButton): string | undefined => clickInfo[button].physicalId, getMousePosition: (button: MouseButton): DoubleCoords | undefined => { const logicalId = clickInfo[button].pointerId; if (!logicalId) return undefined; const logicalPointer = logicalPointers[logicalId]; /** * A. Pointer exists => Return its current position. (It may not exist anymore if it was a finger that has since lifted) * B. Pointer does not exist => Return its last known position since it simulated an UP/DOWN mouse click. */ if (logicalPointer) { // Pointer is still held, get its live position. return logicalPointer.physical.position; } else { // Pointer has been lifted, return its last known position. return clickInfo[button].position; } }, isMouseClicked: (button: MouseButton): boolean => clickInfo[button].clicked, isMouseDoubleClickDragged: (button: MouseButton): boolean => clickInfo[button].doubleClickDrag, setTreatLeftasRight: (value: boolean): boolean => (treatLeftAsRight = value), getPointerPos: (pointerId: string): DoubleCoords | undefined => logicalPointers[pointerId]?.physical.position, getPhysicalPointerPos: (pointerId: string): DoubleCoords | undefined => physicalPointers[pointerId]?.position, getPhysicalPointerIdOfPointer: (pointerId: string): string | undefined => logicalPointers[pointerId]?.physical.id, getPhysicalPointerDelta: (physicalPointerId: string): DoubleCoords | undefined => physicalPointers[physicalPointerId]?.delta, getPointerVel: (pointerId: string): DoubleCoords | undefined => logicalPointers[pointerId]?.physical.velocity, getAllPointers: (button: MouseButton): string[] => Object.values(logicalPointers) .filter((p) => p.button === button) .map((p) => p.id), // Filter out the ones not for the button action, and map to ids getAllTouchPointers: (): string[] => Object.values(logicalPointers) .filter((p) => p.physical.isTouch) .map((p) => p.id), // Filter out the non-touch ones, and map to ids getAllPhysicalPointers: (): string[] => Object.keys(physicalPointers), isPointerHeld: (pointerId: string): boolean => logicalPointers[pointerId] !== undefined, pointerExists: (pointerId: string): boolean => logicalPointers[pointerId] !== undefined, getPointersDown: (button: MouseButton): string[] => pointersDown.filter((id) => logicalPointers[id]!.button === button), // Filter out the ones not for the button action getTouchPointersDown: (): string[] => pointersDown.filter((id) => logicalPointers[id]!.physical.isTouch), getPointersDownCount: (): number => pointersDown.length, doesPointerBelongToPhysicalPointer: ( logicalPointerId: string, physicalPointerId: string, ): boolean => { const logicalPointer = logicalPointers[logicalPointerId]; if (!logicalPointer) return false; return logicalPointer.physical === physicalPointers[physicalPointerId]; }, getWheelDelta: (): number => wheelDelta, isKeyDown: ( keyCode: string, requireMetaKey?: boolean, requireShiftKey?: boolean, ): boolean => { return keyDowns.some( (keyInfo) => keyInfo.keyCode === keyCode && (!requireMetaKey || keyInfo.metaKey) && (!requireShiftKey || keyInfo.shiftKey), ); }, isKeyHeld: (keyCode: string): boolean => keyHelds.includes(keyCode), claimKey: (keyCode: string): void => { const index = keyDowns.findIndex((k) => k.keyCode === keyCode); if (index !== -1) keyDowns.splice(index, 1); }, removeEventListeners: (): void => { Object.keys(eventHandlers).forEach((eventType) => { const { target, handler } = eventHandlers[eventType]!; target.removeEventListener(eventType, handler); }); console.log('Closed event listeners of Input Listener'); }, }; } /** Generates the unique PHYSICAL pointer id for the mouse or touch event. */ function getPhysicalPointerId(e: MouseEvent | Touch): string { const mouseEvent = e instanceof MouseEvent; // CAN'T USE instanceof Touch because it's not defined in Safari! return mouseEvent ? 'mouse' : e.identifier.toString(); } /** Generates the unique pointer id for the mouse or touch event and button action. */ function getLogicalPointerId(e: MouseEvent | Touch, button: MouseButton): string { const mouseEvent = e instanceof MouseEvent; // CAN'T USE instanceof Touch because it's not defined in Safari! return mouseEvent ? `mouse_${MouseNames[button]}` : e.identifier.toString(); } /** * Converts the mouse coordinates to be relative to the * element bounding box instead of absolute to the whole page. */ function getRelativeMousePosition( coords: DoubleCoords, element: HTMLElement | typeof document, ): DoubleCoords { if (element instanceof Document) return coords; // No need to adjust if we're listening on the document. const rect = element.getBoundingClientRect(); return [coords[0] - rect.left, coords[1] - rect.top]; } export { Mouse, CreateInputListener }; export default { getRelativeMousePosition, }; export type { InputListener, MouseButton }; ================================================ FILE: src/client/scripts/esm/game/main.ts ================================================ // src/client/scripts/esm/game/main.ts /* * This is the main script. This is where the game begins running. * This initiates the gl context, calls for the initiating of the shader programs, camera, * and input listeners, and begins the game loop. */ import game from './chess/game.js'; import webgl from './rendering/webgl.js'; import camera from './rendering/camera.js'; import socketman from './websocket/socketman.js'; import IndexedDB from '../util/IndexedDB.js'; import maskedDraw from '../webgl/maskedDraw.js'; import guiloading from './gui/guiloading.js'; import LocalStorage from '../util/LocalStorage.js'; import frametracker from './rendering/frametracker.js'; import loadbalancer from './misc/loadbalancer.js'; import socketmessages from './websocket/socketmessages.js'; import frameratelimiter from './rendering/frameratelimiter.js'; // Starts the game. Runs automatically once the page is loaded. function start(): void { guiloading.closeAnimation(); // Stops the loading screen animation webgl.init(); // Initiate the WebGL context. This is our web-based render engine. camera.init(); // Initiates the matrixes (uniforms) of our shader programs: viewMatrix (Camera), projMatrix (Projection), modelMatrix (world translation) game.init(); initListeners(); // Immediately asks the server if we are in a game. // If so, it will send the info to join it. socketmessages.send('game', 'joingame'); // Update & draw the scene repeatedly frameratelimiter.requestFrame(gameLoop); } function initListeners(): void { window.addEventListener('beforeunload', (_event) => { // console.log('Detecting unload'); // This allows us to control the reason why the socket was closed. // "1000 Closed by client" instead of "1001 Endpoint left" socketman.closeSocket(); LocalStorage.eraseExpiredItems(); IndexedDB.eraseExpiredItems(); }); } /** The main game loop. Called every frame. */ function gameLoop(runtime: number): void { loadbalancer.update(runtime); // Updates fps, delta time, etc.. game.update(); // Always update the game, even if we're afk. By FAR this is less resource intensive than rendering! render(); // Render everything // Reset all event listeners states so we can catch any new events that happen for the next frame. document.dispatchEvent(new Event('reset-listener-events')); // Loop again while app is running. frameratelimiter.requestFrame(gameLoop); } function render(): void { if (!frametracker.doWeRenderNextFrame()) return; // Only render the world though if any visual on the screen changed! This is to save cpu when there's no page interaction or we're afk. // console.log("Rendering this frame"); webgl.clearScreen(); // Clear the color buffer and depth buffers maskedDraw.onFrameStart(); // Reset stencil bit-pair index for this frame game.render(); frametracker.onFrameRender(); } globalThis.main = { start }; ================================================ FILE: src/client/scripts/esm/game/misc/controls.ts ================================================ // src/client/scripts/esm/game/misc/controls.ts /** * This script controls the board navigation * via the WASD keys, space/shift, and mouse wheel. */ import type { Mesh } from '../rendering/piecemodels.js'; import type { FullGame } from '../../../../../shared/chess/logic/gamefile.js'; import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import vectors from '../../../../../shared/util/math/vectors.js'; import toast from '../gui/toast.js'; import stats from '../gui/stats.js'; import mouse from '../../util/mouse.js'; import camera from '../rendering/camera.js'; import docutil from '../../util/docutil.js'; import guipause from '../gui/guipause.js'; import copygame from '../chess/copygame.js'; import boardpos from '../rendering/boardpos.js'; import socketman from '../websocket/socketman.js'; import boarddrag from '../rendering/boarddrag.js'; import selection from '../chess/selection.js'; import animation from '../rendering/animation.js'; import miniimage from '../rendering/miniimage.js'; import Transition from '../rendering/transitions/Transition.js'; import enginegame from './enginegame.js'; import perspective from '../rendering/perspective.js'; import piecemodels from '../rendering/piecemodels.js'; import guigameinfo from '../gui/guigameinfo.js'; import boardeditor from '../boardeditor/boardeditor.js'; import loadbalancer from './loadbalancer.js'; import guipromotion from '../gui/guipromotion.js'; import guinavigation from '../gui/guinavigation.js'; import { listener_document } from '../chess/game.js'; import specialrighthighlights from '../rendering/highlights/specialrighthighlights.js'; // Constants ------------------------------------------------------------------- /** The accelleration/deceleration rate of the board velocity in 2D mode. */ const panAccel2D: number = 145; // Default: 145 /** The accelleration/deceleration rate of the board velocity in 3D mode. */ const panAccel3D: number = 75; // Default: 75 /** The acceleration/deration rate of the board SCALE velocity in 2D mode. */ const scaleAccel_Desktop: number = 6.0; // Acceleration of board scaling Default: 6 /** * The deceleration rate of the board SCALE velocity in 3D mode. * (No accerlation, scale velocity is determined by finger movement) */ const scaleAccel_Mobile: number = 14.0; // Acceleration of board scaling Default: 14 /** * This is the scale velocity cap when using Space/Shift. * It is NOT the absoulte cap which you can reach by scrolling. */ const scaleVelCap = 2.0; // Default: 1 /** The scale velocity cap when u sing the scroll wheel (higher). */ const scaleVelCap_Scroll = 2.5; /** Dampener multiplied to the wheel delta before applying it to the scale velocity. */ const wheelMultiplier = 0.015; // Default: 0.015 // Panning & Zooming Controls WASD/Space/Shift/Wheel ------------------------------------------------------ // Called from game.updateBoard() function updateNavControls(): void { if (guipause.areWePaused()) return; // Exit if paused boarddrag.checkIfBoardDropped(); // Needs to be before exiting from teleporting if (Transition.areTransitioning()) return; // Exit if teleporting // Keyboard detectPanning(); // Movement (WASD) detectZooming(); // Zoom/Scale (Space shift, mouse wheel) } /** Detects WASD controls, updating board velocity accordingly. */ function detectPanning(): void { if (boarddrag.isBoardDragging()) return; // Only pan if we aren't dragging the board let panVel = boardpos.getPanVel(); let panning = false; // Any panning key pressed this frame? if (!guipromotion.isUIOpen()) { // Disable the controls temporarily if (listener_document.isKeyHeld('KeyD')) { panning = true; accelPanVel(panVel, 0); } if (listener_document.isKeyHeld('KeyA')) { panning = true; accelPanVel(panVel, 180); } if (listener_document.isKeyHeld('KeyW')) { panning = true; accelPanVel(panVel, 90); } if (listener_document.isKeyHeld('KeyS')) { panning = true; accelPanVel(panVel, -90); } } if (panning) { // Make sure the velocity doesn't exceed the cap const hyp = Math.hypot(...panVel); const relativePanVelCap = boardpos.getRelativePanVelCap(); const ratio = hyp / relativePanVelCap; if (ratio > 1) { // Too fast, divide components by the ratio to cap our velocity panVel[0] /= ratio; panVel[1] /= ratio; } } else { panVel = deccelPanVel(panVel); } boardpos.setPanVel(panVel); // Set the pan velocity } /** Accelerates the given pan velocity in the provided vector direction. */ function accelPanVel(panVel: DoubleCoords, angleDegs: number): DoubleCoords { const baseAngle = -perspective.getRotZ(); const dirOfTravel = baseAngle + angleDegs; const angleRad = vectors.degreesToRadians(dirOfTravel); const XYComponents: DoubleCoords = vectors.getXYComponents_FromAngle(angleRad); const accelToUse = perspective.getEnabled() ? panAccel3D : panAccel2D; panVel[0] += loadbalancer.getDeltaTime() * accelToUse * XYComponents[0]; panVel[1] += loadbalancer.getDeltaTime() * accelToUse * XYComponents[1]; return panVel; } /** Deccelerates the given pan velocity towards zero, without skipping past it. */ function deccelPanVel(panVel: DoubleCoords): DoubleCoords { if (panVel[0] === 0 && panVel[1] === 0) return panVel; // Already stopped const rateToUse = perspective.getEnabled() ? panAccel3D : panAccel2D; const hyp = Math.hypot(...panVel); const newHyp = hyp - loadbalancer.getDeltaTime() * rateToUse; if (newHyp < 0) return [0, 0]; // Stop completely before we start going in the opposite direction const ratio = newHyp / hyp; const newPanVel: DoubleCoords = [panVel[0] * ratio, panVel[1] * ratio]; return newPanVel; } /** Detects Space/Shift/Wheel controls, updating board SCALE velocity accordingly. */ function detectZooming(): void { let scaleVel = boardpos.getScaleVel(); let scaling = false; let scrolling = false; if (!guipromotion.isUIOpen()) { // Disable the controls temporarily // Space/Shift if (listener_document.isKeyHeld('Space')) { scaling = true; scaleVel -= loadbalancer.getDeltaTime() * scaleAccel_Desktop; } if (listener_document.isKeyHeld('ShiftLeft')) { scaling = true; scaleVel += loadbalancer.getDeltaTime() * scaleAccel_Desktop; } // Mouse wheel const wheelDelta = mouse.getWheelDelta(); if (wheelDelta !== 0) { scaling = true; scrolling = true; scaleVel -= wheelMultiplier * wheelDelta; } } if (scaling) { // Cap the velocity const capToUse = scrolling ? scaleVelCap_Scroll : scaleVelCap; if (scaleVel > capToUse) scaleVel = capToUse; else if (scaleVel < -capToUse) scaleVel = -capToUse; } else { scaleVel = deccelerateScaleVel(scaleVel); } boardpos.setScaleVel(scaleVel); } /** Deccelerates the given scale velocity towards zero, without skipping past it. */ function deccelerateScaleVel(scaleVel: number): number { if (scaleVel === 0) return scaleVel; // Already stopped const deccelerationToUse = docutil.isMouseSupported() ? scaleAccel_Desktop : scaleAccel_Mobile; if (scaleVel > 0) { scaleVel -= loadbalancer.getDeltaTime() * deccelerationToUse; if (scaleVel < 0) scaleVel = 0; } else { // scaleVel < 0 scaleVel += loadbalancer.getDeltaTime() * deccelerationToUse; if (scaleVel > 0) scaleVel = 0; } return scaleVel; } // Toggles --------------------------------------------------------------------------------- /** Debug toggles that are not only for in a game, but outside. */ function testOutGameToggles(): void { if (listener_document.isKeyDown('Backquote')) camera.toggleDebug(); if (listener_document.isKeyDown('Digit4')) socketman.toggleDebug(); // Adds simulated websocket latency with high ping if (listener_document.isKeyDown('Digit7')) enginegame.toggleDebug(); // Render engine generated legal moves if (listener_document.isKeyDown('KeyM')) stats.toggleFPS(); } /** Debug toggles that are only for in a game. */ function testInGameToggles(gamefile: FullGame, mesh: Mesh | undefined): void { if (listener_document.isKeyDown('Escape')) guipause.toggle(); if (listener_document.isKeyDown('Digit1')) selection.toggleEditMode(); // EDIT MODE TOGGLE if (listener_document.isKeyDown('Digit2')) { console.log(jsutil.deepCopyObject(gamefile)); console.log('Estimated gamefile memory usage: ' + jsutil.estimateMemorySizeOf(gamefile)); } if (listener_document.isKeyDown('Digit3')) animation.toggleDebug(); // Each animation slows down and renders continuous ribbon if (listener_document.isKeyDown('Digit5')) copygame.copyGame(true); // Copies the gamefile as a single position, without all the moves. if (listener_document.isKeyDown('Digit6')) specialrighthighlights.toggle(); // Highlights special rights and en passant if (listener_document.isKeyDown('Tab')) guipause.callback_ToggleArrows(); if (mesh && listener_document.isKeyDown('KeyR')) { piecemodels.regenAll(gamefile.boardsim, mesh); toast.show('Regenerated piece models.', { durationMultiplier: 0.5 }); } if (listener_document.isKeyDown('KeyN')) { guinavigation.toggle(); if (!boardeditor.areInBoardEditor()) guigameinfo.toggle(); } if (listener_document.isKeyDown('KeyP')) miniimage.toggle(); guinavigation.update(); } // Exports --------------------------------------------------------------------------------- export default { updateNavControls, testOutGameToggles, testInGameToggles, }; ================================================ FILE: src/client/scripts/esm/game/misc/enginegame.ts ================================================ // src/client/scripts/esm/game/misc/enginegame.ts // This module keeps track of the data of the engine game we are currently in. import type { Player } from '../../../../../shared/chess/util/typeutil.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import movevalidation from '../../../../../shared/chess/logic/movevalidation.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import typeutil, { players as p } from '../../../../../shared/chess/util/typeutil.js'; import coordutil, { Coords, CoordsKey } from '../../../../../shared/chess/util/coordutil.js'; import toast from '../gui/toast.js'; import gameslot from '../chess/gameslot.js'; import premoves from '../chess/premoves.js'; import boardpos from '../rendering/boardpos.js'; import snapping from '../rendering/highlights/snapping.js'; import selection from '../chess/selection.js'; import perspective from '../rendering/perspective.js'; import drawsquares from '../rendering/highlights/annotations/drawsquares.js'; import { GameBus } from '../GameBus.js'; import movesequence from '../chess/movesequence.js'; import frametracker from '../rendering/frametracker.js'; import gamecompressor from '../chess/gamecompressor.js'; import squarerendering from '../rendering/highlights/squarerendering.js'; import checkmatepractice from '../chess/checkmatepractice.js'; // Types ------------------------------------------------------------------------ interface EngineConfig { /** Hard time limit for the engine to think in milliseconds */ engineTimeLimitPerMoveMillis: number; // If you are using a checkmate practice engine, this is required. checkmateSelectedID?: string; strengthLevel?: number; multiPv?: number; } // Variables -------------------------------------------------------------------- /** Whether we are currently in an engine game. */ let inEngineGame: boolean = false; let ourColor: Player | undefined; let engineColor: Player | undefined; let currentEngine: string | undefined; // name of the current engine used let engineConfig: EngineConfig | undefined; // json that is sent to the engine, giving it extra config information let engineWorker: Worker | undefined; /** Whether to render the engine's generated legal moves for debugging purposes. Toggled by pressing `7`. */ let move_gen_debug: boolean = false; /** Stores the legal moves generated by the engine for each move index in the game history. */ const moveHistoryLegalMoves: Map = new Map(); /** Queue of pending debug requests with their move indices */ const pendingDebugRequests: number[] = []; // Events ----------------------------------------------------------------------- GameBus.addEventListener('user-move-played', () => { onMovePlayed(); }); GameBus.addEventListener('game-concluded', () => { if (!inEngineGame) return; checkmatepractice.onEngineGameConclude(); }); // Functions ------------------------------------------------------------------------ function areInEngineGame(): boolean { return inEngineGame; } function getOurColor(): Player | undefined { if (!inEngineGame) throw Error('Cannot get our color if we are not in an engine game!'); return ourColor!; } function isItOurTurn(): boolean { if (!inEngineGame) throw Error("Cannot get isItOurTurn of engine game when we're not in an engine game."); return gameslot.getGamefile()!.basegame.whosTurn === ourColor; } function getCurrentEngine(): string | undefined { return currentEngine; } /** * Inits an engine game. In particular, it needs gameOptions in order to know what engine to use for this enginegame. * This method launches an engine webworker for the current game. * @param {Object} options - An object that contains the properties `currentEngine` and `engineConfig` */ function initEngineGame(options: { youAreColor: Player; currentEngine: string; engineConfig: EngineConfig; }): Promise { console.log(`Starting engine game with engine "${options.currentEngine}".`); inEngineGame = true; ourColor = options.youAreColor; engineColor = typeutil.invertPlayer(ourColor); currentEngine = options.currentEngine; engineConfig = options.engineConfig; // Initialize the engine as a webworker if (!window.Worker) { alert("Your browser doesn't support web workers. Cannot play against an engine."); // Reject the promise returned by this function return Promise.reject( new Error("Cannot finish loading engine game because web workers aren't supported."), ); } engineWorker = new Worker(`../scripts/esm/game/chess/engines/${currentEngine}.js`, { type: 'module', }); // module type allows the web worker to import methods and types from other scripts. // Return a promise that resolves when the ENGINE WORKER has finished fetching/loading. return new Promise((resolve, reject): void => { // Set up a handler for the 'isready' command that indicates the worker is loaded and ready // We have to manually send this message at the top of our engines. engineWorker!.onmessage = (e: MessageEvent): void => { if (e.data === 'readyok') resolve(); // Engine is ready! }; engineWorker!.onerror = (e: ErrorEvent): void => { reject(new Error('Worker failed to load: ' + e.message)); }; }).then((_result: any) => { // After the promise resolves, we know the worker is ready // Overwrite the onmessage listener to listen for move submissions engineWorker!.onmessage = (e: MessageEvent): void => handleEngineMessage(e.data); // Remove the error handler (no longer needed after worker is ready) engineWorker!.onerror = null; // Ensures if the debug mode was on before starting an engine game, // the engine generated legal moves are rendered as soon as the engine is ready. requestMovesForCurrentPosition(); }); } // Call when we leave an engine game function closeEngineGame(): void { inEngineGame = false; ourColor = undefined; engineColor = undefined; currentEngine = undefined; engineConfig = undefined; moveHistoryLegalMoves.clear(); pendingDebugRequests.length = 0; perspective.resetRotations(); // Without this, leaving an engine game of which we were black, won't reset our rotation. // terminate the webworker if (engineWorker) engineWorker.terminate(); engineWorker = undefined; checkmatepractice.onGameUnload(); } /** * Tests if we are this color in the engine game. * @param color - p.WHITE / p.BLACK * @returns *true* if we are that color. */ function areWeColor(color: Player): boolean { return color === ourColor; } /** * This method is called externally when the player submits his move in an engine game * It submits the gamefile to the webworker */ function onMovePlayed(): void { if (!inEngineGame) return; // Don't do anything if it's not an engine game const gamefile = gameslot.getGamefile()!; // Make sure it's the engine's turn if (gamefile.basegame.whosTurn !== engineColor) return; // Don't do anything if it's our turn (not the engines) checkmatepractice.registerHumanMove(); // inform the checkmatepractice script that the human player has made a move if (gamefile.basegame.gameConclusion) return; // Don't do anything if the game is over requestMovesForCurrentPosition(); // Request generated moves for debugging FIRST // Request the engine to perform a best move calculation... const longformIn = gamecompressor.compressGamefile(gamefile); // Compress the gamefile to send to the engine in a simpler json format // Send the gamefile to the engine web worker /** This has all nested functions removed. */ const stringGamefile = JSON.stringify(gamefile, jsutil.stringifyReplacer); // Derive clock times for both colors in milliseconds, similar to UCI wtime/btime/winc/binc let wtime: number | undefined; let btime: number | undefined; let winc: number | undefined; let binc: number | undefined; const basegame = gamefile.basegame; const clocks = basegame.clocks; if (!basegame.untimed && clocks) { wtime = clocks.currentTime[p.WHITE]; btime = clocks.currentTime[p.BLACK]; const incSeconds = clocks.startTime.increment; winc = incSeconds * 1000; binc = incSeconds * 1000; } // prettier-ignore const timing = wtime !== undefined && btime !== undefined ? { wtime, btime, winc, binc, } : undefined; if (engineWorker) engineWorker.postMessage({ stringGamefile, lf: longformIn, engineConfig: engineConfig, youAreColor: engineColor, wtime: timing?.wtime, btime: timing?.btime, winc: timing?.winc, binc: timing?.binc, }); else console.error('User made a move in an engine game but no engine webworker is loaded!'); } function handleEngineMessage(data: any): void { // console.log('Received message from engine worker:', data); if (typeof data !== 'object' || data === null) { console.error('Received invalid message from engine worker:', data); return; } // Check if the message contains generated moves for debugging if (data.type === 'move') { // Message contains the engine's best move suggestion makeEngineMove(data.data); } else if (data.type === 'generatedMoves') { // Store the moves at the oldest pending request's move index if (pendingDebugRequests.length > 0) { const requestedMoveIndex = pendingDebugRequests.shift()!; moveHistoryLegalMoves.set(requestedMoveIndex, [...data.data]); } // console.log('Received generated moves from engine worker:', data.data); frametracker.onVisualChange(); // Ensure the frame is rendered } else { console.error('Received unknown message from engine worker:', data); } } /** * This method takes care of all the logic involved in making an engine move * It gets called after the engine finishes its calculation * @param move - The move that SHOULD be a string in compact format "x,y>x,y=P" */ function makeEngineMove(tokenMove: unknown): void { if (!inEngineGame) return; if (!currentEngine) return console.error('Attempting to make engine move, but no engine loaded!'); const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); if (tokenMove === null) { // Null can mean the engine didn't return a best move (perhaps it didn't // find any legal moves, or thought it was checkmate), or an error occurred. // In this case, resign for the engine. console.log(`Engine returned a null move. Resigning the game...`); gamefileutility.setConclusion(gamefile.basegame, { condition: 'resignation', victor: ourColor!, }); gameslot.concludeGame(); return; } premoves.performWithUnapplied(gamefile, mesh, () => { const moveValidationResults = movevalidation.isTokenMoveLegal(gamefile, tokenMove); if (!moveValidationResults.valid) { toast.show( `Engine submitted an illegal move. Please report this bug! Move "${tokenMove}" is illegal for reason: ${moveValidationResults.reason}`, { error: true, durationMultiplier: 100 }, ); return false; // Don't physically play next premove } // Go to latest move before making a new move movesequence.viewFront(gamefile, mesh); movesequence.makeMoveAndAnimate(gamefile, mesh, moveValidationResults.tagged); checkmatepractice.registerEngineMove(); // inform the checkmatepractice script that the engine has made a move // If the debug mode is on, request the generated moves for the new position after playing the engine's move requestMovesForCurrentPosition(); return true; // Good to physically play next premove }); selection.reselectPiece(); // Reselect the currently selected piece. Recalc its moves and recolor it if needed. } /** Toggles the rendering of engine generated legal moves for debugging purposes. */ function toggleDebug(): void { move_gen_debug = !move_gen_debug; toast.show(`Toggled engine move gen highlights: ${move_gen_debug}`); if (!move_gen_debug) pendingDebugRequests.length = 0; // Turning off: Clear pending requests. else requestMovesForCurrentPosition(); // Turning on: Request moves for current position. } /** Callback for enginegame actions when a new local move is viewed. */ function onViewMove(): void { // Request the move gen for the current ply, if debug mode is on requestMovesForCurrentPosition(); } /** * Requests legal moves for the currently viewed position if not already cached. * Should be called when navigating through move history with debug mode on. */ function requestMovesForCurrentPosition(): void { if (!inEngineGame || !move_gen_debug) return; const gamefile = gameslot.getGamefile()!; const currentMoveIndex = gamefile.boardsim.state.local.moveIndex; if (moveHistoryLegalMoves.has(currentMoveIndex)) return; // Already have move gen for this position // Add a new move gen request to pending queue pendingDebugRequests.push(currentMoveIndex); // Compress the gamefile as a single position (not including future moves) // This ensures the engine analyzes the currently viewed position const longformIn = gamecompressor.compressGamefile(gamefile, true); const stringGamefile = JSON.stringify(gamefile, jsutil.stringifyReplacer); if (engineWorker) engineWorker.postMessage({ stringGamefile, lf: longformIn, engineConfig: engineConfig, youAreColor: engineColor, requestGeneratedMoves: true, }); } /** Renders a debug preview of the engine's generated legal moves for the current position. */ function render(): void { if (!inEngineGame) return; if (!move_gen_debug) return; // Get the moves for the current position const gamefile = gameslot.getGamefile()!; const currentMoveIndex = gamefile.boardsim.state.local.moveIndex; const currentMoves = moveHistoryLegalMoves.get(currentMoveIndex) || []; if (currentMoves.length === 0) return; // No moves to render // Map moves to squares const coordsKeys: CoordsKey[] = currentMoves.flatMap((moveStr: string) => { const [_from, to] = moveStr.split('>'); return [to]; // We only care about the destination square for highlighting }) as CoordsKey[]; // ["x,y", ...] const coords: Coords[] = coordsKeys.map((s) => coordutil.getCoordsFromKey(s)); // [[x,y], ...] // If we're zoomed out, then the size of the moves are constant. const u_size = boardpos.areZoomedOut() ? snapping.getEntityWidthWorld() : boardpos.getBoardScaleAsNumber(); const color = drawsquares.PRESET_SQUARE_COLOR; // Render legal move squares squarerendering.genModel(coords, color).render(undefined, undefined, { u_size }); } // Export --------------------------------------------------------------------------------- export default { areInEngineGame, getOurColor, isItOurTurn, getCurrentEngine, initEngineGame, closeEngineGame, areWeColor, onMovePlayed, toggleDebug, render, onViewMove, }; export type { EngineConfig }; ================================================ FILE: src/client/scripts/esm/game/misc/gamesound.ts ================================================ // src/client/scripts/esm/game/misc/gamesound.ts /** * This script is in charge of storing our audio * spritesheet, and playing game sound effects. * It takes variables such as distances pieces moved * so it can deduce the correct sound play options when * calling {@link AudioManager.playAudio}. */ import type { Coords } from '../../../../../shared/chess/util/coordutil.js'; import type { EffectConfig } from '../../audio/AudioEffects.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import math from '../../../../../shared/util/math/math.js'; import screenshake from '../rendering/screenshake.js'; import WaterRipples from '../rendering/WaterRipples.js'; import AudioManager, { SoundObject } from '../../audio/AudioManager.js'; // Constants -------------------------------------------------------------------------- /** The timestamps where each game sound effect starts and ends inside our sound spritesheet. */ const soundStamps = { gamestart: [0, 2.008], move: [2.009, 2.15], capture: [2.151, 2.462], bell: [2.463, 5.402], lowtime: [5.404, 5.985], win: [5.986, 7.994], draw: [7.995, 10.003], loss: [10.004, 12.012], drum1: [12.013, 16.012], drum2: [16.013, 19.262], tick: [19.263, 25.012], ticking: [25.013, 36.357], viola_staccato_c3: [36.359, 38.357], violin_staccato_c4: [38.359, 40.357], marimba_c2: [40.359, 42.356], marimba_c2_soft: [42.357, 44.356], base_staccato_c2: [44.357, 46.354], ripple: [46.356, 50.354], glass_crack_1: [50.356, 50.76], glass_crack_2: [50.76, 51.848], glass_crack_3: [51.848, 52.621], glass_crack_4: [52.621, 53.222], glass_crack_5: [53.222, 53.627], } as const; type SoundName = keyof typeof soundStamps; type SoundTimeSnippet = readonly [number, number]; // Move Configs -------------------------------------------------------------------------- /** Config for successive, or rapidly played move sounds. */ const SUCCESSIVE_MOVES_CONFIG = { /** If move sounds are played within this time, they get delayed until this amount time has passed, in milliseconds. * This is to prevent sounds from playing at the exact same time, such as the king & rook while castling. */ gap: 35, /** The threshold in milliseconds to count two move sounds as successive. */ threshold: 60, /** The volume dampener for successive move sounds. */ dampener: 0.5, } as const; /** Config for controlling moves' reverb effect. */ const REVERB_CONFIG = { /** The maximum `wetLevel` to use for moves' reverb effects. */ maxWetLevel: 3.5, /** The duration of moves' reverb effects, in seconds. */ duration: 1.5, /** The minimum distance a piece needs to move for a reverb effect to gradually increase in volume. */ minDist: 15, /** The distance a piece needs to move for the reverb effect to be at its max volume. */ maxDist: 80, } as const; /** Config for the bell gong sound effect when moves are extremely large. */ const BELL_CONFIG = { /** The distance a piece needs to move for the bell sound to play. */ minDist: bd.fromBigInt(1_000_000n), /** The volume of the bell gongs, as a multiplier to the move sound's volume. */ volume: 0.6, } as const; /** Config for the water droplet ripple effect for EXTREMELY large moves. */ const RIPPLE_CONFIG = { /** * The minimum distance a piece needs to move for the water droplet ripple effect to trigger. * At current settings, this starts at the Spectral Edge beginning. */ minDist: bd.fromBigInt(10n ** 120n), // 10^120 squares // minDist: bd.fromBigInt(20n), // FOR TESTING maxPlaybackRate: 1.18, minPlaybackRate: 1.0, /** * How much slower the playback rate is, depending on how far you move. * 0.002 yields .18 playback rate travel in e90 * At current settings, it stops decreasing at about e210, 30e after Iridescence zone begins. */ playbackRateReductionPerE: 0.002, // Default: 0.002 /** The volume of the ripple sound effecet, as a multiplier to the move sound's volume. */ volume: 0.8, } as const; /** Config for the screen shake effect for very large moves. */ const SHAKE_CONFIG = { /** The order of magnitude distance a piece needs to move for the screen shake to begin triggering. */ minDist: 4, // 10,000 squares => trauma begins increasing from 0 /** * How much screen shake trauma is added per order of magnitude the piece moved. * 0.012 yields 1.0 shake trauma at about 1e90 */ traumaMultiplier: 0.012, }; /** Config for playing premove sound effects. */ const PREMOVE_CONFIG = { /** Premove sounds are played faster so they sound more like a click. */ playbackRate: 1.5, /** Premove sounds are slightly quieter. */ volume: 0.5, } as const; // Initiation Variables -------------------------------------------------------------------------- /** The decoded buffer of the fetched game sound spritesheet. */ let spritesheetDecodedBuffer: AudioBuffer | undefined = undefined; // State ------------------------------------------------------------------------------ /** Timestamp of the last played move sound. */ let timeLastMoveOrCaptureSound = 0; // Spritesheet Buffer ---------------------------------------------------- // Fetch and decode the buffer of the sound spritesheet. fetch('sounds/spritesheet/soundspritesheet.opus') .then((response) => response.arrayBuffer()) .then((arrayBuffer) => AudioManager.decodeAudioData(arrayBuffer)) .then((decodedBuffer) => { spritesheetDecodedBuffer = decodedBuffer; // console.log('Sound spritesheet loaded and decoded successfully.'); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(`An error ocurred during loading of sound spritesheet: ${message}`); }); /** Retrieves the sound time snippet for the specified sound. */ function getSoundStamp(soundName: SoundName): SoundTimeSnippet { const stamp = soundStamps[soundName]; if (!stamp) throw new Error(`Cannot return sound stamp for unknown sound "${soundName}".`); return stamp; } /** Calculates the duration of a sound time snippet in seconds. */ function getStampDuration(stamp: SoundTimeSnippet): number { // [ startTimeSecs, endTimeSecs ] return stamp[1] - stamp[0]; } /** Retrieves the start time and duration of a sound inside the spritesheet. */ function getSoundTimeSnippet(soundName: SoundName): { startTime: number; duration: number } { const stamp = getSoundStamp(soundName); const startTime = stamp[0]; const duration = getStampDuration(stamp); return { startTime, duration }; } // Playing Sounds ----------------------------------------------------------------------------- /** * Plays a sound by name from the spritesheet. * @param soundName The name of the sound to play. * @param options Optional parameters like volume, delay, and offset. * @returns A SoundObject if the sound is played, otherwise undefined. */ function playSoundEffect( soundName: SoundName, options: { volume?: number; delay?: number; offset?: number; reverbWetLevel?: number; reverbDuration?: number; playbackRate?: number; bypassDownsampler?: boolean; } = {}, ): SoundObject | undefined { let { startTime, duration } = getSoundTimeSnippet(soundName); const { volume, delay, offset, reverbWetLevel, reverbDuration, playbackRate, bypassDownsampler, } = options; // If offset is specified, adjust the start time and duration accordingly if (offset) { const offsetSecs = offset / 1000; startTime += offsetSecs; duration -= offsetSecs; // Don't play the sound if the offset exceeds the sound duration (can happen with 'tick' sound) if (duration <= 0) return; } // Add reverb effect if specified const effects: EffectConfig[] = []; if (reverbWetLevel && reverbDuration) effects.push({ type: 'reverb', durationSecs: reverbDuration, dryLevel: 1, wetLevel: reverbWetLevel, }); return AudioManager.playAudio(spritesheetDecodedBuffer, { startTime, duration, volume, delay, playbackRate, effects, bypassDownsampler, }); } /** * Plays a piece move sound effect. * Automatically handles effects such as capture, reverb, bell, dampening, etc. * @param distanceMoved - How far the piece moved. * @param capture - Whether this move made a capture. * @param premove - Whether this move is a premove. * @param destination - Optional. The destination coordinates of the piece move, for ripple effects. */ function playMove( distanceMoved: BigDecimal, capture: boolean, premove: boolean, destination?: Coords, ): void { // Update the time since the last move sound was played const now = Date.now(); const timeSinceLastMoveSoundPlayed = now - timeLastMoveOrCaptureSound; timeLastMoveOrCaptureSound = now; const soundEffectName = capture ? 'capture' : 'move'; // Determine if we should add delay (sounds played at same time, such as the king & rook while castling) const delaySecs = Math.max(0, SUCCESSIVE_MOVES_CONFIG.gap - timeSinceLastMoveSoundPlayed) / 1000; // Determine if we should dampen the sound (sounds played successively, close together) const shouldDampen = timeSinceLastMoveSoundPlayed < SUCCESSIVE_MOVES_CONFIG.threshold; const successiveDampener = shouldDampen ? SUCCESSIVE_MOVES_CONFIG.dampener : 1; // Successively played moves are quieter const premoveDampener = premove ? PREMOVE_CONFIG.volume : 1; // Premoves are slightly quieter const dampener = successiveDampener * premoveDampener; // Total dampener const volume = 1 * dampener; const playbackRate = premove ? PREMOVE_CONFIG.playbackRate : 1; // Premove moves are played faster, so they sound more like a click. const { reverbWetLevel, reverbDuration } = calculateReverb(distanceMoved); playSoundEffect(soundEffectName, { volume, reverbWetLevel, reverbDuration, delay: delaySecs, playbackRate, }); if (destination && bd.compare(distanceMoved, RIPPLE_CONFIG.minDist) >= 0) { // Trigger water dropplet ripple effect const rippleVolume = volume * RIPPLE_CONFIG.volume; // Calculate playback rate based on distance moved const eDifference = bd.log10(distanceMoved) - bd.log10(RIPPLE_CONFIG.minDist); const ripplePlayrate = playbackRate * Math.max( RIPPLE_CONFIG.maxPlaybackRate - eDifference * RIPPLE_CONFIG.playbackRateReductionPerE, RIPPLE_CONFIG.minPlaybackRate, ); // console.log("Ripple playrate:", ripplePlayrate); playSoundEffect('ripple', { volume: rippleVolume, delay: delaySecs, playbackRate: ripplePlayrate, }); WaterRipples.addRipple(destination); screenshake.trigger(0.25); } else { // Apply screen shake for very large moves const rawTrauma = (bd.log10(distanceMoved) - SHAKE_CONFIG.minDist) * SHAKE_CONFIG.traumaMultiplier; const trauma = math.clamp(rawTrauma, 0, 1); if (trauma > 0) screenshake.trigger(trauma); if (bd.compare(distanceMoved, BELL_CONFIG.minDist) >= 0) { // Move is large enough to play the bell sound too const bellVolume = volume * BELL_CONFIG.volume; playSoundEffect('bell', { volume: bellVolume, delay: delaySecs, playbackRate }); } } } /** Takes the distance a piece moved, and returns the applicable reverb wet level and duration. */ function calculateReverb( distanceMoved: BigDecimal, ): | { reverbWetLevel: number; reverbDuration: number } | { reverbWetLevel: undefined; reverbDuration: undefined } { const distanceMovedNum = bd.toNumber(distanceMoved); const x = (distanceMovedNum - REVERB_CONFIG.minDist) / (REVERB_CONFIG.maxDist - REVERB_CONFIG.minDist); // 0-1 if (x <= 0) return { reverbWetLevel: undefined, reverbDuration: undefined }; else if (x >= 1) return { reverbWetLevel: REVERB_CONFIG.maxWetLevel, reverbDuration: REVERB_CONFIG.duration, }; const reverbWetLevel = REVERB_CONFIG.maxWetLevel * x; // No easing applied, for now return { reverbWetLevel, reverbDuration: REVERB_CONFIG.duration }; } function playGamestart(): SoundObject | undefined { return playSoundEffect('gamestart', { volume: 0.4, bypassDownsampler: true }); } function playWin(delay?: number): SoundObject | undefined { return playSoundEffect('win', { volume: 0.7, delay }); } function playDraw(delay?: number): SoundObject | undefined { return playSoundEffect('draw', { volume: 0.7, delay }); } function playLoss(delay?: number): SoundObject | undefined { return playSoundEffect('loss', { volume: 0.7, delay }); } function playLowtime(): SoundObject | undefined { return playSoundEffect('lowtime'); } function playDrum(): SoundObject | undefined { const soundName = Math.random() > 0.5 ? 'drum1' : 'drum2'; return playSoundEffect(soundName, { volume: 0.7 }); } function playTick({ volume, offset }: { volume?: number; offset?: number } = {}): | SoundObject | undefined { return playSoundEffect('tick', { volume, offset }); } function playTicking({ volume, offset }: { volume?: number; offset?: number } = {}): | SoundObject | undefined { return playSoundEffect('ticking', { volume, offset }); } function playViola_c3({ volume }: { volume?: number } = {}): SoundObject | undefined { return playSoundEffect('viola_staccato_c3', { volume }); } function playViolin_c4(): SoundObject | undefined { return playSoundEffect('violin_staccato_c4', { volume: 0.9 }); } function playMarimba(): SoundObject | undefined { const audioName = Math.random() > 0.15 ? 'marimba_c2_soft' : 'marimba_c2'; return playSoundEffect(audioName, { volume: 0.4 }); } function playBase(): SoundObject | undefined { return playSoundEffect('base_staccato_c2', { volume: 0.8 }); } function playGlassCrack(): SoundObject | undefined { const rand = Math.random(); // prettier-ignore const soundName: SoundName = rand < 0.2 ? 'glass_crack_1' : rand < 0.4 ? 'glass_crack_2' : rand < 0.6 ? 'glass_crack_3' : rand < 0.8 ? 'glass_crack_4' : 'glass_crack_5'; const PLAYRATE_BASE_OFFSET = -0.2; const PLAYRATE_VARIATION = 0.07; const playrate = 1 + (Math.random() * 2 - 1) * PLAYRATE_VARIATION + PLAYRATE_BASE_OFFSET; return playSoundEffect(soundName, { volume: 0.04, playbackRate: playrate, reverbWetLevel: 4.0, reverbDuration: 0.8, bypassDownsampler: true, }); } // Exports ------------------------------------------------------------------------------ export default { playMove, playGamestart, playWin, playDraw, playLoss, playLowtime, playDrum, playTick, playTicking, playViola_c3, playViolin_c4, playMarimba, playBase, playGlassCrack, }; ================================================ FILE: src/client/scripts/esm/game/misc/invites.ts ================================================ // src/client/scripts/esm/game/misc/invites.ts /** * This script manages the invites on the Play page. */ import type { Player } from '../../../../../shared/chess/util/typeutil.js'; import type { VariantCode } from '../../../../../shared/chess/variants/variantdictionary.js'; import type { TimeControl } from '../../../../../shared/types.js'; import type { Invite, InvitesMessage } from '../websocket/socketschemas.js'; import uuid from '../../../../../shared/util/uuid.js'; import clockutil from '../../../../../shared/chess/util/clockutil.js'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import toast from '../gui/toast.js'; import guiplay from '../gui/guiplay.js'; import docutil from '../../util/docutil.js'; import gamesound from './gamesound.js'; import socketsubs from '../websocket/socketsubs.js'; import LocalStorage from '../../util/LocalStorage.js'; import loadbalancer from './loadbalancer.js'; import validatorama from '../../util/validatorama.js'; import socketmessages from '../websocket/socketmessages.js'; import usernamecontainer from '../../util/usernamecontainer.js'; // Types ------------------------------------------------------------------------- /** Create lobby invite options. */ export interface InviteOptions { variant: VariantCode; clock: TimeControl; color: Player | null; private: 'public' | 'private'; rated: 'casual' | 'rated'; } // Elements ---------------------------------------------------------------------- const invitesContainer = document.getElementById('invites')!; const ourInviteContainer = document.getElementById('our-invite')!; const element_joinExisting = document.getElementById('join-existing')!; const element_inviteCodeCode = document.getElementById('invite-code-code')!; // Variables --------------------------------------------------------------------- /** Whether we have an existing invite created by us. */ let weHaveInvite = false; /** The ID of our existing invite, if any. */ let ourInviteID: string | undefined; // Functions --------------------------------------------------------------------- function gelement_iCodeCode(): HTMLElement { return element_inviteCodeCode; } function update(): void { if (!guiplay.isOpen()) return; // Not on the play screen if (loadbalancer.areWeHibernating()) toast.show(translations.invites.move_mouse, { durationMultiplier: 0.1 }); } function unsubIfWeNotHave(): void { if (!weHaveInvite) unsubFromInvites(); } /** * Unsubscribes from the invites subscriptions list. */ function unsubFromInvites(): void { clear(true); socketsubs.unsubFromSub('invites'); } /** * Update invites list according to new data! * Should be called by websocket script when it receives a * message that the server says is for the "invites" subscription */ function onmessage(contents: InvitesMessage): void { // Any incoming message will have no effect if we're not on the invites page. // This can happen if we have slow network and leave the invites screen before the server sends us an invites-related message. if (!guiplay.isOpen()) return; switch (contents.action) { case 'inviteslist': // Update the list in the document updateInviteList(contents.value.invitesList); updateActiveGameCount(contents.value.currentGameCount); break; case 'gamecount': updateActiveGameCount(contents.value); break; default: // @ts-ignore console.error(`Received message for invites with unknown action: ${contents.action}`); break; } } /** * Sends the create invite request message from the given InviteOptions specified on the invite creation screen. */ function create(variantOptions: InviteOptions): void { if (weHaveInvite) return console.error("We already have an existing invite, can't create more."); const inviteOptions = { variant: variantOptions.variant, clock: variantOptions.clock, color: variantOptions.color, publicity: variantOptions.private, // Only the `private` property is changed to `publicity` rated: variantOptions.rated, }; generateTagForInvite(inviteOptions); guiplay.lockCreateInviteButton(); // The function to execute when we hear back the server's response const onreplyFunc = guiplay.unlockCreateInviteButton; // console.log("Invite options before sending create invite:"); // console.log(inviteOptions); socketmessages.send('invites', 'createinvite', inviteOptions, true, onreplyFunc); } function cancel(inviteID = ourInviteID): void { if (!weHaveInvite) return; if (!inviteID) return toast.show(translations.invites.cannot_cancel, { error: true }); LocalStorage.deleteItem('invite-tag'); guiplay.lockCreateInviteButton(); // The function to execute when we hear back the server's response const onreplyFunc = guiplay.unlockCreateInviteButton; socketmessages.send('invites', 'cancelinvite', inviteID, true, onreplyFunc); } /** * Generates a tag id for the invite parameters before * we send off action "createinvite" to the server. */ function generateTagForInvite(inviteOptions: { variant: string; clock: TimeControl; color: Player | null; publicity: 'public' | 'private'; rated: 'casual' | 'rated'; tag?: string; }): void { // Create and send invite with a tag so we know which ones ours const tag = uuid.generateID_Base62(8); // NEW browser storage method! LocalStorage.saveItem('invite-tag', tag); inviteOptions.tag = tag; } /** Updates the invite elements on the invite creation screen according to the new list provided. */ function updateInviteList(list: Invite[]): void { // { invitesList, currentGameCount } if (!list) return; const alreadySeenOurInvite = weHaveInvite; let alreadyPlayedSound = false; // Close all previous event listeners and delete invites from the document clear(); // Append latest invites to the document and re-init event listeners. let foundOurs = false; let privateInviteID: string | undefined = undefined; ourInviteID = undefined; for (let i = 0; i < list.length; i++) { // { usernamecontainer, variant, clock, color, publicity } const invite = list[i]!; // Is this our own invite? const ours = foundOurs ? false : isInviteOurs(invite); if (ours) { foundOurs = true; ourInviteID = invite.id; if (!alreadySeenOurInvite) { gamesound.playMarimba(); alreadyPlayedSound = true; } } const classes = ['invite', 'button', 'unselectable']; const isPrivate = invite.publicity === 'private'; if (isPrivate) privateInviteID = invite.id; if (ours && !isPrivate) classes.push('ours'); else if (ours && isPrivate) classes.push('private'); const newInvite = createDiv(classes, undefined, invite.id); //
Playername (elo)
//
Standard
//
15m+15s
//
Random
//
Casual
//
Accept
if (invite.usernamecontainer.type === 'guest') { // Standardize the name according to our language. if (ours) invite.usernamecontainer.username = translations.you_indicator; else invite.usernamecontainer.username = translations.guest_indicator; } const username_item = { value: invite.usernamecontainer.username, openInNewWindow: false }; const displayelement_usernamecontainer = usernamecontainer.createUsernameContainer( invite.usernamecontainer.type, username_item, invite.usernamecontainer.rating, ).element; displayelement_usernamecontainer.classList.add('invite-child'); newInvite.appendChild(displayelement_usernamecontainer); // @ts-ignore const variant = createDiv(['invite-child'], translations[invite.variant]); newInvite.appendChild(variant); const time = clockutil.getClockFromKey(invite.clock); const cloc = createDiv(['invite-child'], time); newInvite.appendChild(cloc); // prettier-ignore const uColor: string = ours ? invite.color === p.WHITE ? translations.invites.you_are_white : invite.color === p.BLACK ? translations.invites.you_are_black : translations.invites.random : invite.color === p.WHITE ? translations.invites.you_are_black : invite.color === p.BLACK ? translations.invites.you_are_white : translations.invites.random; const color = createDiv(['invite-child'], uColor); newInvite.appendChild(color); const rated = createDiv(['invite-child'], translations[invite.rated]); newInvite.appendChild(rated); const a: string = ours ? translations.invites.cancel : translations.invites.accept; const accept = createDiv(['invite-child', 'accept'], a); newInvite.appendChild(accept); const targetCont = ours ? ourInviteContainer : invitesContainer; targetCont.appendChild(newInvite); } if (!alreadyPlayedSound) playBaseIfNewInvite(list); weHaveInvite = foundOurs; updateCreateInviteButton(); updatePrivateInviteCode(privateInviteID); guiplay.initListeners_Invites(); // If we are on "Local" and have an existing invite, IMMEDIATELY cancel it! This can happen with slow network. if (weHaveInvite && guiplay.getModeSelected() !== 'online') cancel(); } /** * Plucks base C2 (audio cue) if the new invites list contains an invite from a new person! * * Uses a closure to maintain state of recent users and IDs from the last list. */ const playBaseIfNewInvite = (() => { const COOLDOWN_SECS = 10; const recentUsers: Record = {}; let IDsInLastList: Record = {}; return function (inviteList: Invite[]): void { let playedSound = false; const newIDsInList: Record = {}; inviteList.forEach((invite) => { const name = invite.usernamecontainer.username; const id = invite.id; newIDsInList[id] = true; if (IDsInLastList[id]) return; // Not a new invite, was there last update. if (recentUsers[name]) return; // We recently played a sound for this user if (isInviteOurs(invite)) return; recentUsers[name] = true; setTimeout(() => delete recentUsers[name], COOLDOWN_SECS * 1000); if (playedSound) return; playSoundNewOpponentInvite(); playedSound = true; }); IDsInLastList = newIDsInList; }; })(); function playSoundNewOpponentInvite(): void { if (docutil.isMouseSupported()) gamesound.playBase(); else gamesound.playViola_c3(); } /** * Close all previous event listeners and delete invites from the document * @param resetRecentUsersCache - If true, resets the playBaseIfNewInvite closure's internal state for tracking recent users */ function clear(resetRecentUsersCache?: true): void { guiplay.closeListeners_Invites(); ourInviteContainer.innerHTML = ''; // Deletes all contained invite elements invitesContainer.innerHTML = ''; // Deletes all contained invite elements weHaveInvite = false; ourInviteID = undefined; element_inviteCodeCode.textContent = ''; // Passing in an empty list resets the local scope variables for next time. if (resetRecentUsersCache) playBaseIfNewInvite([]); } /** Deletes all invites and resets create invite button if on play page. */ function clearIfOnPlayPage(): void { if (!guiplay.isOpen()) return; // Not on the play screen clear(); updateCreateInviteButton(); } /** Tests if a virtual invite belongs to us. */ function isInviteOurs(invite: Invite): boolean { if (validatorama.areWeLoggedIn()) { return ( invite.usernamecontainer.type === 'player' && validatorama.getOurUsername() === invite.usernamecontainer.username ); } if (!invite.tag) return invite.id === ourInviteID; // Tag not present (invite converted from an HTML element), compare ID instead. // Compare the tag.. const localStorageTag = LocalStorage.loadItem('invite-tag'); if (!localStorageTag) return false; if (invite.tag === localStorageTag) return true; return false; } /** Creates a virtual invite from the given invite HTML element. */ function getInviteFromElement(inviteElement: HTMLElement): Invite { const id = inviteElement.getAttribute('id')!; /** * Starting from the first child, the order goes: * Usernamecontainer, Variant, TimeControl, Color, Publicity, Rated * (see the {@link Invite} object) */ return { usernamecontainer: usernamecontainer.extractPropertiesFromUsernameContainerElement( inviteElement.children[0] as HTMLDivElement, ), variant: inviteElement.children[1]!.textContent, clock: inviteElement.children[2]!.textContent as TimeControl, color: Number(inviteElement.children[3]!.textContent) as Player, publicity: inviteElement.children[4]!.textContent as 'public' | 'private', rated: inviteElement.children[5]!.textContent as 'casual' | 'rated', id, }; } function createDiv( classes: string[], textContent: string | undefined, id?: string, ): HTMLDivElement { const element = document.createElement('div'); classes.forEach((c) => element.classList.add(c)); if (textContent !== undefined) element.textContent = textContent; if (id !== undefined) element.id = id; return element; } function accept(inviteID: string, isPrivate: boolean): void { const inviteinfo = { id: inviteID, isPrivate }; guiplay.lockAcceptInviteButton(); // The function to execute when we hear back the server's response const onreplyFunc = guiplay.unlockAcceptInviteButton; socketmessages.send('invites', 'acceptinvite', inviteinfo, true, onreplyFunc); } /** Called when an invite element is clicked. */ function click(element: HTMLElement): void { const invite = getInviteFromElement(element); const isOurs = isInviteOurs(invite); if (isOurs) { // Only cancel if the Create Invite button isn't disabled if (!guiplay.isCreateInviteButtonLocked()) cancel(invite.id); } else { // Not our invite, accept the one we clicked if (!guiplay.isAcceptInviteButtonLocked()) accept(invite.id, false); } } function updateCreateInviteButton(): void { if (guiplay.getModeSelected() !== 'online') return; if (weHaveInvite) guiplay.setElement_CreateInviteTextContent(translations.invites.cancel_invite); else guiplay.setElement_CreateInviteTextContent(translations.invites.create_invite); } function updatePrivateInviteCode(privateInviteID: string | undefined): void { // If undefined, we know we don't have a "private" invite if (guiplay.getModeSelected() === 'local') return; if (!weHaveInvite) { guiplay.showElement_joinPrivate(); guiplay.hideElement_inviteCode(); return; } // We have an invite... // If the classlist of our private invite contains a "private" property of "private", // then display our invite code text! if (privateInviteID) { guiplay.hideElement_joinPrivate(); guiplay.showElement_inviteCode(); element_inviteCodeCode.textContent = privateInviteID.toUpperCase(); return; } // Else our invite is NOT private, only show the "Private Invite:" display. guiplay.showElement_joinPrivate(); guiplay.hideElement_inviteCode(); } function updateActiveGameCount(newCount: number): void { if (newCount === undefined) throw Error('Need to specify active game count'); element_joinExisting.textContent = `${translations.invites.join_existing_active_games} ${newCount}`; } function doWeHave(): boolean { return weHaveInvite; } /** * Subscribes to the invites list. We will receive updates * for incoming and deleted invites from other players. * @param ignoreAlreadySubbed - *true* If the socket closed unexpectedly and we need to resub. subs.invites will already be true so we ignore that. */ async function subscribeToInvites(ignoreAlreadySubbed?: boolean): Promise { // Set to true when we are restarting the connection and need to resub to everything we were to before. if (!guiplay.isOpen()) return; // Don't subscribe to invites if we're not on the play page! const alreadySubbed = socketsubs.areSubbedToSub('invites'); if (!ignoreAlreadySubbed && alreadySubbed) return; // console.log("Subbing to invites!"); socketsubs.addSub('invites'); socketmessages.send('general', 'sub', 'invites'); } // Exports ----------------------------------------------------------------------- export default { gelement_iCodeCode, onmessage, update, create, cancel, clear, accept, click, doWeHave, clearIfOnPlayPage, unsubIfWeNotHave, subscribeToInvites, unsubFromInvites, }; ================================================ FILE: src/client/scripts/esm/game/misc/keybinds.ts ================================================ // src/client/scripts/esm/game/misc/keybinds.ts /** * This script will store the keybinds for various game actions. * * Currently we only store keybinds that actually CHANGE. * But in the future we can expand this with perhaps an option menu. */ import perspective from '../rendering/perspective.js'; import preferences from '../../components/header/preferences.js'; import etoolmanager from '../boardeditor/tools/etoolmanager.js'; import guinavigation from '../gui/guinavigation.js'; import { listener_document } from '../chess/game.js'; import { Mouse, MouseButton } from '../input.js'; /** Returns the mouse button currently assigned to board dragging. */ function getBoardDragMouseButton(): MouseButton | undefined { if (perspective.getEnabled()) return undefined; if (guinavigation.isAnnotationsButtonEnabled()) return Mouse.LEFT; // Allows a second pointer to pinch zoom the board even when drawing annote with first pointer. if (etoolmanager.isLeftMouseReserved()) return Mouse.RIGHT; // Default: Left mouse drags board return Mouse.LEFT; } /** Returns the mouse button currently assigned to drawing annotations. */ function getAnnotationMouseButton(): MouseButton | undefined { if (guinavigation.isAnnotationsButtonEnabled() || perspective.getEnabled()) return Mouse.RIGHT; if (etoolmanager.isLeftMouseReserved()) return undefined; // NO BUTTON draws annotations (right click reserved for dragging) // Default: Right mouse draws annotations return Mouse.RIGHT; } /** Returns the mouse button currently assigned to collapsing annotations, or cancelling premoves. */ function getCollapseMouseButton(): MouseButton | undefined { if (etoolmanager.isLeftMouseReserved()) return undefined; // Left click reserved for drawing tool // Default: Right mouse return Mouse.LEFT; } /** Returns the mouse button currently assigned to piece selection. */ function getPieceSelectionMouseButton(): MouseButton | undefined { if (etoolmanager.isLeftMouseReserved()) return undefined; // Left click reserved for drawing tool // Default: Left mouse return Mouse.LEFT; } /** * Returns true if piece dragging should currently be treated as enabled. * The Ctrl key, if held, temporarily inverts the drag preference. */ function getEffectiveDragEnabled(): boolean { const dragEnabled = preferences.getDragEnabled(); const ctrlOverride = listener_document.isKeyHeld('ControlLeft') || listener_document.isKeyHeld('ControlRight'); return ctrlOverride ? !dragEnabled : dragEnabled; } export default { getBoardDragMouseButton, getAnnotationMouseButton, getCollapseMouseButton, getPieceSelectionMouseButton, getEffectiveDragEnabled, }; ================================================ FILE: src/client/scripts/esm/game/misc/loadbalancer.ts ================================================ // src/client/scripts/esm/game/misc/loadbalancer.ts /** * This script keeps track of our deltaTime, FPS, AFK status, and hibernation status. */ import jsutil from '../../../../../shared/util/jsutil.js'; import stats from '../gui/stats.js'; import config from '../config.js'; import invites from './invites.js'; import tabnameflash from './onlinegame/tabnameflash.js'; import { listener_document, listener_overlay } from '../chess/game.js'; // Variables ------------------------------------------------------------- /** In millis since the start of the program (updated at the beginning of each frame). */ let runTime: number; /** Time in seconds since last animation frame */ let deltaTime: number; let lastFrameTime: number = 0; /** Milliseconds to average the fps over */ const fpsWindow = 1000; /** Contains an ordered array of the timestamps of all frames over the last second */ const frames: number[] = []; let fps = 0; /** Estimation of the monitor's refresh rate. */ let monitorRefreshRate = 0; let isAFK = false; /** Milliseconds of inactivity to pause title screen animation, saving cpu. */ const timeUntilAFK = { normal: 30_000, dev: 2_000 }; // Default: 30_000 let AFKTimeoutID: number | undefined; let isHibernating = false; const timeUntilHibernation = 1000 * 60 * 30; // 30 minutes // const timeUntilHibernation = 10000; // 10s for dev testing /** ID of the timer to declare we are hibernating! */ let hibernateTimeoutID: number | undefined; let windowIsVisible = true; const timeToDeleteInviteAfterPageHiddenMillis = 1000 * 60 * 30; // 30 minutes // const timeToDeleteInviteAfterPageHiddenMillis = 1000 * 10; // 10 seconds let timeToDeleteInviteTimeoutID: number | undefined; // Functions ------------------------------------------------------------- /** Millis since the start of the program. */ function getRunTime(): number { return runTime; } /** Returns the amount of seconds that have passed since last frame. */ function getDeltaTime(): number { return deltaTime; } function getTimeUntilAFK(): number { return config.DEV_BUILD ? timeUntilAFK.dev : timeUntilAFK.normal; } function areWeAFK(): boolean { return isAFK; } function areWeHibernating(): boolean { return isHibernating; } function isPageHidden(): boolean { return !windowIsVisible; } function update(runtime: number): void { // milliseconds updateDeltaTime(runtime); frames.push(runTime); trimFrames(); updateFPS(); updateMonitorRefreshRate(); updateAFK(); } function updateDeltaTime(runtime: number): void { runTime = runtime; deltaTime = (runTime - lastFrameTime) / 1000; lastFrameTime = runTime; } // Deletes frame timestamps from our list over 1 second ago function trimFrames(): void { // What time was it 1 second ago const splitPoint = runTime - fpsWindow; // Use binary search to find the split point. const indexToSplit = jsutil.findIndexOfPointInOrganizedArray(frames, splitPoint); // This will not delete a timestamp if it falls exactly on the split point. frames.splice(0, indexToSplit); } function updateFPS(): void { fps = (frames.length * 1000) / fpsWindow; stats.updateFPS(fps); } // Our highest-ever fps will be the monitor's refresh rate! function updateMonitorRefreshRate(): void { if (fps <= monitorRefreshRate) return; monitorRefreshRate = fps; } function updateAFK(): void { if (listener_overlay.atleastOneInput() || listener_document.atleastOneInput()) onReturnFromAFK(); } function onReturnFromAFK(): void { isAFK = false; isHibernating = false; restartAFKTimer(); restartHibernateTimer(); // Make sure we're subbed to invites list if we're on the play page! invites.subscribeToInvites(); } function restartAFKTimer(): void { clearTimeout(AFKTimeoutID); AFKTimeoutID = window.setTimeout(onAFK, getTimeUntilAFK()); } function restartHibernateTimer(): void { clearTimeout(hibernateTimeoutID); hibernateTimeoutID = window.setTimeout(onHibernate, timeUntilHibernation); } function onAFK(): void { isAFK = true; AFKTimeoutID = undefined; //console.log("Set AFK to true!") } function onHibernate(): void { if (invites.doWeHave()) return restartHibernateTimer(); // Don't hibernate if we have an open invite AND the page is visible! isHibernating = true; hibernateTimeoutID = undefined; // console.log("Set hibernating to true!") // Unsub from invites list invites.unsubFromInvites(); } // The 'focus' and 'blur' event listeners fire the MOST common, when you so much as click a different window on-screen, // EVEN though the game is still visible on screen, it just means it lost focus! // This fires the next most commonly, whenever // the page becomes NOT visible on the screen no more! // It's at the same time this fires when animation frames are no longer rendered. // Use this listener as a giveaway that we have disconnected! document.addEventListener('visibilitychange', function () { if (document.hidden) { windowIsVisible = false; // Unsub from invites list if we don't have an invite! // invitesweb.unsubIfWeNotHave(); // Set a timer to delete our invite after not returning to the page! // THIS ALSO UNSUBS US // timeToDeleteInviteTimeoutID = setTimeout(websocket.unsubFromInvites, timeToDeleteInviteAfterPageHiddenMillis) // This ONLY cancels our invite if we have one timeToDeleteInviteTimeoutID = window.setTimeout( invites.cancel, timeToDeleteInviteAfterPageHiddenMillis, ); } else { windowIsVisible = true; // Resub to invites list if we are on the play page and aren't already! // invitesweb.subscribeToInvites(); // Cancel the timer to delete our invite after not returning to the page cancelTimerToDeleteInviteAfterLeavingPage(); tabnameflash.cancelMoveSound(); } }); // Cancel the timer to delete our invite after not returning to the page function cancelTimerToDeleteInviteAfterLeavingPage(): void { clearTimeout(timeToDeleteInviteTimeoutID); timeToDeleteInviteTimeoutID = undefined; } // Exports -------------------------------------------------------------------- export default { getRunTime, getDeltaTime, update, areWeAFK, areWeHibernating, isPageHidden, restartAFKTimer, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/afk.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/afk.ts /** * This script keeps track of how long we have been afk in the current online game, * and if it's for too long, it informs the server that fact, * then the server starts an auto-resign timer if we don't return. * * This will also display a countdown onscreen, and sound effects, * before we are auto-resigned. * * It will also display a countdown until our opponent is auto-resigned, * if they are the one that is afk. */ import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import toast from '../../gui/toast.js'; import gameslot from '../../chess/gameslot.js'; import gamesound from '../gamesound.js'; import onlinegame from './onlinegame.js'; import pingManager from '../../../util/pingManager.js'; import socketmessages from '../../websocket/socketmessages.js'; import { listener_document, listener_overlay } from '../../chess/game.js'; // Constants ----------------------------------------------------------------------- /** The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */ const timeUntilAFKSecs: number = 40; // 40 + 20 = 1 minute /** ABORTABLE GAMES ONLY (< 2 moves played): The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */ const timeUntilAFKSecs_Abortable: number = 20; // 20 + 20 = 40 seconds /** UNTIMED GAMES ONLY: The time, in seconds, we must be AFK for us to alert the server that fact. Afterward the server will start an auto-resign timer. */ const timeUntilAFKSecs_Untimed: number = 100; // 100 + 20 = 2 minutes /** The amount of time we have, in milliseconds, from the time we alert the * server we are afk, to the time we lose if we don't return. */ const timerToLossFromAFK: number = 20000; // HAS TO MATCH SERVER-END /** The ID of the timeout that can be used to cancel the timer that will alert the server we are afk, if we are not no longer afk by then. */ let timeoutID: ReturnType | undefined; /** The timestamp we will lose from being AFK, if we are not no longer afk by that time. */ let timeWeLoseFromAFK: number | undefined; /** The timeout ID of the timer to display the next "You are AFK..." message. */ let displayAFKTimeoutID: ReturnType | undefined; /** The timeout ID of the timer to play the next staccato violin sound effect of the 10-second countdown to auto-resign from being afk. */ let playStaccatoTimeoutID: ReturnType | undefined; /** The timestamp our opponent will lose from being AFK, if they are not no longer afk by that time. */ let timeOpponentLoseFromAFK: number | undefined; /** The timeout ID of the timer to display the next "Opponent is AFK..." message. */ let displayOpponentAFKTimeoutID: ReturnType | undefined; // If we lost connection while displaying toast status messages of when our opponent will disconnect, stop doing that. document.addEventListener('connection-lost', () => { // Stop saying when the opponent will lose from being afk clearTimeout(displayOpponentAFKTimeoutID); }); function isOurAFKAutoResignTimerRunning(): boolean { // If the time we will lose from being afk is defined, the timer is running return timeWeLoseFromAFK !== undefined; } function onGameStart(): void { // Start the timer that will inform the server we are afk, the server thenafter starting an auto-resign timer. rescheduleAlertServerWeAFK(); } function onGameClose(): void { // Reset everything cancelAFKTimer(); timeoutID = undefined; timeWeLoseFromAFK = undefined; displayAFKTimeoutID = undefined; playStaccatoTimeoutID = undefined; displayOpponentAFKTimeoutID = undefined; timeOpponentLoseFromAFK = undefined; } function onMovePlayed({ isOpponents }: { isOpponents: boolean }): void { // Restart the timer that will inform the server we are afk, the server thenafter starting an auto-resign timer. rescheduleAlertServerWeAFK(); if (isOpponents) stopOpponentAFKCountdown(); // The opponent is no longer AFK if they were) } function updateAFK(): void { if (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) return; // Game is over if (!listener_overlay.atleastOneInput() && !listener_document.atleastOneInput()) return; // No input this frame, don't reset the timer to tell the server we are afk. // There has been mouse movement, restart the afk auto-resign timer. if (isOurAFKAutoResignTimerRunning()) tellServerWeBackFromAFK(); // Also tell the server we are back, IF it had started an auto-resign timer! rescheduleAlertServerWeAFK(); } /** * Restarts the timer that will inform the server we are afk, * the server thenafter starting an auto-resign timer. */ function rescheduleAlertServerWeAFK(): void { clearTimeout(timeoutID); const { basegame } = gameslot.getGamefile()!; if ( !onlinegame.isItOurTurn() || gamefileutility.isGameOver(basegame) || (onlinegame.getIsPrivate() && basegame.untimed) ) return; // Timed resignable games cannot be auto-resigned from going afk (to make tournament games more fair) if (!basegame.untimed && moveutil.isGameResignable(basegame)) return; // Games with less than 2 moves played more-quickly start the AFK auto resign timer const timeUntilAlertServerWeAFKSecs = !moveutil.isGameResignable(basegame) ? timeUntilAFKSecs_Abortable : basegame.untimed ? timeUntilAFKSecs_Untimed : timeUntilAFKSecs; timeoutID = setTimeout(tellServerWeAFK, timeUntilAlertServerWeAFKSecs * 1000); } function cancelAFKTimer(): void { clearTimeout(timeoutID); clearTimeout(displayAFKTimeoutID); clearTimeout(playStaccatoTimeoutID); clearTimeout(displayOpponentAFKTimeoutID); } function tellServerWeAFK(): void { socketmessages.send('game', 'AFK'); timeWeLoseFromAFK = Date.now() + timerToLossFromAFK; // Play lowtime alert sound gamesound.playLowtime(); // Display on screen "You are AFK. Auto-resigning in 20..." displayWeAFK(20); // The first violin staccato note is played in 10 seconds playStaccatoTimeoutID = setTimeout(playStaccatoNote, 10000, 'c3', 10); } function tellServerWeBackFromAFK(): void { socketmessages.send('game', 'AFK-Return'); timeWeLoseFromAFK = undefined; clearTimeout(displayAFKTimeoutID); clearTimeout(playStaccatoTimeoutID); displayAFKTimeoutID = undefined; playStaccatoTimeoutID = undefined; } function displayWeAFK(secsRemaining: number): void { const resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame) ? translations.onlinegame.auto_resigning_in : translations.onlinegame.auto_aborting_in; toast.show( `${translations.onlinegame.afk_warning} ${resigningOrAborting} ${secsRemaining}...`, { durationMillis: 1000 }, ); const nextSecsRemaining = secsRemaining - 1; if (nextSecsRemaining === 0) return; // Stop const timeRemainUntilAFKLoss = timeWeLoseFromAFK! - Date.now(); const timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000; displayAFKTimeoutID = setTimeout(displayWeAFK, timeToPlayNextDisplayWeAFK, nextSecsRemaining); } function playStaccatoNote(note: 'c3' | 'c4', secsRemaining: number): void { if (note === 'c3') gamesound.playViola_c3(); else if (note === 'c4') gamesound.playViolin_c4(); const nextSecsRemaining = secsRemaining > 5 ? secsRemaining - 1 : secsRemaining - 0.5; if (nextSecsRemaining === 0) return; // Stop const nextNote = nextSecsRemaining === Math.floor(nextSecsRemaining) ? 'c3' : 'c4'; const timeRemainUntilAFKLoss = timeWeLoseFromAFK! - Date.now(); const timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000; playStaccatoTimeoutID = setTimeout( playStaccatoNote, timeToPlayNextDisplayWeAFK, nextNote, nextSecsRemaining, ); } function startOpponentAFKCountdown(millisUntilAutoAFKResign: number): void { // Cancel the previous one if this is overwriting stopOpponentAFKCountdown(); // Ping is round-trip time (RTT), So divided by two to get the approximate // time that has elapsed since the server sent us the correct clock values const timeLeftMillis = millisUntilAutoAFKResign - pingManager.getHalfPing(); timeOpponentLoseFromAFK = Date.now() + timeLeftMillis; // How much time is left? Usually starts at 20 seconds const secsRemaining = Math.ceil(timeLeftMillis / 1000); displayOpponentAFK(secsRemaining); } function stopOpponentAFKCountdown(): void { clearTimeout(displayOpponentAFKTimeoutID); displayOpponentAFKTimeoutID = undefined; } function displayOpponentAFK(secsRemaining: number): void { const resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame) ? translations.onlinegame.auto_resigning_in : translations.onlinegame.auto_aborting_in; toast.show( `${translations.onlinegame.opponent_afk} ${resigningOrAborting} ${secsRemaining}...`, { durationMillis: 1000 }, ); const nextSecsRemaining = secsRemaining - 1; if (nextSecsRemaining === 0) return; // Stop const timeRemainUntilAFKLoss = timeOpponentLoseFromAFK! - Date.now(); const timeToPlayNextDisplayWeAFK = timeRemainUntilAFKLoss - nextSecsRemaining * 1000; displayOpponentAFKTimeoutID = setTimeout( displayOpponentAFK, timeToPlayNextDisplayWeAFK, nextSecsRemaining, ); } export default { onGameStart, isOurAFKAutoResignTimerRunning, onMovePlayed, updateAFK, timeUntilAFKSecs, onGameClose, startOpponentAFKCountdown, stopOpponentAFKCountdown, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/disconnect.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/disconnect.ts /** * This script displays a countdown on screen, when our opponent disconnects, * how much longer they have remaining until they are auto-resigned. * * If they disconnect not by choice (bad network), the server they are gives them a little * extra time to reconnect. */ import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import afk from './afk.js'; import toast from '../../gui/toast.js'; import gameslot from '../../chess/gameslot.js'; import pingManager from '../../../util/pingManager.js'; // Types --------------------------------------------------------------- /** The parameters for the opponent disconnect countdown. */ interface OpponentDisconnectValue { millisUntilAutoDisconnectResign: number; wasByChoice: boolean; } // Variables ----------------------------------------------------------------------- /** The timestamp our opponent will lose from disconnection, if they don't reconnect before then. */ let timeOpponentLoseFromDisconnect: number | undefined; /** The timeout ID of the timer to display the next "Opponent has disconnected..." message. */ let displayOpponentDisconnectTimeoutID: ReturnType | undefined; /** * Starts the countdown for when the opponent will be auto-resigned due to disconnection. * This will overwrite any existing "Opponent is AFK" or disconnection countdowns. * @param params - Parameters for the countdown. * @param params.millisUntilAutoDisconnectResign - The number of milliseconds remaining until the opponent is auto-resigned for disconnecting. * @param params.wasByChoice - Indicates whether the opponent disconnected intentionally (true) or unintentionally (false). */ function startOpponentDisconnectCountdown({ millisUntilAutoDisconnectResign, wasByChoice, }: OpponentDisconnectValue): void { // This overwrites the "Opponent is AFK" timer afk.stopOpponentAFKCountdown(); // Cancel the previous one if this is overwriting stopOpponentDisconnectCountdown(); const timeLeftMillis = millisUntilAutoDisconnectResign - pingManager.getHalfPing(); timeOpponentLoseFromDisconnect = Date.now() + timeLeftMillis; // How much time is left? Usually starts at 20 | 60 seconds const secsRemaining = Math.ceil(timeLeftMillis / 1000); displayOpponentDisconnect(secsRemaining, wasByChoice); } function stopOpponentDisconnectCountdown(): void { clearTimeout(displayOpponentDisconnectTimeoutID); displayOpponentDisconnectTimeoutID = undefined; } function displayOpponentDisconnect(secsRemaining: number, wasByChoice: boolean): void { const opponent_disconnectedOrLostConnection = wasByChoice ? translations.onlinegame.opponent_disconnected : translations.onlinegame.opponent_lost_connection; const resigningOrAborting = moveutil.isGameResignable(gameslot.getGamefile()!.basegame) ? translations.onlinegame.auto_resigning_in : translations.onlinegame.auto_aborting_in; // The "You are AFK" message should overwrite, be on top of, this message, // so if that is running, don't display this 1-second disconnect message, but don't cancel it either! if (!afk.isOurAFKAutoResignTimerRunning()) toast.show( `${opponent_disconnectedOrLostConnection} ${resigningOrAborting} ${secsRemaining}...`, { durationMillis: 1000 }, ); const nextSecsRemaining = secsRemaining - 1; if (nextSecsRemaining === 0) return; // Stop const timeRemainUntilDisconnectLoss = timeOpponentLoseFromDisconnect! - Date.now(); const timeToPlayNextDisplayOpponentDisconnect = timeRemainUntilDisconnectLoss - nextSecsRemaining * 1000; displayOpponentDisconnectTimeoutID = setTimeout( displayOpponentDisconnect, timeToPlayNextDisplayOpponentDisconnect, nextSecsRemaining, wasByChoice, ); } export default { startOpponentDisconnectCountdown, stopOpponentDisconnectCountdown, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/drawoffers.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/drawoffers.ts /** * This script stores the logic surrounding draw extending and acceptance * in online games, client-side. * * It also keeps track of the last ply (half-move) we extended a draw offer, * if we have done so, in the current online game. */ import type { DrawOfferInfo } from '../../../../../../shared/types.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import toast from '../../gui/toast.js'; import guipause from '../../gui/guipause.js'; import gameslot from '../../chess/gameslot.js'; import gamesound from '../gamesound.js'; import onlinegame from './onlinegame.js'; import guidrawoffer from '../../gui/guidrawoffer.js'; import socketmessages from '../../websocket/socketmessages.js'; // Variables --------------------------------------------------- /** * Minimum number of plies (half-moves) that * must span between 2 consecutive draw offers * by the same player! * * THIS MUST ALWAYS MATCH THE SERVER-SIDE!!!! */ const movesBetweenDrawOffers: number = 2; /** The last move we extended a draw, if we have, otherwise undefined. */ let plyOfLastOfferedDraw: number | undefined; /** Whether we have an open draw offer FROM OUR OPPONENT */ let isAcceptingDraw: boolean = false; // Functions --------------------------------------------------- /** * Returns true if us extending a draw offer to our opponent is legal. */ function isOfferingDrawLegal(): boolean { const gamefile = gameslot.getGamefile()!; if (!onlinegame.areInOnlineGame()) return false; // Can't offer draws in local games if (!moveutil.isGameResignable(gamefile.basegame)) return false; // Not at least 2+ moves if (onlinegame.hasServerConcludedGame()) return false; // Can't offer draws after the game has ended if (isTooSoonToOfferDraw()) return false; // It's been too soon since our last offer return true; // Is legal to EXTEND } /** * Returns true if it's been too soon since our last draw offer extension * for us to extend another one. We cannot extend them too rapidly. */ function isTooSoonToOfferDraw(): boolean { const gamefile = gameslot.getGamefile()!; if (plyOfLastOfferedDraw === undefined) return false; // We have made zero offers so far this game const movesSinceLastOffer = gamefile.basegame.moves.length - plyOfLastOfferedDraw; if (movesSinceLastOffer < movesBetweenDrawOffers) return true; return false; } /** * Returns *true* if we have an open draw offer from our OPPONENT. */ function areWeAcceptingDraw(): boolean { return isAcceptingDraw; } /** Is called when we receive a draw offer from our opponent */ function onOpponentExtendedOffer(): void { isAcceptingDraw = true; // Needs to be set FIRST, because guidrawoffer.open() relies on it. guidrawoffer.open(); gamesound.playBase(); guipause.updateDrawOfferButton(); } /** Is called when our opponent declines our draw offer */ function onOpponentDeclinedOffer(): void { toast.show(`Opponent declined draw offer.`); } /** * Extends a draw offer in our current game. * All legality checks have already passed! */ function extendOffer(): void { socketmessages.send('game', 'offerdraw'); const gamefile = gameslot.getGamefile()!; plyOfLastOfferedDraw = gamefile.basegame.moves.length; toast.show(`Waiting for opponent to accept...`); // TODO: Needs to be localized for the user's language. guipause.updateDrawOfferButton(); } /** * This fires when we click the checkmark in * the draw offer UI on the bottom navigation bar. */ function callback_AcceptDraw(): void { isAcceptingDraw = false; socketmessages.send('game', 'acceptdraw'); guidrawoffer.close(); guipause.updateDrawOfferButton(); } /** * This fires when we click the X-mark in * the draw offer UI on the bottom navigation bar, * or when we click "Accept Draw" in the pause menu! * @param [options] - Optional settings. * @param [options.informServer=true] - If true, the server will be informed that the draw offer has been declined. * We'll want to set this to false if we call this after making a move, because the server auto-declines it. */ function callback_declineDraw(): void { if (!isAcceptingDraw) return; // No open draw offer from our opponent closeDraw(); // Notify the server socketmessages.send('game', 'declinedraw'); toast.show(`Draw declined`); // TODO: This needs to be localized to the user's language } /** * Closes the current draw offer, if there is one, from our opponent. * This does NOT notify the server. */ function closeDraw(): void { if (!isAcceptingDraw) return; // No open draw offer from our opponent guidrawoffer.close(); isAcceptingDraw = false; } /** * Set the current draw offer values according to the information provided. * This is called after a page refresh when we're in a game. */ function set(drawOffer: DrawOfferInfo): void { plyOfLastOfferedDraw = drawOffer.lastOfferPly; if (!drawOffer.unconfirmed) return; // No open draw offer // Open draw offer!! onOpponentExtendedOffer(); } /** Called whenever a move is played in an online game */ function onMovePlayed({ isOpponents }: { isOpponents: boolean }): void { // Declines any open draw offer from our opponent. We don't need to inform // the server because the server knows to auto decline when we submit our move. if (!isOpponents) closeDraw(); } /** * Called when an online game concludes or is closed. Closes any open draw * offer and resets all draw for values for future games. */ function onGameClose(): void { plyOfLastOfferedDraw = undefined; isAcceptingDraw = false; guidrawoffer.close(); guipause.updateDrawOfferButton(); } export default { isOfferingDrawLegal, areWeAcceptingDraw, callback_AcceptDraw, callback_declineDraw, onOpponentExtendedOffer, onOpponentDeclinedOffer, extendOffer, set, onMovePlayed, onGameClose, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/movesendreceive.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/movesendreceive.ts /** * This script handles sending our move in online games to the server, * and receiving moves from our opponent. */ import type { Mesh } from '../../rendering/piecemodels.js'; import type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js'; import type { MoveValidationResult } from '../../../../../../shared/chess/logic/movevalidation.js'; import type { ClockValues, OpponentsMoveMessage } from '../../../../../../shared/types.js'; import clock from '../../../../../../shared/chess/logic/clock.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import movevalidation from '../../../../../../shared/chess/logic/movevalidation.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import { isGameInstantlyDeleted } from '../../../../../../shared/chess/variants/servervalidation.js'; import gameslot from '../../chess/gameslot.js'; import guiclock from '../../gui/guiclock.js'; import premoves from '../../chess/premoves.js'; import guipause from '../../gui/guipause.js'; import selection from '../../chess/selection.js'; import socketsubs from '../../websocket/socketsubs.js'; import onlinegame from './onlinegame.js'; import { GameBus } from '../../GameBus.js'; import movesequence from '../../chess/movesequence.js'; import socketmessages from '../../websocket/socketmessages.js'; // Events --------------------------------------------------------------------- GameBus.addEventListener('user-move-played', () => { sendMove(); }); // Functions ------------------------------------------------------------------- /** * Called when selection.js moves a piece. This will send it to the server * if we're in an online game. */ function sendMove(): void { if ( !onlinegame.areInOnlineGame() || !onlinegame.areInSync() || !socketsubs.areSubbedToSub('game') ) return; // Skip // console.log("Sending our move.."); const gamefile = gameslot.getGamefile()!; const lastMove = moveutil.getLastMove(gamefile.boardsim.moves)!; const moveToken = lastMove.token; // "x,y>x,yN" const data = { move: moveToken, moveNumber: gamefile.basegame.moves.length, gameConclusion: gamefile.basegame.gameConclusion, }; socketmessages.send('game', 'submitmove', data, true); onlinegame.onMovePlayed({ isOpponents: false }); } /** * Called when we received our opponents move. This verifies they're move * and claimed game conclusion is legal. If it isn't, it reports them and doesn't forward their move. * If it is legal, it forwards the game to the front, then forwards their move. */ function handleOpponentsMove( gamefile: FullGame, mesh: Mesh | undefined, message: OpponentsMoveMessage, ): void { // Make sure the move number matches the expected. const expectedMoveNumber = gamefile.boardsim.moves.length + 1; if (message.moveNumber !== expectedMoveNumber) { // A desync happened console.error( `We have desynced from the game. Resyncing. Expected opponent's move number: ${expectedMoveNumber}. Actual: ${message.moveNumber}. Opponent's move: ${JSON.stringify(message.move)}. Move number: ${message.moveNumber}`, ); return onlinegame.resyncToGame(); } // Convert the move from compact short format "x,y>x,y=N" to JSON. // Gauranteed by the server to be parsable. const moveTagged: MoveTagged = icnconverter.parseTokenMove(message.move.token); premoves.performWithUnapplied(gamefile, mesh, () => { // If not legal, this will be a string for why it is illegal. // THIS ATTACHES ANY SPECIAL TAGS TO THE MOVE const moveValidationResult = movevalidation.isOpponentsMoveLegal( gamefile, moveTagged, message.gameConclusion, ); // Only report cheating when the server won't delete the game instantly. if ( checkAndReportIllegalOpponentMove( gamefile, moveValidationResult, message.move.token, message.moveNumber, ) ) { return false; // Don't physically play next premove } // At this stage, the move is legal, or allowed anyway in a private game. Apply it. // Go to latest move before making a new move movesequence.viewFront(gamefile, mesh); movesequence.makeMoveAndAnimate(gamefile, mesh, moveTagged); // Edit the clocks const { basegame } = gamefile; // Adjust the timer whos turn it is depending on ping. applyClockValues(gamefile, message.clockValues); // For online games, the server is boss, so if they say the game is over, conclude it here. if (gamefileutility.isGameOver(basegame)) gameslot.concludeGame(); onlinegame.onMovePlayed({ isOpponents: true }); guipause.onReceiveOpponentsMove(); // Update the pause screen buttons return true; // Good to physically play next premove }); selection.reselectPiece(); // Reselect the currently selected piece. Recalc its moves and recolor it if needed. } /** * Logs an illegal opponent move and reports it to the server if the game warrants it. * @param moveValidationResult - The result of move validation (may be valid or invalid). * @param tokenMove - The move in compact string format, used for logging. * @param moveNumber - The move number, used for logging. * @returns Whether the move was illegal and was reported. */ function checkAndReportIllegalOpponentMove( gamefile: FullGame, moveValidationResult: MoveValidationResult, tokenMove: string, moveNumber: number, ): boolean { if (moveValidationResult.valid) return false; console.log( `Buddy made an illegal play: "${tokenMove}". Reason: ${moveValidationResult.reason} Move number: ${moveNumber}`, ); if ( !isGameInstantlyDeleted( gamefile.boardsim.variant, gamefile.basegame.dateTimestamp, onlinegame.getIsPrivate(), ) ) { onlinegame.reportOpponentsMove(moveValidationResult.reason); return true; } return false; // Private or server-validated game — allow through without reporting } /** Adjusts received clock values for ping and applies them to the game, if provided. */ function applyClockValues(gamefile: FullGame, clockValues: ClockValues | undefined): void { if (!clockValues) return; if (gamefile.basegame.untimed) { console.warn('Received clock values for untimed game??'); return; } clockValues = onlinegame.adjustClockValuesForPing(clockValues); clock.edit(gamefile.basegame.clocks, clockValues); guiclock.edit(gamefile.basegame); } // Exports ------------------------------------------------------------------- export default { sendMove, handleOpponentsMove, checkAndReportIllegalOpponentMove, applyClockValues, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/onlinegame.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/onlinegame.ts /** * This module keeps trap of the data of the onlinegame we are currently in. */ import type { ServerGameInfo } from '../../websocket/socketschemas.js'; import type { Player, PlayerGroup } from '../../../../../../shared/chess/util/typeutil.js'; import type { ClockValues, ParticipantState, Rating } from '../../../../../../shared/types.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import { isGameInstantlyDeleted } from '../../../../../../shared/chess/variants/servervalidation.js'; import afk from './afk.js'; import gameslot from '../../chess/gameslot.js'; import IndexedDB from '../../../util/IndexedDB.js'; import socketsubs from '../../websocket/socketsubs.js'; import disconnect from './disconnect.js'; import drawoffers from './drawoffers.js'; import pingManager from '../../../util/pingManager.js'; import { GameBus } from '../../GameBus.js'; import tabnameflash from './tabnameflash.js'; import socketmessages from '../../websocket/socketmessages.js'; // Variables ------------------------------------------------------------------------------------------------------ /** Whether or not we are currently in an online game. */ let inOnlineGame: boolean = false; /** The id of the online game we are in, if we are in one. */ let id: number | undefined; /** * Whether the game is a private one (joined from an invite code). */ let isPrivate: boolean | undefined; /** * Whether the game is rated. */ let rated: boolean | undefined; /** * The color we are in the online game, if we are in it. */ let ourColor: Player | undefined; /** * The ratings of the non-guest players in the game. * If the variant doesn't have a leaderboard, we fall back to the INFINITY leaderboard. */ let playerRatings: PlayerGroup | undefined; /** * Different from gamefile.basegame.gameConclusion, because this is only true if {@link gamefileutility.concludeGame} * has been called, which IS ONLY called once the SERVER tells us the result of the game, not us! */ let serverHasConcludedGame: boolean | undefined; /** * Different from gamefile.basegame.gameConclusion, because this is true if the player has pressed the "Resign/Abort" button at some time during this game, * and NOT if the SERVER tells us that the game is concluded. */ let playerHasPressedAbortOrResignButton: boolean | undefined; /** * Whether we are in sync with the game on the server. * If false, we do not submit our move. (move will be auto-submitted upon resyncing) * Set to false whenever we lose connection, or the socket closes. * Set to true whenever we join game, or successfully resync. * * If we aren't subbed to a game, then it's automatically assumed we are out of sync. */ let inSync: boolean | undefined; // Events ------------------------------------------------------------------------------------------------------ GameBus.addEventListener('game-concluded', () => { if (!inOnlineGame) return; // The game concluded wasn't an online game. serverHasConcludedGame = true; // This NEEDS to be above drawoffers.onGameClose(), as that relies on this! afk.onGameClose(); tabnameflash.onGameClose(); deleteCustomVariantOptions(); drawoffers.onGameClose(); requestRemovalFromPlayersInActiveGames(); }); // Getters -------------------------------------------------------------------------------------------------------------- function areInOnlineGame(): boolean { return inOnlineGame; } /** Returns the game id of the online game we're in. */ function getGameID(): number { if (!inOnlineGame) throw Error("Cannot get id of online game when we're not in an online game."); return id!; } function getIsPrivate(): boolean { if (!inOnlineGame) throw Error("Cannot get isPrivate of online game when we're not in an online game."); return isPrivate!; } function isRated(): boolean { if (!inOnlineGame) throw Error("Cannot ask if online game is rated when we're not in one."); return rated!; } /** Returns whether we are one of the players in the online game. */ function doWeHaveRole(): boolean { if (!inOnlineGame) throw Error( "Cannot ask if we have a role in online game when we're not in an online game.", ); return ourColor !== undefined; } function getOurColor(): Player | undefined { if (!inOnlineGame) throw Error("Cannot get color we are in online game when we're not in an online game."); return ourColor; } function getPlayerRatings(): PlayerGroup | undefined { if (!inOnlineGame) throw Error("Cannot get player ratings when we're not in an online game."); return playerRatings; } function areWeColorInOnlineGame(color: Player): boolean { if (!inOnlineGame) return false; // Can't be that color, because we aren't even in a game. return ourColor === color; } function isItOurTurn(): boolean { if (!inOnlineGame) throw Error("Cannot get isItOurTurn of online game when we're not in an online game."); return gameslot.getGamefile()!.basegame.whosTurn === ourColor; } /** Whether we have pressed the Abort/Resign game button this game. NOT when it says main menu. */ function hasPlayerPressedAbortOrResignButton(): boolean { if (!inOnlineGame) throw Error( "Cannot get playerHasPressedAbortOrResignButton of online game when we're not in an online game.", ); return playerHasPressedAbortOrResignButton!; } function areInSync(): boolean { if (!inOnlineGame) throw Error("Cannot get inSync of online game when we're not in an online game."); return inSync!; } /** * Different from {@link gamefileutility.isGameOver}, because this only returns true if {@link gamefileutility.concludeGame} * has been called, which IS ONLY called once the SERVER tells us the result of the game, not us! */ function hasServerConcludedGame(): boolean { if (!inOnlineGame) throw Error( "Cannot get serverHasConcludedGame of online game when we're not in an online game.", ); return serverHasConcludedGame!; } function setInSyncTrue(): void { inSync = true; } function setInSyncFalse(): void { if (!inOnlineGame) return; inSync = false; } // Functions ------------------------------------------------------------------------------------------------------ function initOnlineGame(options: { gameInfo: ServerGameInfo; /** Specify if we are a participant in the game, not a spectator. */ youAreColor?: Player; /** Only provide if we're a participant of an ongoing game, not a spectator, or when the game is over! */ participantState?: ParticipantState; }): void { inOnlineGame = true; inSync = true; // Set static game properties that never change id = options.gameInfo.id; rated = options.gameInfo.rated; isPrivate = options.gameInfo.publicity === 'private'; playerRatings = options.gameInfo.playerRatings; ourColor = options.youAreColor; // If we are a participator, set the draw offers, disconnect timer, afk auto resign timer. set_DrawOffers_DisconnectInfo_AutoAFKResign(options.participantState); afk.onGameStart(); tabnameflash.onGameStart({ isOurMove: isItOurTurn() }); serverHasConcludedGame = false; playerHasPressedAbortOrResignButton = false; initEventListeners(); } function set_DrawOffers_DisconnectInfo_AutoAFKResign(participantState?: ParticipantState): void { if (participantState) { drawoffers.set(participantState.drawOffer); // If opponent is currently disconnected, display that countdown if (participantState.disconnect) disconnect.startOpponentDisconnectCountdown(participantState.disconnect); else disconnect.stopOpponentDisconnectCountdown(); // If Opponent is currently afk, display that countdown if (participantState.millisUntilAutoAFKResign !== undefined) afk.startOpponentAFKCountdown(participantState.millisUntilAutoAFKResign); else afk.stopOpponentAFKCountdown(); } } // Call when we leave an online game function closeOnlineGame(): void { inOnlineGame = false; id = undefined; isPrivate = undefined; rated = undefined; ourColor = undefined; inSync = undefined; serverHasConcludedGame = undefined; playerHasPressedAbortOrResignButton = undefined; afk.onGameClose(); disconnect.stopOpponentDisconnectCountdown(); tabnameflash.onGameClose(); drawoffers.onGameClose(); closeEventListeners(); } function initEventListeners(): void { // Add the event listeners for when we lose connection or the socket closes, // to set our inSync variable to false document.addEventListener('connection-lost', setInSyncFalse); // Custom event document.addEventListener('socket-closed', setInSyncFalse); // Custom event /** * Leave-game warning popups on every hyperlink. * * Add an listener for every single hyperlink on the page that will * confirm to us if we actually want to leave if we are in an online game. */ document.querySelectorAll('a').forEach((link) => { link.addEventListener('click', confirmNavigationAwayFromGame); }); } function closeEventListeners(): void { document.removeEventListener('connection-lost', setInSyncFalse); document.removeEventListener('socket-closed', setInSyncFalse); document.querySelectorAll('a').forEach((link) => { link.removeEventListener('click', confirmNavigationAwayFromGame); }); } /** * Confirm that the user DOES actually want to leave the page if they are in an online game. * * Sometimes they could leave by accident, or even hit the "Logout" button by accident, * which just ejects them out of the game * @param event */ function confirmNavigationAwayFromGame(event: MouseEvent): void { // Check if Command (Meta) or Ctrl key is held down if (event.metaKey || event.ctrlKey) return; // Allow opening in a new tab without confirmation if (gamefileutility.isGameOver(gameslot.getGamefile()!.basegame)) return; const userConfirmed = confirm('Are you sure you want to leave the game?'); if (userConfirmed) return; // Follow link like normal. Server then starts a 20-second auto-resign timer for disconnecting on purpose. // Cancel the following of the link. event.preventDefault(); /* * KEEP IN MIND that if we leave the pop-up open for 10 seconds, * JavaScript is frozen in that timeframe, which means as * far as the server can tell we're not communicating anymore, * so it automatically closes our websocket connection, * thinking we've disconnected, and starts a 60-second auto-resign timer. * * As soon as we hit cancel, we are communicating again. */ } function update(): void { afk.updateAFK(); } /** * Requests a game update from the server, since we are out of sync. */ function resyncToGame(): void { if (!inOnlineGame) throw Error("Don't call resyncToGame() if not in an online game."); inSync = false; socketmessages.send('game', 'resync', id!); } function onMovePlayed({ isOpponents }: { isOpponents: boolean }): void { // Inform all the scripts that rely on online game // logic that a move occurred, so they can update accordingly afk.onMovePlayed({ isOpponents }); tabnameflash.onMovePlayed({ isOpponents }); drawoffers.onMovePlayed({ isOpponents }); } function reportOpponentsMove(reason: string): void { // Send the move number of the opponents move so that there's no mixup of which move we claim is illegal. const opponentsMoveNumber = gameslot.getGamefile()!.basegame.moves.length + 1; const message = { reason, opponentsMoveNumber, }; socketmessages.send('game', 'report', message); } /** Called when the player presses the "Abort / Resign" button for the first time in an onlinegame. */ function onAbortOrResignButtonPress(): void { if (!inOnlineGame) return; if (serverHasConcludedGame) return; // Don't need to abort/resign, game is already over if (playerHasPressedAbortOrResignButton) return; // Don't need to abort/resign, we have already done this during this game playerHasPressedAbortOrResignButton = true; const gamefile = gameslot.getGamefile()!; if (moveutil.isGameResignable(gamefile.basegame)) socketmessages.send('game', 'resign'); else socketmessages.send('game', 'abort'); } /** * Called when the player presses the "Main Menu" button in an onlinegame * This can happen if the game is already over or if the player has already pressed the "Abort / Resign" button. * This requests the server to stop serving us game updates, and allow us to join a new game. */ function onMainMenuButtonPress(): void { // MUST BE BEFORE UNSUBBING, since the code will skip // sending this message if we are not subbed. // This allows us to join a new game. // Basically tells the server we don't want to see the game conclusion. requestRemovalFromPlayersInActiveGames(); // Tell the server we no longer want game updates. socketsubs.unsubFromSub('game'); } function deleteCustomVariantOptions(): void { // Delete any custom pasted position in a private game. if (isPrivate) { const storageKey = getKeyForOnlineGameVariantOptions(id!); IndexedDB.deleteItem(storageKey); } } /** * Lets the server know we have seen the game conclusion, and would * like to be allowed to join a new game if we leave quickly. * * THIS SHOULD ALSO be the point when the server knows we agree * with the resulting game conclusion (no cheating detected), * and the server may change the players elos! */ function requestRemovalFromPlayersInActiveGames(): void { if (!areInOnlineGame()) return; if (!socketsubs.areSubbedToSub('game')) { // THE SERVER has deleted the game. Already removed from players in active games list! // console.log("Not sending request to remove from players in active games, because we are not subbed to the game."); return; } // Don't send this request if the server will have deleted this game instantly. const { basegame, boardsim } = gameslot.getGamefile()!; if (isGameInstantlyDeleted(boardsim.variant, basegame.dateTimestamp, isPrivate!)) return; socketmessages.send('game', 'removefromplayersinactivegames'); } /** * Modifies the clock values to account for ping. */ function adjustClockValuesForPing(clockValues: ClockValues): ClockValues { if (!clockValues.colorTicking) return clockValues; // No clock is ticking (< 2 moves, or game is over), don't adjust for ping // console.log(`Adjusting clock values for ping. Ping is ${pingManager.getPing()}.`); // Ping is round-trip time (RTT), So divided by two to get the approximate // time that has elapsed since the server sent us the correct clock values const halfPing = pingManager.getHalfPing(); if (halfPing > 2500) console.error( 'Ping is above 5000 milliseconds!!! This is a lot to adjust the clock values!', ); // console.log(`Ping is ${halfPing * 2}. Subtracted ${halfPing} millis from ${clockValues.colorTicking}'s clock.`); if (clockValues.clocks[clockValues.colorTicking] === undefined) throw Error( `Invalid color "${clockValues.colorTicking}" to modify clock value to account for ping.`, ); clockValues.clocks[clockValues.colorTicking]! -= halfPing; // Flag what time the player who's clock is ticking will lose on time. // Do this because while while the gamefile is being constructed, the time left may become innacurate. clockValues.timeColorTickingLosesAt = Date.now() + clockValues.clocks[clockValues.colorTicking]!; return clockValues; } /** * Returns the key that's put in local storage to store the variant options * of the current online game, if we have pasted a position in a private match. */ function getKeyForOnlineGameVariantOptions(gameID: number): string { return `online-game-variant-options${gameID}`; } // Exports ------------------------------------------------------------------------- export default { onmessage, getGameID, getIsPrivate, isRated, doWeHaveRole, getOurColor, getPlayerRatings, setInSyncTrue, initOnlineGame, set_DrawOffers_DisconnectInfo_AutoAFKResign, closeOnlineGame, isItOurTurn, hasPlayerPressedAbortOrResignButton, areInSync, resyncToGame, update, onAbortOrResignButtonPress, onMainMenuButtonPress, hasServerConcludedGame, reportOpponentsMove, onMovePlayed, areInOnlineGame, areWeColorInOnlineGame, adjustClockValuesForPing, getKeyForOnlineGameVariantOptions, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/onlinegamerouter.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/onlinegamerouter.ts import type { Game } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Condition } from '../../../../../../shared/chess/util/winconutil.js'; import type { PlayerGroup } from '../../../../../../shared/chess/util/typeutil.js'; import type { GamesRecord } from '../../../../../../server/database/gamesManager.js'; import type { LongFormatOut } from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import type { GameMessage, JoinGameMessage } from '../../websocket/socketschemas.js'; import type { ClockValues, MovePacket, Rating } from '../../../../../../shared/types.js'; import uuid from '../../../../../../shared/util/uuid.js'; import clock from '../../../../../../shared/chess/logic/clock.js'; import icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import { players as p, Player } from '../../../../../../shared/chess/util/typeutil.js'; import afk from './afk.js'; import toast from '../../gui/toast.js'; import board from '../../rendering/boardtiles.js'; import guiplay from '../../gui/guiplay.js'; import resyncer from './resyncer.js'; import gameslot from '../../chess/gameslot.js'; import guititle from '../../gui/guititle.js'; import guiclock from '../../gui/guiclock.js'; import selection from '../../chess/selection.js'; import disconnect from './disconnect.js'; import drawoffers from './drawoffers.js'; import gameloader from '../../chess/gameloader.js'; import onlinegame from './onlinegame.js'; import socketsubs from '../../websocket/socketsubs.js'; import guigameinfo from '../../gui/guigameinfo.js'; import validatorama from '../../../util/validatorama.js'; import movesendreceive from './movesendreceive.js'; import clientmetadatautil from '../../chess/clientmetadatautil.js'; // Types ------------------------------------------------------------------------------------------------- /** The game info of an ended game from the database, as sent by the server. */ type LoggedGameInfo = Required< Pick >; // Routers -------------------------------------------------------------------------------------- /** * Routes a server websocket message with subscription marked `game`. * This handles all messages related to the active game we're in. * @param contents - The contents of the incoming server websocket message */ function routeMessage(contents: GameMessage): void { // console.log(`Received ${contents.action} from server! Message contents:`) // console.log(contents.value) // These actions are listened to, even when we're not in a game. if (contents.action === 'joingame') return handleJoinGame(contents.value); else if (contents.action === 'logged-game-info') return handleLoggedGameInfo(contents.value); // All other actions should be ignored if we're not in a game... if (!onlinegame.areInOnlineGame()) { console.log( `Received server 'game' message when we're not in an online game. Ignoring. Message: ${JSON.stringify(contents)}`, ); return; } const gamefile = gameslot.getGamefile()!; const mesh = gameslot.getMesh(); switch (contents.action) { case 'move': movesendreceive.handleOpponentsMove(gamefile, mesh, contents.value); break; case 'clock': handleUpdatedClock(gamefile.basegame, contents.value); break; case 'gameupdate': resyncer.handleServerGameUpdate(gamefile, mesh, contents.value); break; case 'gameratingchange': guigameinfo.addRatingChangeToExistingUsernameContainers(contents.value); break; case 'unsub': handleUnsubbing(); break; case 'login': handleLogin(gamefile.basegame); break; case 'nogame': handleNoGame(gamefile.basegame); break; case 'leavegame': handleLeaveGame(); break; case 'opponentafk': afk.startOpponentAFKCountdown(contents.value.millisUntilAutoAFKResign); break; case 'opponentafkreturn': afk.stopOpponentAFKCountdown(); break; case 'opponentdisconnect': disconnect.startOpponentDisconnectCountdown(contents.value); break; case 'opponentdisconnectreturn': disconnect.stopOpponentDisconnectCountdown(); break; case 'drawoffer': drawoffers.onOpponentExtendedOffer(); break; case 'declinedraw': drawoffers.onOpponentDeclinedOffer(); break; default: toast.show( // @ts-ignore `Unknown action "${contents.action}" received from server in 'game' route.`, { error: true }, ); break; } } /** * Joins a game when the server tells us we are now in one. * * This happens when we click an invite, or our invite is accepted. * * This type of message contains the MOST information about the game. * Less then "gameupdate"s, or resyncing. */ function handleJoinGame(message: JoinGameMessage): void { // We were auto-unsubbed from the invites list, BUT we want to keep open the socket!! socketsubs.deleteSub('invites'); socketsubs.addSub('game'); guititle.close(); guiplay.close(); // If the clock values are present, adjust them for ping. if (message.clockValues) message.clockValues = onlinegame.adjustClockValuesForPing(message.clockValues); gameloader.startOnlineGame(message); } /** * Called when the server sends us the game info of an ENDED game inside the database. * This loads it, even if we didn't participate in the game, and immediately concludes it. * @param message - The message from the server containing the game info. */ function handleLoggedGameInfo(message: LoggedGameInfo): void { let parsedGame: LongFormatOut; try { parsedGame = icnconverter.ShortToLong_Format(message.icn); } catch (e) { // Hmm, this isn't good. Why is a server-sent ICN crashing? console.error(e); toast.show( 'There was an error processing the game ICN sent from the server. This is a bug, please report!', { error: true }, ); return; } // Unload the currently loaded game, if we are in one if (gameloader.areInAGame()) { gameloader.unloadGame(); socketsubs.deleteSub('game'); // The server will have already unsubscribed us from the previous game. } // Else perhaps we need to close the title screen?? Or the loading screen?? // Are we one of the players (automatically no, if there's only guests) const ourUserId: number | undefined = validatorama.getOurUserId(); const whiteId: number | undefined = parsedGame.metadata.WhiteID ? uuid.base62ToBase10(parsedGame.metadata.WhiteID) : undefined; const blackId: number | undefined = parsedGame.metadata.BlackID ? uuid.base62ToBase10(parsedGame.metadata.BlackID) : undefined; // prettier-ignore const ourRole: Player | undefined = ourUserId !== undefined ? (ourUserId === whiteId ? p.WHITE : ourUserId === blackId ? p.BLACK : undefined) : undefined; // The clock values are already ingrained into the moves! // prettier-ignore const moves: MovePacket[] = parsedGame.moves ? parsedGame.moves.map(m => { const move: { token: string, clockStamp?: number } = { token: m.token }; if (m.clockStamp !== undefined) move.clockStamp = m.clockStamp; return move; }) : []; // Display elo ratings, if any. const playerRatings: PlayerGroup = {}; if (parsedGame.metadata.WhiteElo) playerRatings[p.WHITE] = clientmetadatautil.getRatingFromWhiteBlackElo( parsedGame.metadata.WhiteElo, ); if (parsedGame.metadata.BlackElo) playerRatings[p.BLACK] = clientmetadatautil.getRatingFromWhiteBlackElo( parsedGame.metadata.BlackElo, ); // Load the game. gameloader.startOnlineGame({ gameInfo: { id: message.game_id, rated: Boolean(message.rated), publicity: message.private ? 'private' : 'public', playerRatings, }, metadata: parsedGame.metadata, gameConclusion: clientmetadatautil.getGameConclusionFromResultAndTermination( parsedGame.metadata.Result!, message.termination as Condition, ), moves, youAreColor: ourRole, }); } /** * Called when we received the updated clock values from the server after submitting our move. */ function handleUpdatedClock(basegame: Game, clockValues: ClockValues): void { if (basegame.untimed) throw Error('Received clock values for untimed game??'); // Adjust the timer whos turn it is depending on ping. clockValues = onlinegame.adjustClockValuesForPing(clockValues); clock.edit(basegame.clocks, clockValues); // Edit the clocks guiclock.edit(basegame); } /** * Called after the server deletes the game after it has ended. * It basically tells us the server will no longer be sending updates related to the game, * so we should just unsub. * * Called when the server informs us they have unsubbed us from receiving updates from the game. * At this point we should leave the game. */ function handleUnsubbing(): void { socketsubs.deleteSub('game'); } /** * The server has unsubscribed us from receiving updates from the game * and from submitting actions as ourselves, * due to the reason we are no longer logged in. */ function handleLogin(basegame: Game): void { toast.show(translations.onlinegame.not_logged_in, { error: true, durationMultiplier: 100 }); socketsubs.deleteSub('game'); clock.endGame(basegame); guiclock.stopClocks(basegame); selection.unselectPiece(); board.darkenColor(); } /** * The server has reported the game no longer exists, * there will be nore more updates for it. * * Visually, abort the game. * * This can happen when either: * * Your page tries to resync to the game after it's long over. * * The server restarts mid-game. */ function handleNoGame(basegame: Game): void { toast.show(translations.onlinegame.game_no_longer_exists, { durationMultiplier: 1.5 }); socketsubs.deleteSub('game'); gamefileutility.setConclusion(basegame, { condition: 'aborted' }); gameslot.concludeGame(); } /** * You have connected to the same game from another window/device. * Leave the game on this page. * * This allows you to return to the invite creation screen, * but you won't be allowed to create an invite if you're still in a game. * However you can start a local game. */ function handleLeaveGame(): void { toast.show(translations.onlinegame.another_window_connected); socketsubs.deleteSub('game'); gameloader.unloadGame(); guititle.open(); } export default { routeMessage, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/resyncer.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/resyncer.ts /** * This script handles game updates and recyning an online game, * when for one reason or another we become out of sync. * * Game updates also count as resyncs, because that's what the server * sends anyway when we request a resync. * * This could be because we sent a move at the exact same time * the opponent resigned, * or it could be because the socket closed... */ import type { Mesh } from '../../rendering/piecemodels.js'; import type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { GameConclusion } from '../../../../../../shared/chess/util/winconutil.js'; import type { MoveRecord, MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js'; import type { GameUpdateMessage, MovePacket } from '../../../../../../shared/types.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import icnconverter from '../../../../../../shared/chess/logic/icn/icnconverter.js'; import movevalidation from '../../../../../../shared/chess/logic/movevalidation.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import gameslot from '../../chess/gameslot.js'; import premoves from '../../chess/premoves.js'; import guipause from '../../gui/guipause.js'; import selection from '../../chess/selection.js'; import onlinegame from './onlinegame.js'; import movesequence from '../../chess/movesequence.js'; import movesendreceive from './movesendreceive.js'; // Functions ----------------------------------------------------------------------------- /** * Called when the server sends us the conclusion of the game when it ends, * OR we just need to resync! The game may not always be over. */ function handleServerGameUpdate( gamefile: FullGame, mesh: Mesh | undefined, message: GameUpdateMessage, ): void { const claimedGameConclusion = message.gameConclusion; // This needs to be BEFORE synchronizeMovesList(), otherwise it won't resend our move since it thinks we're not in sync onlinegame.setInSyncTrue(); /** * Make sure we are in sync with the final move list. * We need to do this because sometimes the game can end before the * server sees our move, but on our screen we have still played it. */ const result = synchronizeMovesList( gamefile, mesh, message.moves, claimedGameConclusion, message.forceSync, ); // { opponentPlayedIllegalMove } if (result.opponentPlayedIllegalMove) return; onlinegame.set_DrawOffers_DisconnectInfo_AutoAFKResign(message.participantState); // Must be set before editing the clocks. gamefileutility.setConclusion(gamefile.basegame, claimedGameConclusion); // Adjust the timer whos turn it is depending on ping. movesendreceive.applyClockValues(gamefile, message.clockValues); // For online games, the server is boss, so if they say the game is over, conclude it here. if (gamefileutility.isGameOver(gamefile.basegame)) gameslot.concludeGame(); } /** * Adds or deletes moves in the game until it matches the server's provided moves. * This can rarely happen when we move after the game is already over, * or if we're disconnected when our opponent made their move. * THIS CAN EVEN BE CALLED when our moves match the server's! * @param gamefile - The gamefile * @param moves - The moves list in the most compact form: `['1,2>3,4','5,6>7,8Q']` * @param claimedGameConclusion - The supposed game conclusion after synchronizing our opponents move * @param forceSync - If true, skip the early-exit re-submit path and force our move list to exactly match the server's * @returns A result object containg the property `opponentPlayedIllegalMove`. If that's true, we'll report it to the server. */ function synchronizeMovesList( gamefile: FullGame, mesh: Mesh | undefined, moves: MovePacket[], claimedGameConclusion: GameConclusion | undefined, forceSync: boolean, ): { opponentPlayedIllegalMove: boolean } { const { boardsim } = gamefile; // console.log("Resyncing..."); // Early exit case. If we have played exactly 1 more move than the server, // and the rest of the moves list matches, don't modify our moves, // just re-submit our move! // Skip this if forceSync is set — the server wants us to match its state exactly // (e.g. it rejected our last move as illegal). const hasOneMoreMoveThanServer = boardsim.moves.length === moves.length + 1; const finalMoveIsOurMove = boardsim.moves.length > 0 && moveutil.getColorThatPlayedMoveIndex(gamefile.basegame, boardsim.moves.length - 1) === onlinegame.getOurColor(); const previousMove = boardsim.moves.length > 1 ? boardsim.moves[boardsim.moves.length - 2] : undefined; const previousMoveMatches = (moves.length === 0 && boardsim.moves.length === 1) || (boardsim.moves.length > 1 && moves.length > 0 && previousMove!.token === moves[moves.length - 1]!.token); if ( !forceSync && !claimedGameConclusion && hasOneMoreMoveThanServer && finalMoveIsOurMove && previousMoveMatches ) { console.log('Sending our move again after resyncing..'); movesendreceive.sendMove(); return { opponentPlayedIllegalMove: false }; } const originalMoveIndex = boardsim.state.local.moveIndex; movesequence.viewFront(gamefile, mesh); let aChangeWasMade = false; /** The index of the lastest move in the game we agree with the server on. -1 = starting position. */ const latestMatchingMoveIndex = findLastestMatchingMoveIndex(boardsim.moves, moves); // Rewind moves until we reach the first move we agree with the server on. // Catches our move if we moved RIGHT after the game ended but we haven't seen the conclusion. for (let i = boardsim.moves.length - 1; i > latestMatchingMoveIndex; i--) { console.log(`Rewinding move index ${i} while resyncing to online game.`); movesequence.rewindMove(gamefile, mesh); aChangeWasMade = true; } let opponentPlayedIllegalMove: boolean = false; /** Whether or not we forwarded at least one of OUR OWN moves the server had that we didn't. */ let atleastOneOfOurMovesWasForwarded: boolean = false; // Forward moves until we perfectly match the server's moves list. premoves.performWithUnapplied(gamefile, mesh, () => { const ourColor = onlinegame.getOurColor(); for (let i = latestMatchingMoveIndex + 1; i < moves.length; i++) { // Incrementally add the server's correct moves to our own moves list const isLastMove = i === moves.length - 1; const playerOfMove = moveutil.getColorThatPlayedMoveIndex(gamefile.basegame, i); const isOpponentMove = playerOfMove !== ourColor; const thisShortmove = moves[i]!; // '1,2>3,4=Q' The shortmove from the server's move list to add // Convert the move from compact short format "x,y>x,y=N" to JSON. // Gauranteed by the server to be parsable. const moveTagged: MoveTagged = icnconverter.parseTokenMove(thisShortmove.token); if (isOpponentMove) { // Perform legality checks // THIS ATTACHES ANY SPECIAL TAGS TO THE MOVE const moveValidationResult = movevalidation.isOpponentsMoveLegal( gamefile, moveTagged, claimedGameConclusion, ); // Only report cheating in games where the server won't delete the game instantly when it ends if ( movesendreceive.checkAndReportIllegalOpponentMove( gamefile, moveValidationResult, thisShortmove.token, i + 1, ) ) { opponentPlayedIllegalMove = true; return false; // Don't physically play next premove } } else { atleastOneOfOurMovesWasForwarded = true; } movesequence.makeMoveAndAnimate(gamefile, mesh, moveTagged, { doGameOverChecks: isLastMove, }); // Automatically cancels animations of forwarded moves in previous loops onlinegame.onMovePlayed({ isOpponents: isOpponentMove }); if (isOpponentMove) guipause.onReceiveOpponentsMove(); // Update the pause screen buttons console.log('Forwarded one move while resyncing to online game.'); aChangeWasMade = true; } // Whether we're good to physically play the next premove depends on whether it is our turn or not, // AND whether we forwarded at least one of our own moves that the server had that we didn't. if (!atleastOneOfOurMovesWasForwarded && ourColor === gamefile.basegame.whosTurn) { return true; // Good to physically play next premove } else { return false; // Don't physically play next premove } }); // If we happened to forward one of our own moves forwarded (not sure when our state // would be so behind to inherit this), then also cancel all premoves we had. if (atleastOneOfOurMovesWasForwarded) premoves.cancelPremoves(gamefile, mesh); if (opponentPlayedIllegalMove) return { opponentPlayedIllegalMove: true }; if (!aChangeWasMade) movesequence.viewIndex(gamefile, mesh, originalMoveIndex); else selection.reselectPiece(); // Reselect the selected piece from before we resynced. Recalc its moves and recolor it if needed. return { opponentPlayedIllegalMove: false }; // No cheating detected } /** * Finds the latest move index at which our moves and the server's moves match. Returns -1 if we only agree on the starting position. * @param ourMoves - Our moves list in compact form: `['1,2>3,4','5,6>7,8Q']` * @param serverMoves - The server's moves list in compact form: `[{ token: '1,2>3,4' }, { token: '5,6>7,8Q' }]` */ function findLastestMatchingMoveIndex(ourMoves: MoveRecord[], serverMoves: MovePacket[]): number { if (ourMoves.length === 0) return -1; // We only agree with the starting position for (let i = 0; i < ourMoves.length; i++) { if (ourMoves[i]!.token !== serverMoves[i]?.token) return i - 1; // We agree up to the previous move, but not this one } return ourMoves.length - 1; // We agree with all } // Exports ------------------------------------------------------------------- export default { handleServerGameUpdate, }; ================================================ FILE: src/client/scripts/esm/game/misc/onlinegame/tabnameflash.ts ================================================ // src/client/scripts/esm/game/misc/onlinegame/tabnameflash.ts /** * This script controls the flashing of the tab name "YOUR MOVE" * when it is your turn and your in another tab. */ import bd from '@naviary/bigdecimal'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import afk from './afk.js'; import gameslot from '../../chess/gameslot.js'; import gamesound from '../gamesound.js'; import loadbalancer from '../loadbalancer.js'; /** The original tab title. We will always revert to this after temporarily changing the name name to alert player's it's their move. */ const originalDocumentTitle: string = document.title; /** How rapidly the tab title should flash "YOUR MOVE" */ const periodicityMillis = 1500; /** The ID of the timeout that can be used to cancel the timer that flips the tab title between "YOUR MOVE" and the default title. */ let timeoutID: ReturnType | undefined; /** The ID of the timeout that can be used to cancel the timer that will play a move sound effect to help you realize it's your move. Typically about 20 seconds. */ let moveSound_timeoutID: ReturnType | undefined; function onGameStart({ isOurMove }: { isOurMove: boolean }): void { // This will already flash the tab name onMovePlayed({ isOpponents: isOurMove }); } /** Called when the online game is closed */ function onGameClose(): void { cancelFlashTabTimer(); cancelMoveSound(); } function onMovePlayed({ isOpponents }: { isOpponents: boolean }): void { if (isOpponents) { // Flash the tab name flashTabNameYOUR_MOVE(true); scheduleMoveSound_timeoutID(); } else { // our move // Stop flashing the tab name cancelFlashTabTimer(); } } /** * Toggles the document title showing "YOUR MOVE", * and sets a timer for the next toggle. * @param parity - If true, the tab name becomes "YOUR MOVE", otherwise it reverts to the original title */ function flashTabNameYOUR_MOVE(parity: boolean): void { if (!loadbalancer.isPageHidden()) { // The page is no longer hidden, restore the tab's original title, // and stop flashing "YOUR MOVE" document.title = originalDocumentTitle; return; } document.title = parity ? 'YOUR MOVE' : originalDocumentTitle; // Set a timer for the next toggle timeoutID = setTimeout(flashTabNameYOUR_MOVE, periodicityMillis, !parity); } function cancelFlashTabTimer(): void { document.title = originalDocumentTitle; clearTimeout(timeoutID); timeoutID = undefined; } function scheduleMoveSound_timeoutID(): void { if (!loadbalancer.isPageHidden()) return; // Don't schedule it if the page is already visible if (!moveutil.isGameResignable(gameslot.getGamefile()!.basegame)) return; const timeNextSoundFromNow = (afk.timeUntilAFKSecs * 1000) / 2; const ZERO = bd.fromBigInt(0n); moveSound_timeoutID = setTimeout( () => gamesound.playMove(ZERO, false, false), timeNextSoundFromNow, ); } function cancelMoveSound(): void { clearTimeout(moveSound_timeoutID); moveSound_timeoutID = undefined; } export default { onGameStart, onGameClose, onMovePlayed, cancelMoveSound, }; ================================================ FILE: src/client/scripts/esm/game/misc/space.ts ================================================ // src/client/scripts/esm/game/misc/space.ts /** * This script converts world-space coordinates to square coordinates, and vice verca. * * Where square coordinates are where the pieces are located, * world-space coordinates are where in space objects are actually rendered. * * There is also pixel space, which is the [x,y] coordinate of virtual pixels on the screen. * * Grid space: 1 unit = width of 1 square */ import type { BDCoords, Coords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import board from '../rendering/boardtiles.js'; import camera from '../rendering/camera.js'; import boardpos from '../rendering/boardpos.js'; const HALF: BigDecimal = bd.fromNumber(0.5); /** * Since the camera is fixed in place, with the board moving and scaling below it, * this depends on your position and scale. */ function convertWorldSpaceToCoords(worldCoords: DoubleCoords): BDCoords { const boardPos: BDCoords = boardpos.getBoardPos(); const boardScale: BigDecimal = boardpos.getBoardScale(); return [ convertWorldSpaceToCoords_Axis(worldCoords[0], boardScale, boardPos[0]), convertWorldSpaceToCoords_Axis(worldCoords[1], boardScale, boardPos[1]), ]; } /** Converts a single axis' coordinates from world space to squares. */ function convertWorldSpaceToCoords_Axis( worldCoords: number, boardScale: BigDecimal, boardPos: BigDecimal, ): BigDecimal { const positionBD = bd.fromNumber(worldCoords); return bd.add(bd.divideFloating(positionBD, boardScale), boardPos); } /** Returns the integer square coordinate that includes the floating point square coords inside its area. */ function convertWorldSpaceToCoords_Rounded(worldCoords: DoubleCoords): Coords { const coordsBD: BDCoords = convertWorldSpaceToCoords(worldCoords); return roundCoords(coordsBD); } /** Returns the integer coordinate that contains the floating point coordinate provided. */ function roundCoord(coord: BigDecimal): bigint { const squareCenter = board.getSquareCenter(); return bd.toBigInt(bd.floor(bd.add(coord, squareCenter))); } /** Returns the integer coordinates that contain the floating point coordinate provided. */ function roundCoords(coords: BDCoords): Coords { return [roundCoord(coords[0]), roundCoord(coords[1])]; } // Takes a square coordinate, returns the world-space location of the square's VISUAL center! Dependant on board.getSquareCenter(). function convertCoordToWorldSpace( coords: BDCoords, position: BDCoords = boardpos.getBoardPos(), scale: BigDecimal = boardpos.getBoardScale(), ): DoubleCoords { const squareCenter = board.getSquareCenter(); const halfMinusSquareCenter = bd.subtract(HALF, squareCenter); function getAxis(coord: BigDecimal, position: BigDecimal): number { const diff = bd.subtract(coord, position); const diffPlusHalf = bd.add(diff, halfMinusSquareCenter); const scaled = bd.multiplyFloating(diffPlusHalf, scale); return bd.toNumber(scaled); } // (coords[0] - position[0] + 0.5 - squareCenter) * scale return [getAxis(coords[0], position[0]), getAxis(coords[1], position[1])]; } function convertCoordToWorldSpace_IgnoreSquareCenter( coords: BDCoords, position = boardpos.getBoardPos(), scale = boardpos.getBoardScale(), ): DoubleCoords { function getAxis(coord: BigDecimal, position: BigDecimal): number { const diff = bd.subtract(coord, position); const scaled = bd.multiplyFloating(diff, scale); return bd.toNumber(scaled); } // (coords[0] - position[0]) * scale return [getAxis(coords[0], position[0]), getAxis(coords[1], position[1])]; } /** Converts a measurement of virtual screen pixels to world space units. Dependant on the current screen height. */ function convertPixelsToWorldSpace_Virtual(value: number): number { const screenHeight = camera.getScreenHeightWorld(false); return (value / camera.getCanvasHeightVirtualPixels()) * screenHeight; } /** Converts a measurement of world space units to virtual screen pixels. Dependant on the current screen height. */ function convertWorldSpaceToPixels_Virtual(value: number): number { const screenHeight = camera.getScreenHeightWorld(false); return (value / screenHeight) * camera.getCanvasHeightVirtualPixels(); } /** Tells you how many square units span the grid value you pass in. */ function convertWorldSpaceToGrid(value: number): BigDecimal { const valueBD = bd.fromNumber(value); const scale = boardpos.getBoardScale(); // value / scale return bd.divideFloating(valueBD, scale); } export default { convertWorldSpaceToCoords, convertWorldSpaceToCoords_Axis, convertWorldSpaceToCoords_Rounded, roundCoord, roundCoords, convertCoordToWorldSpace, convertCoordToWorldSpace_IgnoreSquareCenter, convertPixelsToWorldSpace_Virtual, convertWorldSpaceToPixels_Virtual, convertWorldSpaceToGrid, }; ================================================ FILE: src/client/scripts/esm/game/rendering/ColorFlowRenderer.ts ================================================ // src/client/scripts/esm/game/rendering/ColorFlowRenderer.ts /** * A modular renderer that paints a color flow effect across the * entire screen on demand, similar to the Iridescene Zone effect. * Intended for use as a background effect inside void for video footage. * * It is entirely self-contained, using its own shaders and buffers. * The shader written specifically for this script is: src/client/shaders/fullscreen_colorflow/fragment.glsl * * Usage: * 1. Instantiate with a WebGL2RenderingContext. * 2. Call render(deltaTime) each frame to draw the effect. */ export class ColorFlowRenderer { private gl: WebGL2RenderingContext; private program: WebGLProgram | null = null; // --- Buffers & VAO --- private quadBuffer: WebGLBuffer | null = null; private vao: WebGLVertexArrayObject | null = null; // --- Configuration (Matching IridescenceZone defaults) --- public flowSpeed: number = 0.07; public flowRotationSpeed: number = 0.0025; public gradientRepeat: number = 0.7; public alpha: number = 1.0; // Abyssal Ocean color palette // Deep, calming, mysterious blues and greens public colors: [number, number, number][] = [ [0.0, 0.1, 0.3], // Midnight Blue [0.0, 0.3, 0.5], // Deep Teal [0.0, 0.6, 0.7], // Ocean Blue [0.0, 0.8, 0.6], // Seafoam Green [0.0, 0.4, 0.8], // Azure [0.1, 0.1, 0.4], // Dark Indigo ]; // --- State --- private flowDirection: number = Math.random() * Math.PI * 2; private flowDistance: number = 0; // --- Shader Source (Inlined for modularity) --- private readonly vsSource = `#version 300 es in vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } `; private readonly fsSource = `#version 300 es precision highp float; uniform vec2 u_resolution; uniform float u_flowDistance; uniform vec2 u_flowDirectionVec; uniform float u_gradientRepeat; uniform float u_alpha; uniform vec3 u_colors[6]; out vec4 fragColor; vec3 getColorFromRamp(float t) { float scaledT = t * 6.0; int index = int(floor(scaledT)); float blend = fract(scaledT); int nextIndex = (index + 1) % 6; if (index >= 6) index = 0; return mix(u_colors[index], u_colors[nextIndex], blend); } void main() { vec2 uv = gl_FragCoord.xy / u_resolution; float aspect = u_resolution.x / u_resolution.y; uv.x *= aspect; float projectedUv = dot(uv, u_flowDirectionVec); float phase = (projectedUv * u_gradientRepeat) + u_flowDistance; vec3 finalColor = getColorFromRamp(fract(phase)); fragColor = vec4(finalColor, u_alpha); } `; constructor(gl: WebGL2RenderingContext) { this.gl = gl; this.init(); } private init(): void { // 1. Compile Shaders const vertexShader = this.createShader(this.gl.VERTEX_SHADER, this.vsSource); const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, this.fsSource); if (!vertexShader || !fragmentShader) throw new Error('ColorFlowRenderer: Failed to create shaders'); // 2. Create Program this.program = this.gl.createProgram(); if (!this.program) throw new Error('ColorFlowRenderer: Failed to create program'); this.gl.attachShader(this.program, vertexShader); this.gl.attachShader(this.program, fragmentShader); this.gl.linkProgram(this.program); if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { console.error(this.gl.getProgramInfoLog(this.program)); throw new Error('ColorFlowRenderer: Failed to link program'); } // 3. Create Full-Screen Quad & VAO this.vao = this.gl.createVertexArray(); this.gl.bindVertexArray(this.vao); // prettier-ignore const vertices = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ]); this.quadBuffer = this.gl.createBuffer(); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.quadBuffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW); // Configure attributes INSIDE the VAO const positionLoc = this.gl.getAttribLocation(this.program, 'a_position'); this.gl.enableVertexAttribArray(positionLoc); this.gl.vertexAttribPointer(positionLoc, 2, this.gl.FLOAT, false, 0, 0); // Clean up: Unbind everything this.gl.bindVertexArray(null); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null); } private createShader(type: number, source: string): WebGLShader | null { const shader = this.gl.createShader(type); if (!shader) return null; this.gl.shaderSource(shader, source); this.gl.compileShader(shader); if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { console.error(this.gl.getShaderInfoLog(shader)); this.gl.deleteShader(shader); return null; } return shader; } /** * Updates internal animation state and draws the effect to the current framebuffer. * @param deltaTime Time in seconds since the last frame */ public render(deltaTime: number): void { if (!this.program || !this.vao) return; // --- 1. Update Animation State --- this.flowDirection += this.flowRotationSpeed * deltaTime; if (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2; this.flowDistance += this.flowSpeed * deltaTime; const flowDirectionVec = [Math.cos(this.flowDirection), Math.sin(this.flowDirection)]; // --- 2. SAVE PREVIOUS STATE --- const prevProgram = this.gl.getParameter(this.gl.CURRENT_PROGRAM); const prevVAO = this.gl.getParameter(this.gl.VERTEX_ARRAY_BINDING); const prevArrayBuffer = this.gl.getParameter(this.gl.ARRAY_BUFFER_BINDING); const prevBlend = this.gl.isEnabled(this.gl.BLEND); const prevDepthTest = this.gl.isEnabled(this.gl.DEPTH_TEST); const prevDepthMask = this.gl.getParameter(this.gl.DEPTH_WRITEMASK); // Save blend function parameters const prevSrcRGB = this.gl.getParameter(this.gl.BLEND_SRC_RGB); const prevDstRGB = this.gl.getParameter(this.gl.BLEND_DST_RGB); const prevSrcAlpha = this.gl.getParameter(this.gl.BLEND_SRC_ALPHA); const prevDstAlpha = this.gl.getParameter(this.gl.BLEND_DST_ALPHA); // --- 3. SETUP & DRAW --- this.gl.useProgram(this.program); this.gl.bindVertexArray(this.vao); // Ensure we draw over everything and don't write to depth buffer this.gl.disable(this.gl.DEPTH_TEST); this.gl.depthMask(false); // Set Uniforms const uResolution = this.gl.getUniformLocation(this.program, 'u_resolution'); const uFlowDistance = this.gl.getUniformLocation(this.program, 'u_flowDistance'); const uFlowDirectionVec = this.gl.getUniformLocation(this.program, 'u_flowDirectionVec'); const uGradientRepeat = this.gl.getUniformLocation(this.program, 'u_gradientRepeat'); const uAlpha = this.gl.getUniformLocation(this.program, 'u_alpha'); const uColors = this.gl.getUniformLocation(this.program, 'u_colors'); this.gl.uniform2f(uResolution, this.gl.canvas.width, this.gl.canvas.height); this.gl.uniform1f(uFlowDistance, this.flowDistance); this.gl.uniform2fv(uFlowDirectionVec, flowDirectionVec); this.gl.uniform1f(uGradientRepeat, this.gradientRepeat); this.gl.uniform1f(uAlpha, this.alpha); const flatColors: number[] = []; for (let i = 0; i < 6; i++) { const col = this.colors[i] || [0, 0, 0]; flatColors.push(...col); } this.gl.uniform3fv(uColors, new Float32Array(flatColors)); // Handle Blending if (this.alpha < 1.0) { this.gl.enable(this.gl.BLEND); this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); } else { this.gl.disable(this.gl.BLEND); } this.gl.drawArrays(this.gl.TRIANGLES, 0, 6); // --- 4. RESTORE STATE --- this.gl.depthMask(prevDepthMask); if (prevDepthTest) this.gl.enable(this.gl.DEPTH_TEST); else this.gl.disable(this.gl.DEPTH_TEST); if (prevBlend) { this.gl.enable(this.gl.BLEND); this.gl.blendFuncSeparate(prevSrcRGB, prevDstRGB, prevSrcAlpha, prevDstAlpha); } else { this.gl.disable(this.gl.BLEND); } this.gl.bindVertexArray(prevVAO); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, prevArrayBuffer); this.gl.useProgram(prevProgram); } } ================================================ FILE: src/client/scripts/esm/game/rendering/WaterRipples.ts ================================================ // src/client/scripts/esm/game/rendering/WaterRipples.ts /** * This scripts managers the animated water ripple effect for extremely large moves. */ import type { ProgramManager } from '../../webgl/ProgramManager'; import type { PostProcessPass } from '../../webgl/post_processing/PostProcessingPipeline'; import bounds from '../../../../../shared/util/math/bounds'; import bdcoords from '../../../../../shared/chess/util/bdcoords'; import { players as p } from '../../../../../shared/chess/util/typeutil'; import coordutil, { Coords } from '../../../../../shared/chess/util/coordutil'; import space from '../misc/space'; import camera from './camera'; import boardpos from './boardpos'; import drawrays from './highlights/annotations/drawrays'; import gameloader from '../chess/gameloader'; import perspective from './perspective'; import frametracker from './frametracker'; import { RippleState, WaterRipplePass } from '../../webgl/post_processing/passes/WaterRipplePass'; // Constants -------------------------------------------------------------------------------- /** * The distance beyond the screen edge that ripples are capped at, in virtual pixels, * PER virtual pixel of screen height, as the ripple speed is proportional to screen height. */ const RIPPLE_DIST_FROM_EDGE = 0.54; // Default: 0.54 /** The lifetime offset applied to ripples beyond the screen edge so that we see their ripple sooner. */ const ELAPSED_TIME_OFFSET = -230; // Default: -230 /** * How long each ripple lasts before being removed, in seconds, * on a PERFECTLY SQUARE canvas. */ const RIPPLE_LIFETIME_BASE = 1.1; /** How much longer ripples last per screen ratio of width/height. */ const RIPPLE_LIFETIME_MULTIPLIER = 0.5; // Variables -------------------------------------------------------------------------------- let waterRipplePass: WaterRipplePass; const activeDroplets: RippleState[] = []; /** * ACTUAL ripple lifetime, dependent on screen ratio, as the more * wider the screen is taller, the longer drops take to travel across. */ let rippleLifetime: number; // Functions -------------------------------------------------------------------------------- function init(programManager: ProgramManager, width: number, height: number): void { waterRipplePass = new WaterRipplePass(programManager, width, height); updateRippleLifetime(width, height); // The post processing effect relies on the dimensions of the canvas. // Init listener for screen resize document.addEventListener('canvas_resize', (event) => { const { width, height } = event.detail; waterRipplePass.setResolution(width, height); updateRippleLifetime(width, height); }); } function updateRippleLifetime(width: number, height: number): void { rippleLifetime = RIPPLE_LIFETIME_BASE + RIPPLE_LIFETIME_MULTIPLIER * (width / height); // console.log(`ripple lifetime adjusted to ${rippleLifetime.toFixed(2)}s`); } /** * Adds a ripple droplet at the given source coordinates. * Caps the ripple to be just off-screen if the source is significantly off-screen. */ function addRipple(sourceCoords: Coords): void { // Convert coords to world space const sourceWorldSpace = space.convertCoordToWorldSpace(bdcoords.FromCoords(sourceCoords)); const screenHeight = camera.canvas.height / window.devicePixelRatio; const pixelPadding = RIPPLE_DIST_FROM_EDGE * screenHeight; const rippleWorldFromEdge = space.convertPixelsToWorldSpace_Virtual(pixelPadding); // The screen rectangle in world space const screenBox = camera.getScreenBoundingBox(false); const paddedScreenBox = { left: screenBox.left - rippleWorldFromEdge, right: screenBox.right + rippleWorldFromEdge, top: screenBox.top + rippleWorldFromEdge, bottom: screenBox.bottom - rippleWorldFromEdge, }; let rippleX: number = sourceWorldSpace[0]; let rippleY: number = sourceWorldSpace[1]; let elapsedTimeOffset: number = 0; // Don't let the ripple source be too far off-screen if (!bounds.boxContainsSquareDouble(paddedScreenBox, sourceWorldSpace)) { // console.log("Ripple source outside of padded screen."); const vectorToSource = coordutil.subtractBDCoords( bdcoords.FromCoords(sourceCoords), boardpos.getBoardPos(), ); const closestVector = drawrays.findClosestPredefinedVector(vectorToSource, false); // [-1-1, -1-1] if (closestVector[0] === 0n) { rippleX = 0; if (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom; else if (closestVector[1] === 1n) rippleY = paddedScreenBox.top; } else if (closestVector[0] === 1n) { rippleX = paddedScreenBox.right; if (closestVector[1] === 0n) rippleY = 0; else if (closestVector[1] === 1n) rippleY = paddedScreenBox.top; else if (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom; } else if (closestVector[0] === -1n) { rippleX = paddedScreenBox.left; if (closestVector[1] === 0n) rippleY = 0; else if (closestVector[1] === 1n) rippleY = paddedScreenBox.top; else if (closestVector[1] === -1n) rippleY = paddedScreenBox.bottom; } // More offset for diagonals to account for greater distance from screen edge to ripple source const isDiagonal = closestVector[0] !== 0n && closestVector[1] !== 0n; elapsedTimeOffset = isDiagonal ? ELAPSED_TIME_OFFSET * 1.7 : ELAPSED_TIME_OFFSET; } const screenWidthWorld = screenBox.right - screenBox.left; const screenHeightWorld = screenBox.top - screenBox.bottom; // Convert world coordinates to UV coordinates [0-1] let u = (rippleX - screenBox.left) / screenWidthWorld; let v = (rippleY - screenBox.bottom) / screenHeightWorld; // If we're playing black, negate the UV coordinates if (!gameloader.areInLocalGame() && gameloader.getOurColor() === p.BLACK) { u = 1 - u; v = 1 - v; } // Create a new droplet activeDroplets.push({ center: [u, v], timeCreated: Date.now() + elapsedTimeOffset }); } function update(): void { const now = Date.now(); // Filter out old droplets for (let i = activeDroplets.length - 1; i >= 0; i--) { const droplet = activeDroplets[i]!; if (now >= droplet.timeCreated + rippleLifetime * 1000) { // Convert seconds to milliseconds activeDroplets.splice(i, 1); // console.log("Removed ripple droplet."); } } // Don't render ripple effect in perspective mode, as it is a pure // 2D post processing effect, not an effect on the rendered board. const framesActiveDrops = perspective.getEnabled() ? [] : activeDroplets; // FEED the active list to the pass waterRipplePass.updateDroplets(framesActiveDrops); // Only call for an animation frame if there are active droplets if (activeDroplets.length > 0) frametracker.onVisualChange(); } /** * Returns the WaterRipplePass instance this frame to be added to * the post-processing pipeline, if there are any visible drops. */ function getPass(): PostProcessPass[] { if (activeDroplets.length === 0) return []; return [waterRipplePass]; } export default { init, addRipple, update, getPass, }; ================================================ FILE: src/client/scripts/esm/game/rendering/animation.ts ================================================ // src/client/scripts/esm/game/rendering/animation.ts /** * This script handles the animation of pieces. * It also plays the sounds. */ import type { Piece } from '../../../../../shared/chess/util/boardutil.js'; import type { Color } from '../../../../../shared/util/math/math.js'; import type { BDCoords, Coords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import math from '../../../../../shared/util/math/math.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import vectors, { Vec3 } from '../../../../../shared/util/math/vectors.js'; import typeutil, { RawType, TypeGroup } from '../../../../../shared/chess/util/typeutil.js'; import toast from '../gui/toast.js'; import meshes from './meshes.js'; import splines from '../../util/splines.js'; import boardpos from './boardpos.js'; import gamesound from '../misc/gamesound.js'; import arrowshifts from './arrows/arrowshifts.js'; import piecemodels from './piecemodels.js'; import perspective from './perspective.js'; import { GameBus } from '../GameBus.js'; import frametracker from './frametracker.js'; import texturecache from '../../chess/rendering/texturecache.js'; import WaterRipples from './WaterRipples.js'; import instancedshapes from './instancedshapes.js'; import { createRenderable, createRenderable_Instanced_GivenInfo } from '../../webgl/Renderable.js'; // Types ---------------------------------------------------------------------------------- /** Represents an animation segment between two waypoints. */ interface AnimationSegment { start: BDCoords; end: BDCoords; /** The length of the individual segment. */ length: BigDecimal; /** The precalculated difference going from start to the end. */ difference: BDCoords; /** The precalculated ratio of the x difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */ xRatio: number; /** The precalculated ratio of the y difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */ yRatio: number; } /** Information about the progress of a current animation. */ type SegmentInfo = { /** * The INTEGER segment number along the entire animation path, 0-based. * 0 means it is at or beyond the first waypoint, 1 means it is at or beyond the second waypoint, etc. */ segmentNum: number; /** * The distance along the segment the animation currently is, in squares. * This is more ideal than a percentage between 0-1 since its hard to * predict how much precision you'll need to represent that percentage * in order to get a non-gittery animation for long distance animations. */ distance: BigDecimal; /** Whether the distance is from the start of the segment, or the end backwards. */ forward: boolean; }; /** Represents an animation of a piece. */ interface Animation { /** The type of piece to animate. */ type: number; /** The original integer coordinates of the piece's path. Minimum: 2 */ path: Coords[]; /** The high resolution waypoints the piece will pass throughout the animation. */ path_smooth: BDCoords[]; /** The segments between each waypoint */ segments: AnimationSegment[]; /** Pieces that need to be shown, up until a set path point is reached. Usually needed for captures. 0 is the start of the path. */ showKeyframes: Map; /** Pieces that need to be hidded, up until a set path point is reached. Usually needed for reversing captures and hiding the moved piece. 0 is the start of the path. */ hideKeyframes: Map; /** The time the animation started. */ startTimeMillis: number; /** The duration of the animation. */ durationMillis: number; /** The total distance the piece will travel throughout the animation across all waypoints. */ totalDistance: BigDecimal; /** Whether the animation is for a premove. */ premove: boolean; /** Whether the sound has been played yet. */ soundPlayed: boolean; /** The id of the timeout that will play the sound a little before the animation finishes, so there isn't a delay. */ soundTimeoutId?: ReturnType; /** The id of the timeout that will remove the animation from the list once it's over. */ scheduledRemovalId?: ReturnType; } // Constants ------------------------------------------------------------------- const ZERO = bd.fromBigInt(0n); const ONE = bd.fromBigInt(1n); /** Config for the splines. */ const SPLINES: { /** The number of points per segment of the spline. */ RESOLUTION: number; /** The thickness of the spline. Used when debug rendering. */ WIDTH: number; /** The color of the spline. Used when debug rendering. */ COLOR: [number, number, number, number]; } = { RESOLUTION: 10, // Default: 10 WIDTH: 0.15, // Default: 0.15 COLOR: [1, 0, 0, 1], // Default: [1, 0, 0, 1] }; /** * The z offset of the transparent square meant to block out the default * rendering of the pieces while the animation is visible. * * THIS MUST BE GREATER THAN THE Z AT WHICH PIECES ARE RENDERED. */ const TRANSPARENT_SQUARE_Z: number = 0.01; /** By adding a negative offset, the sound doesn't appear delayed. */ const SOUND_OFFSET: number = 0; // TODO: Delete after next update, after some time with zero delay, to make sure we still like it. /** The maximum distance an animation can be without teleporting mid-animation. */ const MAX_DISTANCE_BEFORE_TELEPORT: number = 80; // 80 /** Used for calculating the duration of move animations. */ const MOVE_ANIMATION_DURATION = { /** The base amount of duration, in millis. */ baseMillis: 150, // Default: 150 /** The multiplier amount of duration, in millis, multiplied by the capped move distance. */ multiplierMillis: 6, /** The multiplierMillis when there's at least 3+ waypoints */ multiplierMillis_Curved: 12, // Default: 12 /** Replaces {@link MOVE_ANIMATION_DURATION.baseMillis} when {@link DEBUG} is true. */ baseMillis_Debug: 2000, /** Replaces {@link MOVE_ANIMATION_DURATION.multiplierMillis} when {@link DEBUG} is true. */ multiplierMillis_Debug: 15, /** Replaces {@link MOVE_ANIMATION_DURATION.multiplierMillis_Curved} when {@link DEBUG} is true. */ multiplierMillis_Curved_Debug: 30, }; // Variables ------------------------------------------------------------------------------- /** The list of all current animations */ const animations: Animation[] = []; /** If this is enabled, the spline of the animations will be rendered, and the animations' duration increased. */ let DEBUG = false; // Events ---------------------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { // Clear all animations from the last game clearAnimations(); }); // Adding / Clearing Animations ----------------------------------------------------------------------- /** * Animates a single piece after moving it. One king/rook in castling counts as one animation. * One animation can hide the animated piece at its destination square, and show captured pieces. * @param type - The type of piece to animate * @param path - The waypoints the piece will pass throughout the animation. Minimum: 2 * @param showKeyframes * @param hideKeyframes * @param instant - Whether the animation should be instantanious, only playing the SOUND. If this is true, the animation will not be added to the list of animations, and will not be rendered. * @param resetAnimations - If false, allows animation of multiple pieces at once. Useful for castling. Default: true */ function animatePiece( type: number, path: Coords[], showKeyframes: Map, hideKeyframes: Map, instant?: boolean, resetAnimations = false, premove = false, ): void { if (path.length < 2) throw new Error('Animation requires at least 2 waypoints'); if (resetAnimations) clearAnimations(true); // Generate smooth spline waypoints const path_smooth = splines.generateSplinePath(path, SPLINES.RESOLUTION); const segments = createAnimationSegments(path_smooth); // Calculates the total length of the path traveled by the piece in the animation. const totalDistance: BigDecimal = segments.reduce((sum, seg) => bd.add(sum, seg.length), ZERO); // The hideShowKeyframes need to be stretched to match the resolution of the spline. hideKeyframes = stretchKeyframesForResolution(hideKeyframes, SPLINES.RESOLUTION, path.length); showKeyframes = stretchKeyframesForResolution(showKeyframes, SPLINES.RESOLUTION, path.length); // If this animation involves rendering a piece that doesn't have an SVG (void), // we can't animate/render it. Make it an instant animationinstead. const typesInvolved: Set = new Set([typeutil.getRawType(type)]); showKeyframes.forEach((w) => w.forEach((p) => typesInvolved.add(typeutil.getRawType(p.type)))); if ( new Set([...typesInvolved, ...typeutil.SVGLESS_TYPES]).size < typesInvolved.size + typeutil.SVGLESS_TYPES.size ) instant = true; // Instant animations still play the sound // Handle instant animation (piece was dropped): Play the SOUND ONLY, but don't animate. if (instant) return gamesound.playMove( totalDistance, showKeyframes.size !== 0, premove, path[path.length - 1]!, ); const newAnimation: Animation = { type, path, path_smooth, segments, showKeyframes, hideKeyframes, startTimeMillis: performance.now(), durationMillis: calculateAnimationDuration(totalDistance, path_smooth.length), totalDistance, premove, soundPlayed: false, }; scheduleSoundPlayback(newAnimation); scheduleAnimationRemoval(newAnimation); animations.push(newAnimation); } /** * Terminates all animations. * * Should be called when we're skipping through moves quickly * (in that scenario we immediately play the sound), * or when the game is unloaded. */ function clearAnimations(playSounds = false): void { animations.forEach((animation) => { clearTimeout(animation.soundTimeoutId); // Don't play it twice.. clearTimeout(animation.scheduledRemovalId); // Don't remove it twice.. if (playSounds && !animation.soundPlayed) playAnimationSound(animation); // .. play it NOW. }); animations.length = 0; // Empties existing animations } function toggleDebug(): void { DEBUG = !DEBUG; toast.show(`Toggled animation splines: ${DEBUG}`, { durationMultiplier: 0.5 }); } // Helper Functions ----------------------------------------------------------- /** * Stretches a {@link Animation.showKeyframes} or {@link Animation.hideKeyframes} * to match the resolution of the animation spline. */ function stretchKeyframesForResolution( keyframes: Map, resolution: number, waypointCount: number, ): Map { if (waypointCount < 3) return keyframes; const t: Map = new Map(); for (const [k, v] of keyframes) { t.set(k * resolution, v); } return t; } /** Creates the segments between each waypoint. */ function createAnimationSegments(waypoints: BDCoords[]): AnimationSegment[] { const segments: AnimationSegment[] = []; for (let i = 0; i < waypoints.length - 1; i++) { const start = waypoints[i]!; const end = waypoints[i + 1]!; const difference: BDCoords = coordutil.subtractBDCoords(end, start); // Since the difference can be arbitrarily large, we need to normalize it // NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers. const normalizedVector: DoubleCoords = vectors.normalizeVectorBD(difference); const normalizedVectorHypot: number = Math.hypot(...normalizedVector); segments.push({ start, end, length: vectors.euclideanDistanceBD(start, end), difference: difference, xRatio: normalizedVector[0] / normalizedVectorHypot, yRatio: normalizedVector[1] / normalizedVectorHypot, }); } return segments; } /** Calculates the duration in milliseconds a particular move would take to animate. */ function calculateAnimationDuration(totalDistance: BigDecimal, waypointCount: number): number { const baseMillis = DEBUG ? MOVE_ANIMATION_DURATION.baseMillis_Debug : MOVE_ANIMATION_DURATION.baseMillis; const cappedDist = Math.min(bd.toNumber(totalDistance), MAX_DISTANCE_BEFORE_TELEPORT); let multiplier: number; if (DEBUG) multiplier = waypointCount > 2 ? MOVE_ANIMATION_DURATION.multiplierMillis_Curved_Debug : MOVE_ANIMATION_DURATION.multiplierMillis_Debug; else multiplier = waypointCount > 2 ? MOVE_ANIMATION_DURATION.multiplierMillis_Curved : MOVE_ANIMATION_DURATION.multiplierMillis; const additionMillis = cappedDist * multiplier; return baseMillis + additionMillis; } /** Schedules the playback of the sound of the animation. */ function scheduleSoundPlayback(animation: Animation): void { const playbackTime = Math.max(0, animation.durationMillis + SOUND_OFFSET); animation.soundTimeoutId = setTimeout(() => playAnimationSound(animation), playbackTime); } /** Schedules the removal of an animation after it's over. */ function scheduleAnimationRemoval(animation: Animation): void { animation.scheduledRemovalId = setTimeout(() => { const index = animations.indexOf(animation); if (index === -1) return; // Already removed animations.splice(index, 1); frametracker.onVisualChange(); }, animation.durationMillis); } /** * Plays the sound of the animation. * @param animation - The animation to play the sound for. * @param dampen - Whether to dampen the sound. This should be true if we're skipping through moves quickly. */ function playAnimationSound(animation: Animation): void { gamesound.playMove( animation.totalDistance, animation.showKeyframes.size !== 0, animation.premove, animation.path[animation.path.length - 1]!, ); animation.soundPlayed = true; } // Updating ------------------------------------------------------------------------------- /** Flags the frame to be rendered if there are any animations, and adds an arrow indicator animation for each */ function update(): void { WaterRipples.update(); if (animations.length === 0) return; frametracker.onVisualChange(); animations.forEach((animation) => shiftArrowIndicatorOfAnimatedPiece(animation)); // Animate the arrow indicator } /** Animates the arrow indicator */ function shiftArrowIndicatorOfAnimatedPiece(animation: Animation): void { const segmentInfo = getCurrentSegment(animation); // Delete the arrows of the hidden pieces forEachActiveKeyframe(animation.hideKeyframes, segmentInfo.segmentNum, (coords) => coords.forEach((c) => arrowshifts.deleteArrow(c)), ); const animationCurrentCoords = getCurrentAnimationPosition(animation.segments, segmentInfo); // Add the arrow of the animated piece (also removes the arrow it off its destination square) arrowshifts.animateArrow( animation.path[animation.path.length - 1]!, animationCurrentCoords, animation.type, ); // Add the arrows of the captured pieces only after we've shifted the piece that captured it forEachActiveKeyframe(animation.showKeyframes, segmentInfo.segmentNum, (pieces) => pieces.forEach((p) => arrowshifts.addArrow(p.type, p.coords)), ); } // Rendering ------------------------------------------------------------------------------- /** * [ZOOMED IN] Renders the transparent squares that block out the default rendering of the pieces while the animation is visible. * This works because they are higher in the depth buffer than the pieces. */ function renderTransparentSquares(): void { if (!animations.length) return; const color: Color = [0, 0, 0, 0]; // Calls map() on each animation, and then flats() the results into a single array. const data = animations.flatMap((animation) => { const hidesData: number[] = []; const segmentNum = getCurrentSegment(animation).segmentNum; forEachActiveKeyframe(animation.hideKeyframes, segmentNum, (v) => { v.forEach((coord) => hidesData.push(...meshes.QuadWorld_Color(coord, color))); }); return hidesData; }); createRenderable(data, 2, 'TRIANGLES', 'color', true).render([0, 0, TRANSPARENT_SQUARE_Z]); } /** [ZOOMED IN] Renders the animations of the pieces. */ function renderAnimations(): void { if (animations.length === 0) return; if (DEBUG) animations.forEach((animation) => splines.renderSplineDebug(animation.path_smooth, SPLINES.WIDTH, SPLINES.COLOR), ); /** * Move away from the depricated spritesheet! * * We need to generate one instanced buffer model * for each type of piece included in the animations. */ const boardPos = boardpos.getBoardPos(); /** Whether the textures should be inverted or not, based on whether we're viewing black's perspective. */ const inverted = perspective.getIsViewingBlackPerspective(); const vertexData = instancedshapes.getDataTexture(inverted); // We need two separate data groups to control render order. // 1. Captured pieces (which should be rendered underneath) // 2. The main moving pieces (which should be rendered on top) const capturedPiecesInstanceData: TypeGroup = {}; const movingPiecesInstanceData: TypeGroup = {}; animations.forEach((animation) => { const segmentInfo = getCurrentSegment(animation); const currentPos = getCurrentAnimationPosition(animation.segments, segmentInfo); // Populate the moving piece data processPiece(animation.type, currentPos, movingPiecesInstanceData); // Populate the captured piece data forEachActiveKeyframe(animation.showKeyframes, segmentInfo.segmentNum, (pieces) => { // Render all captured pieces in place pieces.forEach((p) => { const coordsBD = bdcoords.FromCoords(p.coords); processPiece(p.type, coordsBD, capturedPiecesInstanceData); }); }); }); /** Helper for pushing a piece's instancedata to a specified data group. */ function processPiece( type: number, coords: BDCoords, targetInstanceData: TypeGroup, ): void { const relativePosition: DoubleCoords = bdcoords.coordsToDoubles( coordutil.subtractBDCoords(coords, boardPos), ); if (!(type in targetInstanceData)) targetInstanceData[type] = []; // Initialize targetInstanceData[type]!.push(...relativePosition); } // Render all const boardScale = boardpos.getBoardScaleAsNumber(); const scale: Vec3 = [boardScale, boardScale, 1]; /** Renders an entire group of pieces, organized by type. */ function renderTypeGroup(instanceData: TypeGroup): void { for (const [typeStr, instance_data] of Object.entries(instanceData)) { const type = Number(typeStr); const texture = texturecache.getTexture(type); createRenderable_Instanced_GivenInfo( vertexData, instance_data, piecemodels.ATTRIBUTE_INFO, 'TRIANGLES', 'textureInstanced', [{ texture, uniformName: 'u_sampler' }], ).render(undefined, scale); } } // 1. Render captured pieces FIRST on bottom. renderTypeGroup(capturedPiecesInstanceData); // 2. Render moving pieces SECOND, so they always appear on top. renderTypeGroup(movingPiecesInstanceData); } // Animation Calculations ----------------------------------------------------- /** * Calculates which segment of the animation the animated piece is currently on, * and its distance along that specific segment. * @param animation - The animation to calculate the current segment for. * @param maxDistB4TeleportNumber - The maximum distance the animation should be allowed to travel * before teleporting mid-animation near the end of its destination. * This should be specified if we're animating a miniimage, since when * we're zoomed out, the animation moving faster is perceivable. * Can be arbitrarily large. * @returns The animation's segment information. */ function getCurrentSegment( animation: Animation, maxDistB4Teleport: BigDecimal = bd.fromNumber(MAX_DISTANCE_BEFORE_TELEPORT), ): SegmentInfo { const elapsed = performance.now() - animation.startTimeMillis; /** The interpolated progress of the animation. */ const t = Math.min(elapsed / animation.durationMillis, 1); /** The eased progress of the animation. */ const easedT = math.easeInOut(t); const easedTBD = bd.fromNumber(easedT); /** The total distance along the animation path the animated piece should currently be at. */ let targetDistance: BigDecimal; let forward = true; if (bd.compare(animation.totalDistance, maxDistB4Teleport) <= 0) { // Total distance is short enough to animate the whole path targetDistance = bd.multiplyFloating(animation.totalDistance, easedTBD); } else { // The total distance is great enough to merit teleporting: Skip the middle of the path if (easedT < 0.5) { // First half targetDistance = bd.multiply(maxDistB4Teleport, easedTBD); } else { // easedT >= 0.5 // Second half: animate final portion of path const inverseEasedT = bd.subtract(ONE, easedTBD); targetDistance = bd.multiply(maxDistB4Teleport, inverseEasedT); forward = false; } } // Return the segment the piece should be at, based on the target distance, // and how far along the segment it currently is. let accumulated: BigDecimal = bd.fromBigInt(0n); if (forward) { for (let i = 0; i < animation.segments.length; i++) { const segmentInfo = iterateSegment(i); if (segmentInfo) return segmentInfo; } return { segmentNum: animation.segments.length, distance: ZERO, forward }; // At the end of the path } else { for (let i = animation.segments.length - 1; i >= 0; i--) { const segmentInfo = iterateSegment(i); if (segmentInfo) return segmentInfo; } return { segmentNum: 0, distance: ZERO, forward }; // At the start of the path } /** Helper for iterating over each segment, accumulating the distance traveled until we reach the target distance. */ function iterateSegment(i: number): SegmentInfo | undefined { const segment = animation.segments[i]!; const newAccumulated = bd.add(accumulated, segment.length); if (bd.compare(targetDistance, newAccumulated) <= 0) { // The piece is in this segment /** * Once we've found the segment we're on, this is how far we travel along that * segment until we reach our target distance of the animation from the very start. */ const distanceAlongSegment = bd.subtract(targetDistance, accumulated); return { segmentNum: i, distance: distanceAlongSegment, forward }; } accumulated = newAccumulated; return undefined; // ts gets mad without this } } /** * Calculates the position of the moved piece from the progress of the animation. * @param segments - The segments of the animation. * @param segmentNum - The segment number, which is the progress of the animation from {@link getCurrentSegment}. * @returns the coordinate the animation's piece should be rendered this frame. */ function getCurrentAnimationPosition( segments: AnimationSegment[], segmentInfo: SegmentInfo, ): BDCoords { if (segmentInfo.segmentNum >= segments.length) return segments[segments.length - 1]!.end; const segment = segments[segmentInfo.segmentNum]!; const startPoint = segmentInfo.forward ? segment.start : segment.end; const xTraversalAlongSegment = bd.multiplyFloating( segmentInfo.distance, bd.fromNumber(segment.xRatio), ); const yTraversalAlongSegment = bd.multiplyFloating( segmentInfo.distance, bd.fromNumber(segment.yRatio), ); const addOrSubtract: Function = segmentInfo.forward ? bd.add : bd.subtract; return [ addOrSubtract(startPoint[0], xTraversalAlongSegment), addOrSubtract(startPoint[1], yTraversalAlongSegment), ]; } // ----------------------------------------------------------------------------------------- /** * Iterates over all keyframes that have not been passed by the animation. * This is all showKeyframes that are still being shown, or all hideKeyframes that are still being hidden. */ function forEachActiveKeyframe( keyframes: Map, segment: number, callback: (_value: T) => void, ): void { for (const [k, v] of keyframes) { if (k < segment) continue; callback(v); } } export default { animations, animatePiece, clearAnimations, toggleDebug, update, renderTransparentSquares, renderAnimations, getCurrentSegment, getCurrentAnimationPosition, forEachActiveKeyframe, }; ================================================ FILE: src/client/scripts/esm/game/rendering/area.ts ================================================ // src/client/scripts/esm/game/rendering/area.ts /** * This script handles the calculation of the "Area"s on screen that * will contain the desired list of piece coordinates when at a specific * camera position and scale (zoom), which can be used to tell * {@link Transition} where to teleport to. */ import type { BDCoords, Coords } from '../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../../../../shared/util/jsutil.js'; import bounds, { BoundingBoxBD } from '../../../../../shared/util/math/bounds.js'; import space from '../misc/space.js'; import camera from './camera.js'; import meshes from './meshes.js'; import boardpos from './boardpos.js'; import boardtiles from './boardtiles.js'; import guigameinfo from '../gui/guigameinfo.js'; import guinavigation from '../gui/guinavigation.js'; import Transition, { ZoomTransition } from './transitions/Transition.js'; /** * An area object, containing the information {@link Transition} needs * to teleport/transition to this location on the board. */ export interface Area { /** The coordinates of the area. */ coords: BDCoords; /** The camera scale (zoom) of the area. */ scale: BigDecimal; /** The bounding box that contains the area of interest. */ boundingBox: BoundingBoxBD; } const TWO = bd.fromNumber(2.0); const padding: number = 0.03; // As a percentage of the screen WIDTH/HEIGHT (subtract the navigation bars height) const paddingMiniimage: number = 0.2; // The padding to use when miniimages are visible (zoomed out far) /** * The minimum number of squares that should be visible when transitioning somewhere. * This is so that it doesn't zoom too close-up on a single piece or small group. */ const areaMinHeightSquares: number = 17; // Divided by screen width // Just the action of adding padding, changes the required scale to have that amount of padding, // so we need to iterate it a few times for more accuracy. // MUST BE GREATER THAN 0! const iterationsToRecalcPadding: number = 10; /** * Returns a new bounding box, with added padding so the pieces * aren't too close to the edge or underneath the navigation bar. * @param box - The source bounding box, floating point edges. * @returns The new bounding box */ function applyPaddingToBox(box: BoundingBoxBD): BoundingBoxBD { // { left, right, bottom, top } const boxCopy: BoundingBoxBD = jsutil.deepCopyObject(box); const topNavHeight = guinavigation.getHeightOfNavBar(); const bottomNavHeight = guigameinfo.getHeightOfGameInfoBar(); const navHeight = topNavHeight + bottomNavHeight; const canvasHeightVirtualSubNav = camera.getCanvasHeightVirtualPixels() - navHeight; /** Start with a copy with zero padding. */ let paddedBox: BoundingBoxBD = jsutil.deepCopyObject(boxCopy); let scaleBD: BigDecimal = calcScaleToMatchSides(paddedBox); // Iterate until we have desired padding for (let i = 0; i < iterationsToRecalcPadding; i++) { const paddingToUse: number = bd.compare(scaleBD, camera.getScaleWhenZoomedOut()) < 0 ? paddingMiniimage : padding; const paddingHorzPixels = camera.getCanvasWidthVirtualPixels() * paddingToUse; const paddingVertPixels = canvasHeightVirtualSubNav * paddingToUse + bottomNavHeight; const paddingHorzWorldBD = bd.fromNumber( space.convertPixelsToWorldSpace_Virtual(paddingHorzPixels), ); const paddingVertWorldBD = bd.fromNumber( space.convertPixelsToWorldSpace_Virtual(paddingVertPixels), ); const paddingHorz: BigDecimal = bd.divide(paddingHorzWorldBD, scaleBD); const paddingVert: BigDecimal = bd.divide(paddingVertWorldBD, scaleBD); paddedBox = { left: bd.subtract(boxCopy.left, paddingHorz), right: bd.add(boxCopy.right, paddingHorz), bottom: bd.subtract(boxCopy.bottom, paddingVert), top: bd.add(boxCopy.top, paddingVert), }; // Prep for next iteration scaleBD = calcScaleToMatchSides(paddedBox); } return paddedBox; } /** * Calculates an Area object from the given bounding box. * The box must come PRE-PADDED. * @param box - The bounding box * @returns The area object */ function calculateFromBox(box: BoundingBoxBD): Area { // { left, right, bottom, top } // The new boardPos is the middle point const newBoardPos = bounds.calcCenterOfBoundingBox(box); // What is the scale required to match the sides? const newScale = calcScaleToMatchSides(box); // Now maximize the bounding box to fill entire screen when at position and scale, so that // we don't have long thin slices of a bounding box that will fail the bounds.boxContainsSquare() function EVEN // if the square is visible on screen! const maximizedBox = boardtiles.getBoundingBoxOfBoard(newBoardPos, newScale, false); // PROBLEM WITH this enabled is since it changes the size of the boundingBox, new coords are not centered. return { coords: newBoardPos, scale: newScale, boundingBox: maximizedBox, }; } function getBoundingBoxHalfDimensions(boundingBox: BoundingBoxBD): { xHalfLength: BigDecimal; yHalfLength: BigDecimal; } { const xDiff = bd.subtract(boundingBox.right, boundingBox.left); const yDiff = bd.subtract(boundingBox.top, boundingBox.bottom); return { xHalfLength: bd.divide(xDiff, TWO), yHalfLength: bd.divide(yDiff, TWO), }; } /** * Calculates the camera scale (zoom) needed to fit * the provided board bounding box within the canvas. * @param boundingBox - The bounding box * @returns The scale (zoom) required */ function calcScaleToMatchSides(boundingBox: BoundingBoxBD): BigDecimal { const { xHalfLength, yHalfLength } = getBoundingBoxHalfDimensions(boundingBox); const screenBoundingBox = camera.getScreenBoundingBox(false); // Get the screen bounding box without the navigation bars const screenBoundingBoxBD: BoundingBoxBD = bounds.castDoubleBoundingBoxToBigDecimal(screenBoundingBox); // What is the scale required to match the sides? const xScale = bd.divideFloating(screenBoundingBoxBD.right, xHalfLength); const yScale = bd.divideFloating(screenBoundingBoxBD.top, yHalfLength); const screenHeight = screenBoundingBox.top - screenBoundingBox.bottom; // Can afterward cast to BigDecimal since they are small numbers. const capScale = bd.fromNumber(screenHeight / areaMinHeightSquares); let newScale = bd.min(xScale, yScale); newScale = bd.min(newScale, capScale); // Cap the scale to not zoom in too close for comfort return newScale; } /** * Calculates the area object that contains every coordinate in the provided list, *with padding added*, * and contains the optional {@link existingBox} bounding box. * @param coordsList - An array of coordinates, typically of the pieces. * @returns The area object */ function calculateFromCoordsList(coordsList: Coords[]): Area { if (coordsList.length === 0) throw Error('Cannot calculate area from an empty coords list.'); const box = bounds.getBoxFromCoordsList(coordsList); // Unpadded const boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare(box); return calculateFromUnpaddedBox(boxFloating); } /** * Calulates the area object from the provided bounding box, *with padding added*. * @param box - A BoundingBox object. * @returns The area object */ function calculateFromUnpaddedBox(box: BoundingBoxBD): Area { const paddedBox = applyPaddingToBox(box); return calculateFromBox(paddedBox); } /** * High level function that initaties one or two zoom transitions * with the goal of getting the target Area on screen. * @param thisArea - The Area object to get on screen. * @param [ignoreHistory] Whether to skip adding this teleport to the teleport history. */ function initTransitionFromArea(thisArea: Area, ignoreHistory: boolean): void { const thisAreaBox = thisArea.boundingBox; const startCoords = boardpos.getBoardPos(); const endCoords = thisArea.coords; const currentBoardBoundingBox = boardtiles.gboundingBoxFloat(); // Tile/board space, NOT world-space // Will a teleport to this area be a zoom out or in? const isAZoomOut = bd.compare(thisArea.scale, boardpos.getBoardScale()) < 0; let firstArea: Area | undefined; if (isAZoomOut) { // If our current screen isn't within the final area, create new area to teleport to first if (!bounds.boxContainsSquareBD(thisAreaBox, startCoords)) { bounds.expandBDBoxToContainSquare(thisAreaBox, startCoords); // Unpadded firstArea = calculateFromUnpaddedBox(thisAreaBox); } // Version that fits the entire screen on the zoom out // if (!bounds.boxContainsBoxBD(thisAreaBox, currentBoardBoundingBox)) { // const mergedBoxes = bounds.mergeBoundingBoxBDs(currentBoardBoundingBox, thisAreaBox); // firstArea = calculateFromBox(mergedBoxes); // } } else { // zoom-in. If the end area isn't visible on screen now, create new area to teleport to first if (!bounds.boxContainsSquareBD(currentBoardBoundingBox, endCoords)) { bounds.expandBDBoxToContainSquare(currentBoardBoundingBox, endCoords); // Unpadded firstArea = calculateFromUnpaddedBox(currentBoardBoundingBox); } // Version that fits the entire screen on the zoom out // if (!bounds.boxContainsBoxBD(currentBoardBoundingBox, thisAreaBox)) { // const mergedBoxes = bounds.mergeBoundingBoxBDs(currentBoardBoundingBox, thisAreaBox); // firstArea = calculateFromBox(mergedBoxes); // } } const trans1: ZoomTransition | undefined = firstArea ? { destinationCoords: firstArea.coords, destinationScale: firstArea.scale } : undefined; const trans2: ZoomTransition = { destinationCoords: thisArea.coords, destinationScale: thisArea.scale, }; if (trans1) Transition.startZoomTransition(trans1, trans2, ignoreHistory); else Transition.startZoomTransition(trans2, undefined, ignoreHistory); } export default { calculateFromCoordsList, calculateFromUnpaddedBox, initTransitionFromArea, }; ================================================ FILE: src/client/scripts/esm/game/rendering/arrows/arrowlegalmovehighlights.ts ================================================ // src/client/scripts/esm/game/rendering/arrows/arrowlegalmovehighlights.ts /** * This script keeps track of and renders the * legal moves of all arrow indicators being hovered over. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js'; import type { RenderableInstanced } from '../../../webgl/Renderable.js'; import typeutil from '../../../../../../shared/chess/util/typeutil.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import legalmoves from '../../../../../../shared/chess/logic/legalmoves.js'; import coordutil, { Coords } from '../../../../../../shared/chess/util/coordutil.js'; import meshes from '../meshes.js'; import gameslot from '../../chess/gameslot.js'; import selection from '../../chess/selection.js'; import gameloader from '../../chess/gameloader.js'; import preferences from '../../../components/header/preferences.js'; import boardeditor from '../../boardeditor/boardeditor.js'; import { GameBus } from '../../GameBus.js'; import legalmovemodel from '../highlights/legalmovemodel.js'; import arrows, { ArrowPiece } from './arrows.js'; // Types ------------------------------------------------------------------------------------------------------ /** Contains the legal moves, and other info, about the piece an arrow indicator is pointing to. */ interface ArrowLegalMoves { /** The Piece this arrow is pointing to, including its coords & type. */ piece: Piece; /** The calculated legal moves of the piece. */ legalMoves: LegalMoves; /** The buffer model for rendering the non-capturing legal moves of the piece. */ model_NonCapture: RenderableInstanced; /** The buffer model for rendering the capturing legal moves of the piece. */ model_Capture: RenderableInstanced; /** The [r,b,g,a] values these legal move highlights should be rendered. * Depends on whether the piece is ours, a premove, or an opponent's piece. */ color: Color; } /** * An array storing the LegalMoves, model and other info, for rendering the legal move highlights * of piece arrow indicators currently being hovered over! * * THIS IS UPDATED AFTER OTHER SCRIPTS have a chance to add/delete pieces to show arrows for, * as hovered arrows have a chance of being removed before rendering! */ const hoveredArrowsLegalMoves: ArrowLegalMoves[] = []; // Events ---------------------------------------------------------------------------------------------- GameBus.addEventListener('physical-move', () => { // Whenever a move is made in the game, the color of the legal move highlights // of the hovered arrows often changes. // Erase the list so they can be regenerated next frame with the correct color. reset(); }); // Functions ------------------------------------------------------------------------------------------- /** * This makes sure that the legal moves of all of the hovered arrows this * frame are already calculated. * * Pieces that are consecutively hovered over each frame have their * legal moves cached. */ function update(): void { const gamefile = gameslot.getGamefile()!; // Do not render line highlights upon arrow hover, when game is rewinded, // since calculating their legal moves means overwriting game's move history. if (!moveutil.areWeViewingLatestMove(gamefile.boardsim)) { hoveredArrowsLegalMoves.length = 0; return; } const hoveredArrows = arrows.getHoveredArrows(); // Iterate through all pieces in piecesHoveredOver, if they aren't being // hovered over anymore, delete them. Stop rendering their legal moves. for (let i = hoveredArrowsLegalMoves.length - 1; i >= 0; i--) { // Iterate backwards because we are removing elements as we go const thisHoveredArrow = hoveredArrowsLegalMoves[i]!; // Is this arrow still being hovered over? if ( !hoveredArrows.some((arrow) => { if (arrow.piece.floating) return false; const integerCoords = bdcoords.coordsToBigInt(arrow.piece.coords); return coordutil.areCoordsEqual(integerCoords, thisHoveredArrow.piece.coords); }) ) hoveredArrowsLegalMoves.splice(i, 1); // No longer being hovered over. Delete its legal moves. } for (const pieceHovered of hoveredArrows) { onPieceIndicatorHover(pieceHovered.piece); // Generate their legal moves and highlight model } } /** * Call when a piece's arrow is hovered over. * Calculates their legal moves and model for rendering them. * @param piece - The piece this arrow is pointing to */ function onPieceIndicatorHover(arrowPiece: ArrowPiece): void { // SHOULD WE JUST RETURN HERE INSTEAD OF ERROR??? if (!bdcoords.areCoordsIntegers(arrowPiece.coords)) throw Error( 'We should not be calculating legal moves for a hovered arrow pointing to a piece at floating point coordinates!', ); // Check if their legal moves and mesh have already been stored if ( hoveredArrowsLegalMoves.some((hoveredArrow) => { const integerCoords = bdcoords.coordsToBigInt(arrowPiece.coords); return coordutil.areCoordsEqual(hoveredArrow.piece.coords, integerCoords); }) ) return; // Legal moves and mesh already calculated. const integerCoords: Coords = bdcoords.coordsToBigInt(arrowPiece.coords); const piece: Piece = { type: arrowPiece.type, coords: integerCoords, index: arrowPiece.index, }; // Calculate their legal moves and mesh! const gamefile = gameslot.getGamefile()!; const thisPieceLegalMoves = legalmoves.calculateAll(gamefile, piece); // Calculate the mesh... // Determine what color the legal move highlights should be... const pieceColor = typeutil.getColorFromType(piece.type); const ourColor = gameloader.areInLocalGame() || boardeditor.areInBoardEditor() ? gamefile.basegame.whosTurn : gameloader.getOurColor(); const isOpponentPiece = pieceColor !== ourColor; const isOurTurn = gamefile.basegame.whosTurn === pieceColor; const color = preferences.getLegalMoveHighlightColor({ isOpponentPiece, isPremove: !isOurTurn, }); const { NonCaptureModel, CaptureModel } = legalmovemodel.generateModelsForPiecesLegalMoveHighlights( piece.coords, thisPieceLegalMoves, pieceColor, color, ); // Store both these objects inside piecesHoveredOver hoveredArrowsLegalMoves.push({ piece, legalMoves: thisPieceLegalMoves, model_NonCapture: NonCaptureModel, model_Capture: CaptureModel, color, }); } /** Renders the pre-cached legal move highlights of all arrow indicators being hovered over */ function renderEachHoveredPieceLegalMoves(): void { if (hoveredArrowsLegalMoves.length === 0) return; // No legal moves to render const { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset()); hoveredArrowsLegalMoves.forEach((hoveredArrow) => { // Skip it if the piece being hovered over IS the piece selected! (Its legal moves are already being rendered) if (selection.isAPieceSelected()) { const pieceSelectedCoords = selection.getPieceSelected()!.coords; if (coordutil.areCoordsEqual(hoveredArrow.piece.coords, pieceSelectedCoords)) return; // Skip (already rendering its legal moves, because it's selected) } hoveredArrow.model_NonCapture.render(position, scale); hoveredArrow.model_Capture.render(position, scale); }); } /** * Regenerates the mesh of the piece arrow indicators hovered legal moves. * * Call when our highlights offset, or render range bounding box, changes, * so we account for the new offset. */ function regenModelsOfHoveredPieces(): void { if (hoveredArrowsLegalMoves.length === 0) return; // No arrows being hovered over console.log("Updating models of hovered piece's legal moves.."); hoveredArrowsLegalMoves.forEach((hoveredArrow) => { // Calculate the mesh... const pieceColor = typeutil.getColorFromType(hoveredArrow.piece.type); const { NonCaptureModel, CaptureModel } = legalmovemodel.generateModelsForPiecesLegalMoveHighlights( hoveredArrow.piece.coords, hoveredArrow.legalMoves, pieceColor, hoveredArrow.color, ); // Overwrite the model inside piecesHoveredOver hoveredArrow.model_NonCapture = NonCaptureModel; hoveredArrow.model_Capture = CaptureModel; }); } /** Erases the cached legal moves of the hovered arrow indicators */ function reset(): void { hoveredArrowsLegalMoves.length = 0; // Erase, otherwise their legal move highlights continue to render } // ------------------------------------------------------------------------------------------------------------- export default { update, reset, renderEachHoveredPieceLegalMoves, regenModelsOfHoveredPieces, }; ================================================ FILE: src/client/scripts/esm/game/rendering/arrows/arrows.ts ================================================ // src/client/scripts/esm/game/rendering/arrows/arrows.ts /** * This script manages the state of arrow indicators on the sides of the screen, * pointing to pieces off-screen that are in that direction. * * If the pictures are clicked, we initiate a teleport to that piece. * * Other scripts may add/remove arrows in between update() and render() calls. * Calculation is handled by arrowscalculator, shifting by arrowshifts, * and rendering by arrowsgraphics. */ import type { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import type { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import gameslot from '../../chess/gameslot.js'; import arrowshifts from './arrowshifts.js'; import frametracker from '../frametracker.js'; import arrowscalculator from './arrowscalculator.js'; import arrowlegalmovehighlights from './arrowlegalmovehighlights.js'; // Types ------------------------------------------------------------------------------- /** An object containing all the arrow lines of a single frame. */ export interface SlideArrows { /** An object containing all existing arrows for a specific slide direction */ [vec2Key: Vec2Key]: { /** * A single line containing what arrows ARE visible on the * sides of the screen for offscreen pieces. */ [lineKey: string]: ArrowsLine; }; } /** * An object containing the arrows that should actually be present, * for a single organized line intersecting through our screen. * * The FIRST index in each of these left/right arrays, is the picture * which gets rendered at the default location. * The FINAL index in each of these, is the picture of the piece * that is CLOSEST to you (screen center) on the line! */ export interface ArrowsLine { /** Pieces on this line that intersect the screen with a positive dot product. * SORTED in order of closest to the screen to farthest. */ posDotProd: Arrow[]; /** Pieces on this line that intersect the screen with a negative dot product. * SORTED in order of closest to the screen to farthest. * The arrow direction for these will be flipped to the other side. */ negDotProd: Arrow[]; } /** A single piece-based arrow indicator, with enough information to be able to render it. */ export interface Arrow extends BaseArrow { piece: ArrowPiece; /** Opacity to render this arrow at when not hovered. Defaults to the module-level opacity constant. */ opacity: number; /** * The direction the arrow triangle points. * Equals `negateVector(processPiece vector)`, used directly as the render angle. */ direction: Vec2; /** * Index within the adjacent-picture stack on this line. 0 for the primary (outermost) * indicator; > 0 for stacked indicators closer to the screen center. */ stackIndex: number; } /** * Reflection of the {@link Piece} type, but with extra decimal precision * for the coordinates (needed for animated arrows). */ export interface ArrowPiece { type: number; coords: BDCoords; index: number; /** Whether the piece is at a floating point coordinate. */ floating: boolean; } /** Shared base for all screen-edge arrow indicators. */ interface BaseArrow { /** The world-space position of this indicator on the screen edge. */ worldLocation: DoubleCoords; /** Whether this indicator is being hovered over by the mouse. */ hovered: boolean; } /** Hovered-arrow event: identifies which arrow indicator is currently being hovered. */ export interface HoveredArrow { /** A reference to the piece it is pointing to */ piece: ArrowPiece; /** * The direction this arrow points (from the screen edge toward the piece). * Matches the slide direction the arrow indicator represents. */ direction: Vec2; /** The world-space position of this arrow indicator on the screen edge. */ worldLocation: DoubleCoords; /** * Whether the piece can generally slide in the arrow direction. * IS NOT calculated for shifted arrows (always true). */ ownsSlide: boolean; } /** An arrow indicator for an off-screen individual legal move, shown when in check. */ export interface HintArrow extends BaseArrow { /** Direction this indicator points, from the screen edge toward its target. */ direction: Vec2; /** The target square this hint arrow points to. */ targetSquare: Coords; } // Constants --------------------------------------------------------------------------- /** The maximum number of pieces in a game before we disable arrow indicator rendering, for performance. */ const MAX_PIECES = 40_000; /** The maximum number of lines in a game before we disable arrow indicator rendering, for performance. */ const MAX_LINES = 8; // State ------------------------------------------------------------------------------- /** * The mode the arrow indicators on the edges of the screen is currently in. * 0 = Off, * 1 = Defense, * 2 = All (orthogonals & diagonals) * 3 = All (including hippogonals, only used in variants using hippogonals) */ let mode: 0 | 1 | 2 | 3 = 1; /** * A list of all arrows present for the current frame. * * Other scripts are given an opportunity to add/remove * arrows from this list before rendering, but they must * do so between the update() and render() calls. */ let slideArrows: SlideArrows = {}; /** * A list of all piece-arrows being hovered over this frame (excludes move hints), * with a reference to the piece they are pointing to. * Other scripts may access this so they can add interaction with them. */ let hoveredArrows: HoveredArrow[] = []; /** * A list of all animated arrows IN MOTION for the current frame. * * This does not include still ones, for example rendered from * the piece captured being rendered in place. * Still animation's lines are recalculated manually. */ let animatedArrows: Arrow[] = []; /** * A list of all hint arrows computed for the current frame. * Each hints at an off-screen individual legal move destination. */ let hintArrows: HintArrow[] = []; // Mode management --------------------------------------------------------------------- /** Returns the mode the arrow indicators on the edges of the screen is currently in. */ function getMode(): typeof mode { return mode; } /** Sets the current mode of the arrow indicators. */ function setMode(value: typeof mode): void { mode = value; if (mode === 0) { reset(); arrowlegalmovehighlights.reset(); // Erase, otherwise their legal move highlights continue to render } } /** Rotates the current mode of the arrow indicators. */ function toggleArrows(): void { frametracker.onVisualChange(); // Have to do it weirdly like this, instead of using '++', because typescript complains that nextMode is of type number. let nextMode: typeof mode = mode === 0 ? 1 : mode === 1 ? 2 : mode === 2 ? 3 : /* mode === 3 ? */ 0; // Calculate the cap const cap = gameslot.getGamefile()!.boardsim.pieces.hippogonalsPresent ? 3 : 2; if (nextMode > cap) nextMode = 0; // Wrap back to zero setMode(nextMode); } // Getters ----------------------------------------------------------------------------- /** * Returns all Arrow objects currently in the slide arrows structure. * Does NOT include animated arrows. * Callers may mutate arrow properties (e.g. opacity) before rendering. */ function getAllArrows(): Arrow[] { const result: Arrow[] = []; for (const linesOfDirection of Object.values(slideArrows)) { for (const line of Object.values(linesOfDirection as { [lineKey: string]: ArrowsLine })) { for (const arrow of line.posDotProd) result.push(arrow); for (const arrow of line.negDotProd) result.push(arrow); } } return result; } /** Returns the current slide arrows state for this frame. */ function getSlideArrows(): SlideArrows { return slideArrows; } /** Returns the current animated arrows for this frame. */ function getAnimatedArrows(): Arrow[] { return animatedArrows; } /** * Returns the list of arrow indicators hovered over this frame, * with references to the piece they are pointing to. */ function getHoveredArrows(): HoveredArrow[] { return hoveredArrows; } /** Returns the current hint arrows for this frame. */ function getHintArrows(): HintArrow[] { return hintArrows; } /** * Whether the mouse is currently hovering over at least one * arrow indicator of any type (piece or move hint) on the screen. */ function areHoveringAtleastOneArrow(): boolean { return hoveredArrows.length > 0 || hintArrows.some((ha) => ha.hovered); } /** * Returns the world-space locations of all arrow indicators present for the current frame. * Must be called after update(). */ function getAllArrowWorldLocations(): DoubleCoords[] { return [...getAllArrows(), ...animatedArrows, ...hintArrows].map((a) => a.worldLocation); } /** * Whether the piece arrows should be calculated and rendered this frame. * Excludes move hint arrows--those are always active so long as we're zoomed in enough. */ function areArrowsActiveThisFrame(): boolean { // false if the arrows are off, or if the board is too zoomed out return mode !== 0 && arrowscalculator.areZoomedInEnoughForArrows(); } // Frame lifecycle --------------------------------------------------------------------- /** * Resets the arrows lists in prep for the next frame. */ function reset(): void { slideArrows = {}; animatedArrows = []; hoveredArrows = []; hintArrows = []; arrowshifts.reset(); } /** * Calculates what arrows should be visible this frame. * * Needs to be done every frame, even if the mouse isn't moved, * since actions such as rewinding/forwarding may change them, * or board velocity. */ function update(): void { reset(); const result = arrowscalculator.calculateArrows(mode); // { active, slideArrows, hoveredArrows, hintArrows } if (result.hintArrows) hintArrows = result.hintArrows; if (!result.active) { // Arrow indicators are off, nothing is visible. arrowlegalmovehighlights.reset(); // Also reset this return; } hoveredArrows = result.hoveredArrows!; slideArrows = result.slideArrows!; } // Exports ----------------------------------------------------------------------------- export default { // Constants MAX_PIECES, MAX_LINES, // Mode management getMode, setMode, toggleArrows, // Getters getAllArrows, getSlideArrows, getAnimatedArrows, getHoveredArrows, getHintArrows, areHoveringAtleastOneArrow, getAllArrowWorldLocations, areArrowsActiveThisFrame, // Frame lifecycle update, }; ================================================ FILE: src/client/scripts/esm/game/rendering/arrows/arrowscalculator.ts ================================================ // src/client/scripts/esm/game/rendering/arrows/arrowscalculator.ts /** * This script calculates which arrow indicators should be visible on the * screen edges each frame, where they should be positioned, and which * are being hovered over. * * It also computes hint arrows for off-screen legal move destinations. */ import type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js'; import type { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import type { Arrow, ArrowPiece, HoveredArrow, HintArrow, ArrowsLine, SlideArrows, } from './arrows.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../../../../../shared/util/jsutil.js'; import bimath from '../../../../../../shared/util/math/bimath.js'; import bounds from '../../../../../../shared/util/math/bounds.js'; import typeutil from '../../../../../../shared/chess/util/typeutil.js'; import geometry from '../../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import boardutil from '../../../../../../shared/chess/util/boardutil.js'; import legalmoves from '../../../../../../shared/chess/logic/legalmoves.js'; import { rawTypes as r } from '../../../../../../shared/chess/util/typeutil.js'; import vectors, { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import organizedpieces, { LineKey } from '../../../../../../shared/chess/logic/organizedpieces.js'; import space from '../../misc/space.js'; import mouse from '../../../util/mouse.js'; import arrows from './arrows.js'; import gameslot from '../../chess/gameslot.js'; import boardpos from '../boardpos.js'; import movehints from '../highlights/movehints.js'; import boardtiles from '../boardtiles.js'; import Transition from '../transitions/Transition.js'; import perspective from '../perspective.js'; import guigameinfo from '../../gui/guigameinfo.js'; import guinavigation from '../../gui/guinavigation.js'; import { listener_overlay } from '../../chess/game.js'; import { InputListener, Mouse, MouseButton } from '../../input.js'; // Types ------------------------------------------------------------------------------- /** * An object containing all the arrow lines of a single frame, * BEFORE removing excess arrows due to our mode. */ interface SlideArrowsDraft { /** An object containing all existing arrows for a specific slide direction */ [vec2Key: Vec2Key]: { /** * A single line containing what arrows should be visible on the * sides of the screen for offscreen pieces. */ [lineKey: string]: ArrowsLineDraft; }; } /** * An object containing the arrows that should actually be present, * for a single organized line intersecting through our screen, * BEFORE removing excess arrows due to our mode. * * The FIRST index in each of these left/right arrays, is the picture * which gets rendered at the default location. * The FINAL index in each of these, is the picture of the piece * that is CLOSEST to you (screen center) on the line! */ interface ArrowsLineDraft { /** Pieces on this line that intersect the screen with a positive dot product. * SORTED in order of closest to the screen to farthest. */ posDotProd: ArrowDraft[]; /** Pieces on this line that intersect the screen with a negative dot product. * SORTED in order of closest to the screen to farthest. * The arrow direction for these will be flipped to the other side. */ negDotProd: ArrowDraft[]; /** An array of the points this line intersects the screen bounding box, * in order of ascending dot product. */ intersections: [BDCoords, BDCoords]; } /** A single arrow indicator DRAFT. This may be removed depending on our mode. */ type ArrowDraft = { piece: ArrowPiece; /** Whether the piece the arrow is pointing to can legally slide at least * partially into the screen, not whether it can slide in that direction EVER. */ canSlideOntoScreen: boolean; }; // Constants --------------------------------------------------------------------------- /** The width of all pictures of the pieces and their arrows, in percentage of 1 tile. */ const WIDTH = 0.65; /** How much padding to include between the pictures of the arrow * indicators and the edge of the screen, in percentage of 1 tile. */ const EDGE_GAP = 0.15; // Default: 0.15 0.1 Lines up the tip of the arrows right against the edge /** The precalculated distance one picture's center should be from the screen edge. */ const IMAGE_EDGE_DIST: BigDecimal = bd.fromNumber(WIDTH / 2 + EDGE_GAP); /** How much separation should be between stacked pictures pointing * to multiple pieces on the same line, in percentage of 1 tile. */ const STACK_PADDING = 0.35; /** Opacity of the mini images of the pieces and arrows. */ export const OPACITY = 0.6; /** The smallest width squares can be in virtual pixels * before skipping rendering arrow indicators (too small). */ const MIN_SQUARE_SIZE: BigDecimal = bd.fromBigInt(12n); /** The distance in perspective mode to render the arrow indicators from the camera. * We need this because there is no normal edge of the screen like in 2D mode. */ const PERSPECTIVE_EDGE_DIST = 17; const HALF = bd.fromNumber(0.5); // State ------------------------------------------------------------------------------- /** * The bounding box of the screen for this frame, with padding added so * that arrow indicators aren't touching the very edge of the screen. */ let boundingBoxFloat: BoundingBoxBD | undefined; /** * The bounding box of the screen for this frame, * rounded outward to contain the entirety of * any square even partially visible. */ let boundingBoxInt: BoundingBox | undefined; // Getters ----------------------------------------------------------------------------- export function getBoundingBoxFloat(): BoundingBoxBD | undefined { return boundingBoxFloat; } /** Whether ANY arrow (piece or move hint) should be calculated and rendered this frame. */ export function areZoomedInEnoughForArrows(): boolean { return bd.compare(boardtiles.gtileWidth_Pixels(false), MIN_SQUARE_SIZE) >= 0; } /** * Returns the world-space half-width of each arrow indicator's square hitbox for the current frame. * This is the Chebyshev-distance radius used to detect hover/opacity changes. */ export function getArrowIndicatorHalfWidth(): number { return (WIDTH * boardpos.getBoardScaleAsNumber()) / 2; } // Main entry point -------------------------------------------------------------------- /** * Calculates which arrows should be visible for a frame. * Always computes bounding boxes and hint arrows. * Only computes slide arrows when the mode is non-zero and zoom is sufficient. */ export function calculateArrows(mode: 0 | 1 | 2 | 3): { /** Whether piece arrows are active this frame. */ active: boolean; slideArrows?: SlideArrows; hoveredArrows?: HoveredArrow[]; hintArrows?: HintArrow[]; } { updateBoundingBoxesOfVisibleScreen(); if (!areZoomedInEnoughForArrows()) return { active: false }; const hintArrows = updateHintArrows(); if (!arrows.areArrowsActiveThisFrame()) { return { active: false, hintArrows: hintArrows }; } const slideArrowsDraft = generateArrowsDraft(); removeUnnecessaryArrows(slideArrowsDraft, mode); const { slideArrows, hoveredArrows } = calculatePieceArrows(slideArrowsDraft); return { active: true, slideArrows, hoveredArrows, hintArrows }; } // Bounding box ------------------------------------------------------------------------ /** * Calculates the visible bounding box of the screen for this frame, * both the integer-rounded-away, and the exact floating point one. * * These boxes are used to test whether a piece is visible on-screen or not. * As if it's not, it should get an arrow. */ function updateBoundingBoxesOfVisibleScreen(): void { boundingBoxFloat = perspective.getEnabled() ? boardtiles.generatePerspectiveBoundingBox(PERSPECTIVE_EDGE_DIST) : boardtiles.gboundingBoxFloat(); // Apply the padding of the navigation and gameinfo bars to the screen bounding box. if (!perspective.getEnabled()) { // Perspective is OFF let headerPad = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar()); let footerPad = space.convertPixelsToWorldSpace_Virtual( guigameinfo.getHeightOfGameInfoBar(), ); // Reverse header and footer pads if we're viewing black's side if (!gameslot.isLoadedGameViewingWhitePerspective()) [headerPad, footerPad] = [footerPad, headerPad]; // Swap values // Apply the paddings to the bounding box boundingBoxFloat.top = bd.subtract( boundingBoxFloat.top, space.convertWorldSpaceToGrid(headerPad), ); boundingBoxFloat.bottom = bd.add( boundingBoxFloat.bottom, space.convertWorldSpaceToGrid(footerPad), ); } // If any part of the square is on screen, this box rounds outward to contain it. boundingBoxInt = boardtiles.roundAwayBoundingBox(boundingBoxFloat); /** * Adds a little bit of padding to the bounding box, so that the arrows of the * arrows indicators aren't touching the edge of the screen. */ boundingBoxFloat.left = bd.add(boundingBoxFloat.left, IMAGE_EDGE_DIST); boundingBoxFloat.right = bd.subtract(boundingBoxFloat.right, IMAGE_EDGE_DIST); boundingBoxFloat.bottom = bd.add(boundingBoxFloat.bottom, IMAGE_EDGE_DIST); boundingBoxFloat.top = bd.subtract(boundingBoxFloat.top, IMAGE_EDGE_DIST); } // Arrow draft generation -------------------------------------------------------------- /** * Generates a draft of all the arrows for a game, as if All (plus hippogonals) mode was on. * This contains minimal information, as some may be removed later. */ function generateArrowsDraft(): SlideArrowsDraft { /** The running list of arrows that should be visible */ const slideArrowsDraft: SlideArrowsDraft = {}; const gamefile = gameslot.getGamefile()!; gamefile.boardsim.pieces.slides.forEach((slide: Vec2) => { // For each slide direction in the game... const slideKey: Vec2Key = vectors.getKeyFromVec2(slide); // Find the 2 points on opposite sides of the bounding box // that will contain all organized lines of the given vector // intersecting the box between them. const containingPoints = geometry.findCrossSectionalWidthPoints(slide, boundingBoxInt!); const containingPointsLineC = containingPoints.map((point) => vectors.getLineCFromCoordsAndVec(point, slide), ) as [bigint, bigint]; // Any line of this slope of which its C value is not within these 2 are outside of our screen, // so no arrows will be visible for the piece. containingPointsLineC.sort((a, b) => bimath.compare(a, b)); // Sort them so C is ascending. Then index 0 will be the minimum and 1 will be the max. // For all our lines in the game with this slope... const organizedLinesOfDir = gamefile.boardsim.pieces.lines.get(slideKey)!; for (const [lineKey, organizedLine] of organizedLinesOfDir) { // The C of the lineKey (`C|X`) with this slide at the very left & right sides of the screen. const C: bigint = organizedpieces.getCFromKey(lineKey); if ( bimath.compare(C, containingPointsLineC[0]) < 0 || bimath.compare(C, containingPointsLineC[1]) > 0 ) continue; // Next line, this one is off-screen, so no piece arrows are visible // Calculate the ACTUAL arrows that should be visible for this specific organized line. const arrowsLine = calcArrowsLineDraft(gamefile, slide, slideKey, organizedLine); if (arrowsLine === undefined) continue; if (!slideArrowsDraft[slideKey]) slideArrowsDraft[slideKey] = {}; // Make sure this exists first slideArrowsDraft[slideKey][lineKey] = arrowsLine; // Add this arrows line to our object containing all arrows for this frame } }); return slideArrowsDraft; } /** * Calculates what arrows should be visible for a single * organized line of pieces intersecting our screen. * * In a game with Huygens, there may be multiple arrows * stacked on each other in the same line, since Huygens * can jump/skip over other pieces. */ export function calcArrowsLineDraft( gamefile: FullGame, slideDir: Vec2, slideKey: Vec2Key, organizedline: number[], ): ArrowsLineDraft | undefined { const negDotProd: ArrowDraft[] = []; const posDotProd: ArrowDraft[] = []; /** The innermost piece on the side that is closest to us (screen center). */ let closestPosDotProd: ArrowDraft | undefined; /** The innermost piece on the side that is closest to us (screen center). */ let closestNegDotProd: ArrowDraft | undefined; const axis = slideDir[0] === 0n ? 1 : 0; const firstPiece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, organizedline[0]!)!; /** * The 2 intersections points of the whole organized line, consistent for every piece on it. * The only difference is each piece may have a different dot product, * which just means it's on the opposite side. */ const intersections = geometry .findLineBoxIntersectionsBD( bdcoords.FromCoords(firstPiece.coords), slideDir, boundingBoxFloat!, ) .map((c) => c.coords); if (intersections.length < 2) return; // Arrow line intersected screen box exactly on the corner!! Let's skip constructing this line. No arrow will be visible const boundingBoxIntBD = bounds.castBoundingBoxToBigDecimal(boundingBoxInt!); organizedline.forEach((idx) => { const piece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, idx)!; const arrowPiece: ArrowPiece = { type: piece.type, coords: bdcoords.FromCoords(piece.coords), index: piece.index, floating: false, }; // Is the piece off-screen? if (bounds.boxContainsSquareBD(boundingBoxIntBD, arrowPiece.coords)) return; // On-screen, no arrow needed // Piece is guaranteed off-screen... const thisPieceIntersections = geometry.findLineBoxIntersectionsBD( arrowPiece.coords, slideDir, boundingBoxFloat!, ); if (thisPieceIntersections.length < 2) return; const positiveDotProduct = thisPieceIntersections[0]!.positiveDotProduct; // We know the dot product of both intersections will be identical, because the piece is off-screen. const arrowDraft: ArrowDraft = { piece: arrowPiece, canSlideOntoScreen: false }; // Update the piece that is closest to the screen box. if (positiveDotProduct) { if (closestPosDotProd === undefined) closestPosDotProd = arrowDraft; else if (bd.compare(arrowPiece.coords[axis], closestPosDotProd.piece.coords[axis]) > 0) closestPosDotProd = arrowDraft; } else { // negativeDotProduct if (closestNegDotProd === undefined) closestNegDotProd = arrowDraft; else if (bd.compare(arrowPiece.coords[axis], closestNegDotProd.piece.coords[axis]) < 0) closestNegDotProd = arrowDraft; } /** * Calculate its maximum slide. * * If it is able to slide (ignoring ignore function, and ignoring check respection) * into our screen area, then it should be guaranteed an arrow, * EVEN if it's not the closest piece to us on the line * (which would mean it phased/skipped over pieces due to a custom blocking function) */ // prettier-ignore const slideLegalLimit = legalmoves.calcPiecesLegalSlideLimitOnSpecificLine(gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piece, slideDir, slideKey, organizedline); if (slideLegalLimit === undefined) return; // This piece can't slide along the direction of travel /** * It CAN slide along our direction of travel... * But can it slide far enough where it can reach our screen? * * We already know the intersection points of its slide with the screen box. * * Next, how do we test if its legal slide protrudes into the screen? * * All we do is test if the piece's distance to the furthest point it can * slide is GREATER than its distance to the first intersection of the screen... */ // If the vector is in the opposite direction, then the first intersection is swapped const firstIntersection = positiveDotProduct ? thisPieceIntersections[0]! : thisPieceIntersections[1]!; // What is the distance to the first intersection point? let firstIntersectionDist = vectors.chebyshevDistanceBD( arrowPiece.coords, firstIntersection.coords, ); // Subtract the padding from the intersection so we get the distance to the intersection of the SCREEN EDGE. firstIntersectionDist = bd.subtract(firstIntersectionDist, IMAGE_EDGE_DIST); // What is the distance to the farthest point this piece can slide along this direction? let farthestSlidePoint: Coords | null; if (positiveDotProduct) { farthestSlidePoint = slideLegalLimit[1] === null ? null : [ // Multiply by the number of steps the piece can do in that direction piece.coords[0] + slideDir[0] * slideLegalLimit[1], piece.coords[1] + slideDir[1] * slideLegalLimit[1], ]; } else { // Negative dot product farthestSlidePoint = slideLegalLimit[0] === null ? null : [ piece.coords[0] - slideDir[0] * slideLegalLimit[0], piece.coords[1] - slideDir[1] * slideLegalLimit[0], ]; } const farthestSlidePointDist: bigint | null = farthestSlidePoint === null ? null : vectors.chebyshevDistance(piece.coords, farthestSlidePoint); // If the farthest slide point distance is greater than the first intersection // distance, then the piece is able to slide into the screen bounding box! if (farthestSlidePointDist !== null) { let farthestSlidePointDistBD = bd.fromBigInt(farthestSlidePointDist); // Add the additional distance from the center of the square to its edge // This is so that if any part of the furthest square highlight to // move to is visible on screen, we will still render the arrow! farthestSlidePointDistBD = bd.add(farthestSlidePointDistBD, HALF); // If the farthest slide point distance is less than the first intersection distance, // then this piece cannot slide onto the screen, so we skip it. if (bd.compare(farthestSlidePointDistBD, firstIntersectionDist) < 0) return; // This piece cannot slide so far as to intersect the screen bounding box } // This piece CAN slide far enough to enter our screen... arrowDraft.canSlideOntoScreen = true; // Add the piece to the arrow line if (positiveDotProduct) posDotProd.push(arrowDraft); else /* Opposite side */ negDotProd.push(arrowDraft); }); /** * Add the closest left/right pieces if they haven't been added already * (which would only be the case if they can slide onto our screen), * And DON'T add them if they are a VOID square! */ if ( closestPosDotProd !== undefined && !posDotProd.includes(closestPosDotProd) && typeutil.getRawType(closestPosDotProd.piece.type) !== r.VOID ) posDotProd.push(closestPosDotProd); if ( closestNegDotProd !== undefined && !negDotProd.includes(closestNegDotProd) && typeutil.getRawType(closestNegDotProd.piece.type) !== r.VOID ) negDotProd.push(closestNegDotProd); if (posDotProd.length === 0 && negDotProd.length === 0) return; // If both are empty, return undefined // Now sort them. posDotProd.sort((entry1, entry2) => bd.compare(entry1.piece.coords[axis], entry2.piece.coords[axis]), ); negDotProd.sort((entry1, entry2) => bd.compare(entry2.piece.coords[axis], entry1.piece.coords[axis]), ); return { negDotProd, posDotProd, intersections: intersections as [BDCoords, BDCoords] }; } // Mode-based filtering ---------------------------------------------------------------- /** * Removes arrows based on the mode. * * mode == 1: Removes arrows to ONLY include the pieces which can legally slide into our screen (which may include hippogonals) * mode == 2: Everything in mode 1, PLUS all orthogonals and diagonals, whether or not the piece can slide into our screen * mode == 3: Everything in mode 1 & 2, PLUS all hippogonals, whether or not the piece can slide into our screen */ function removeUnnecessaryArrows(slideArrowsDraft: SlideArrowsDraft, mode: 0 | 1 | 2 | 3): void { if (mode === 3) return; // Don't remove anything const slideExceptions = getSlideExceptions(mode); for (const direction in slideArrowsDraft) { if (slideExceptions.includes(direction as Vec2Key)) continue; // Keep it anyway, our arrows mode is high enough // Remove types that can't slide onto the screen... const arrowsByDir = slideArrowsDraft[direction as Vec2Key]; for (const key in arrowsByDir) { // LineKey const line: ArrowsLineDraft = arrowsByDir[key]!; removeTypesThatCantSlideOntoScreenFromLineDraft(line); if (line.negDotProd.length === 0 && line.posDotProd.length === 0) delete arrowsByDir[key as LineKey]; } if (jsutil.isEmpty(slideArrowsDraft[direction as Vec2Key]!)) delete slideArrowsDraft[direction as Vec2Key]; } } /** Checks if a single animated arrow is needed, based on our current mode, and its direction. */ export function isAnimatedArrowUnnecessary( boardsim: Board, type: number, direction: Vec2, dirKey: Vec2Key, mode: 0 | 1 | 2 | 3, ): boolean { if (mode === 3) return false; // Keep it, whether hippogonal orthogonal or diagonal if (mode === 2) return vectors.chebyshevDistance([0n, 0n], direction) !== 1n; // Only keep orthogonals and diagonals, NO hippogonals. // mode must === 1, only keep it if it can slide in the direction, whether blocked or not const thisPieceMoveset = legalmoves.getPieceMoveset(boardsim, type); // Default piece moveset if (!thisPieceMoveset.sliding) return true; // This piece can't slide at all if (!thisPieceMoveset.sliding[dirKey]) return true; // This piece can't slide ALONG the provided line // This piece CAN slide along the provided line... return false; } /** * IF we're in mode 2, this returns an array of all orthogonal and diagonal vectors. * We don't return anything if it's mode 3, since EVERYTHING is an exception anyway. * If it's mode 1, we don't return anything either, because it depends on whether * the piece can slide into the direction of the vector, and onto our screen. */ export function getSlideExceptions(mode: 0 | 1 | 2 | 3): Vec2Key[] { const gamefile = gameslot.getGamefile()!; let slideExceptions: Vec2Key[] = []; // If we're in mode 2, retain all orthogonals and diagonals, EVEN if they can't slide in that direction. if (mode === 2) slideExceptions = gamefile.boardsim.pieces.slides .filter((slideDir: Vec2) => vectors.chebyshevDistance([0n, 0n], slideDir) === 1n) // Filter out all hippogonal and greater vectors .map((v) => vectors.getKeyFromVec2(v)); return slideExceptions; } export function removeTypesThatCantSlideOntoScreenFromLineDraft(line: ArrowsLineDraft): void { // The only pieces in a line that WOULDN'T be able to slide onto the screen // is the piece closest to us. ALL other pieces we wouldn't have added otherwise. if (line.negDotProd.length > 0) { const arrowDraft: ArrowDraft = line.negDotProd[line.negDotProd.length - 1]!; if (!arrowDraft.canSlideOntoScreen) line.negDotProd.pop(); } if (line.posDotProd.length > 0) { const arrowDraft: ArrowDraft = line.posDotProd[line.posDotProd.length - 1]!; if (!arrowDraft.canSlideOntoScreen) line.posDotProd.pop(); } } // Finalizing arrows ------------------------------------------------------------------- /** * Converts all arrow drafts into fully computed arrows with world-space positions * and hover detection. Collects all hovered arrows. */ function calculatePieceArrows(slideArrowsDraft: SlideArrowsDraft): { slideArrows: SlideArrows; hoveredArrows: HoveredArrow[]; } { const slideArrows: SlideArrows = {}; const hoveredArrows: HoveredArrow[] = []; const worldHalfWidth = getArrowIndicatorHalfWidth(); const pointerWorlds = mouse.getAllPointerWorlds(); for (const vec2Key of Object.keys(slideArrowsDraft) as Vec2Key[]) { const linesOfDirectionDraft = slideArrowsDraft[vec2Key]!; const slideDir = vectors.getVec2FromKey(vec2Key); const linesOfDirection: { [lineKey: string]: ArrowsLine } = {}; for (const lineKey of Object.keys(linesOfDirectionDraft)) { const arrowLineDraft = linesOfDirectionDraft[lineKey]!; // prettier-ignore const { line, newHoveredArrows } = convertLineDraftToLine(arrowLineDraft, slideDir, vec2Key, worldHalfWidth, pointerWorlds, true); linesOfDirection[lineKey] = line; hoveredArrows.push(...newHoveredArrows); } slideArrows[vec2Key] = linesOfDirection; } return { slideArrows, hoveredArrows }; } /** * Converts an {@link ArrowsLineDraft} into a fully computed {@link ArrowsLine}, * resolving world-space positions and hover detection for each arrow. * @param appendHover - When true, also computes ownsSlide and collects hovered arrows. */ export function convertLineDraftToLine( draft: ArrowsLineDraft, slideDir: Vec2, vec2Key: Vec2Key, worldHalfWidth: number, pointerWorlds: DoubleCoords[], appendHover: boolean, ): { line: ArrowsLine; newHoveredArrows: HoveredArrow[] } { const negVector = vectors.negateVector(slideDir); const boardsim = gameslot.getGamefile()!.boardsim!; const newHoveredArrows: HoveredArrow[] = []; const toArrow = ( dir: Vec2, intersection: BDCoords, arrowDraft: ArrowDraft, index: number, ): Arrow => { // prettier-ignore const arrow = processPiece(arrowDraft.piece, dir, intersection, index, worldHalfWidth, pointerWorlds); if (appendHover && arrow.hovered) { const moveset = legalmoves.getPieceMoveset(boardsim, arrowDraft.piece.type); const ownsSlide = !!(moveset.sliding && moveset.sliding[vec2Key]); newHoveredArrows.push({ piece: arrow.piece, direction: arrow.direction, worldLocation: arrow.worldLocation, ownsSlide, }); } return arrow; }; const line: ArrowsLine = { posDotProd: draft.posDotProd.map((ad, i) => toArrow(slideDir, draft.intersections[0], ad, i), ), negDotProd: draft.negDotProd.map((ad, i) => toArrow(negVector, draft.intersections[1], ad, i), ), }; return { line, newHoveredArrows }; } /** * Calculates the detailed information about a single arrow indicator, enough to be able to render. * @param piece * @param vector - A vector pointing TOWARD the piece (from screen edge outward). Used for adjacent-picture offsets and click transitions. * @param intersection - The intersection with the screen window that the line the piece is on intersects. * @param stackIndex - If there are adjacent pictures, this may be > 0 * @param worldHalfWidth * @param pointerWorlds - A list of all world coordinates every existing pointer is over. */ export function processPiece( piece: ArrowPiece, vector: Vec2, intersection: BDCoords, stackIndex: number, worldHalfWidth: number, pointerWorlds: DoubleCoords[], ): Arrow { const renderCoords = intersection; // Don't think we need to deep copy? const worldLocation: DoubleCoords = space.convertCoordToWorldSpace_IgnoreSquareCenter(renderCoords); // If this picture is a stacked picture, adjust it's positioning if (stackIndex > 0) { const scale = boardpos.getBoardScaleAsNumber(); worldLocation[0] += Number(vector[0]) * stackIndex * STACK_PADDING * scale; worldLocation[1] += Number(vector[1]) * stackIndex * STACK_PADDING * scale; } // Does the mouse hover over the piece? let hovered = false; for (const pointerWorld of pointerWorlds) { const chebyshevDist = vectors.chebyshevDistanceDoubles(worldLocation, pointerWorld); if (chebyshevDist < worldHalfWidth) hovered = true; // Mouse inside the picture bounding box } // Teleports toward the given piece if its arrow indicator is clicked this frame. transitionTowardTargetIfClicked(piece.coords, vector, worldLocation, worldHalfWidth); const direction = vectors.negateVector(vector); return { worldLocation, piece, hovered, opacity: OPACITY, direction, stackIndex }; } /** * If a recognized click falls within worldHalfWidth of * worldLocation, claims it and pans towards the target coordinates. */ function transitionTowardTargetIfClicked( targetCoords: BDCoords, direction: Vec2, worldLocation: DoubleCoords, worldHalfWidth: number, ): void { let button: MouseButton; let listener: typeof mouse | InputListener; // Left mouse button if (mouse.isMouseClicked(Mouse.LEFT)) { button = Mouse.LEFT; listener = mouse; } // Finger simulating right mouse down (annotations mode ON) else if ( listener_overlay.isMouseClicked(Mouse.RIGHT) && listener_overlay.isMouseTouch(Mouse.RIGHT) ) { button = Mouse.RIGHT; listener = listener_overlay; } else return; // No recognized click const clickWorld = mouse.getMouseWorld(button); if (!clickWorld) return; // Maybe we're looking into sky? if (vectors.chebyshevDistanceDoubles(worldLocation, clickWorld) >= worldHalfWidth) return; // Mouse is inside the picture bounding box... listener.claimMouseClick(button); // Don't let annotations erase/draw // Pan along parallel direction to the perpendicular foot of targetCoords, NOT straight to the piece. const startCoords = boardpos.getBoardPos(); // The direction we will follow when teleporting const line1GeneralForm = vectors.getLineGeneralFormFromCoordsAndVecBD(startCoords, direction); // The line perpendicular to the target piece === The Normal const perpendicularSlideDir: Vec2 = vectors.getPerpendicularVector(direction); const line2GeneralForm = vectors.getLineGeneralFormFromCoordsAndVecBD( targetCoords, perpendicularSlideDir, ); // The target teleport coords const telCoords = geometry.calcIntersectionPointOfLinesBD( ...line1GeneralForm, ...line2GeneralForm, )!; // We know it will be defined because they are PERPENDICULAR Transition.startPanTransition(telCoords, false); } // Hint Arrows ------------------------------------------------------------------------- /** * Computes hint arrows for the current frame. * For each off-screen square returned by {@link movehints.getSquares}, * creates a hint arrow at the nearest screen edge pointing toward that square. * * Respects the zoom threshold but ignores the current arrow mode, * so hint arrows are visible even when mode is 0 (off). */ function updateHintArrows(): HintArrow[] { const hintSquares = movehints.getSquares(); if (hintSquares.length === 0) return []; const pieceCoords = movehints.getPieceCoords()!; const worldHalfWidth = getArrowIndicatorHalfWidth(); const pointerWorlds = mouse.getAllPointerWorlds(); const newHintArrows: HintArrow[] = []; for (const hintSquare of hintSquares) { const hintSquareBD = bdcoords.FromCoords(hintSquare); // Skip if the hint square is already visible on screen if (bounds.boxContainsSquare(boundingBoxInt!, hintSquare)) continue; // Direction from the selected piece toward the hint square const difference = coordutil.subtractCoords(hintSquare, pieceCoords); let direction: Vec2 = vectors.normalizeVector(difference); // Calculate the world space position of the near-side screen edge intersection // along the line from the piece to the hint square. const intersections = geometry.findLineBoxIntersectionsBD( hintSquareBD, direction, boundingBoxFloat!, ); if (intersections.length < 2) continue; // Arrow line does not intersect screen. const nearSide = intersections[0]!.positiveDotProduct ? intersections[0]!.coords : intersections[1]!.coords; const worldLocation = space.convertCoordToWorldSpace_IgnoreSquareCenter(nearSide); // If we've panned past the hint square, flip the triangle so it still points toward the square if (intersections[0]!.positiveDotProduct) direction = vectors.negateVector(direction); // Whether any pointer is within worldHalfWidth of the given world location. const hovered = pointerWorlds.some( (p) => vectors.chebyshevDistanceDoubles(worldLocation, p) < worldHalfWidth, ); // Prevent dragging the board when clicking on the move hint arrow. if (hovered && mouse.isMouseDown(Mouse.LEFT)) mouse.claimMouseDown(Mouse.LEFT); transitionTowardTargetIfClicked(hintSquareBD, direction, worldLocation, worldHalfWidth); newHintArrows.push({ worldLocation, direction, targetSquare: hintSquare, hovered }); } return newHintArrows; } // Exports ----------------------------------------------------------------------------- export default { // Constants OPACITY, // Getters getBoundingBoxFloat, areZoomedInEnoughForArrows, getArrowIndicatorHalfWidth, // Main entry point calculateArrows, // Arrow draft generation calcArrowsLineDraft, // Mode-based filtering isAnimatedArrowUnnecessary, getSlideExceptions, removeTypesThatCantSlideOntoScreenFromLineDraft, // Finalizing arrows convertLineDraftToLine, processPiece, }; ================================================ FILE: src/client/scripts/esm/game/rendering/arrows/arrowsgraphics.ts ================================================ // src/client/scripts/esm/game/rendering/arrows/arrowsgraphics.ts /** * This script renders all arrow indicators on the screen edges, * including piece indicators (pointing to off-screen pieces) * and hint arrows (pointing to off-screen legal move squares). */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Arrow, ArrowsLine } from './arrows.js'; import type { AttributeInfoInstanced } from '../../../webgl/Renderable.js'; import vectors from '../../../../../../shared/util/math/vectors.js'; import arrows from './arrows.js'; import meshes from '../meshes.js'; import primitives from '../primitives.js'; import preferences from '../../../components/header/preferences.js'; import drawsquares from '../highlights/annotations/drawsquares.js'; import texturecache from '../../../chess/rendering/texturecache.js'; import instancedshapes from '../instancedshapes.js'; import arrowscalculator from './arrowscalculator.js'; import { createRenderable_Instanced, createRenderable_Instanced_GivenInfo, } from '../../../webgl/Renderable.js'; // Constants --------------------------------------------------------------------------- /** The size of arrow triangles as a fraction of the arrow indicator half-width. */ export const ARROW_SIZE_RATIO = 0.3; /** Attribute layout for the instanced piece-image renderable. */ const ATTRIB_INFO_PICTURES: AttributeInfoInstanced = { vertexDataAttribInfo: [ { name: 'a_position', numComponents: 2 }, { name: 'a_texturecoord', numComponents: 2 }, ], instanceDataAttribInfo: [ { name: 'a_instanceposition', numComponents: 2 }, { name: 'a_instancecolor', numComponents: 4 }, ], }; /** Attribute layout for the instanced arrow-triangle renderable. */ const ATTRIB_INFO_ARROWS: AttributeInfoInstanced = { vertexDataAttribInfo: [{ name: 'a_position', numComponents: 2 }], instanceDataAttribInfo: [ { name: 'a_instanceposition', numComponents: 2 }, { name: 'a_instancecolor', numComponents: 4 }, { name: 'a_instancerotation', numComponents: 1 }, ], }; // Functions --------------------------------------------------------------------------- /** Renders all the arrow indicators for this frame. */ export function render(): void { const slideArrows = arrows.getSlideArrows(); const animatedArrows = arrows.getAnimatedArrows(); const hintArrows = arrows.getHintArrows(); const worldHalfWidth = arrowscalculator.getArrowIndicatorHalfWidth(); if ( Object.keys(slideArrows).length === 0 && animatedArrows.length === 0 && hintArrows.length === 0 ) return; // No visible arrows, don't generate the model // Position data of the single quad instance const left = -worldHalfWidth; const right = worldHalfWidth; const bottom = -worldHalfWidth; const top = worldHalfWidth; // Texture data of the single quad instance const { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords(); // Initialize the data arrays... const vertexData_Pictures: number[] = primitives.Quad_Texture(left, bottom, right, top, texleft, texbottom, texright, textop); // prettier-ignore /** Maps each piece type to its list of instance data (position + color per instance). */ const instanceDataByType = new Map(); const vertexData_Arrows: number[] = getVertexDataOfArrow(worldHalfWidth); const instanceData_Arrows: number[] = []; // Add the data... for (const linesOfDirection of Object.values(slideArrows)) { for (const line of Object.values(linesOfDirection) as ArrowsLine[]) { for (const arrow of line.posDotProd) concatData(instanceDataByType, instanceData_Arrows, arrow); for (const arrow of line.negDotProd) concatData(instanceDataByType, instanceData_Arrows, arrow); } } for (const arrow of animatedArrows) { concatData(instanceDataByType, instanceData_Arrows, arrow); } // Render hint squares first (below piece images) if (hintArrows.length > 0) { const hintColor = preferences.getLegalMoveHighlightColor({ isOpponentPiece: false, isPremove: false, }); const size = worldHalfWidth * 2; // Green squares at screen edge for each hint arrow const hintSquaresInstanceData: number[] = hintArrows.flatMap((ha) => ha.worldLocation); createRenderable_Instanced( instancedshapes.getDataLegalMoveSquare(hintColor), hintSquaresInstanceData, 'TRIANGLES', 'highlights', true, ).render(undefined, undefined, { u_size: size }); // Re-render hovered hint squares at increased opacity on top const hoveredHintSquaresInstanceData: number[] = hintArrows.filter((ha) => ha.hovered).flatMap((ha) => ha.worldLocation); // prettier-ignore if (hoveredHintSquaresInstanceData.length > 0) { const hoveredHintColor: Color = [...hintColor]; hoveredHintColor[3] = drawsquares.HOVER_OPACITY; createRenderable_Instanced( instancedshapes.getDataLegalMoveSquare(hoveredHintColor), hoveredHintSquaresInstanceData, 'TRIANGLES', 'highlights', true, ).render(undefined, undefined, { u_size: size }); } // Append hint direction triangles into the shared arrow triangle array for (const ha of hintArrows) { const dirAsDoubles = vectors.convertVectorToDoubles(ha.direction); const angle = Math.atan2(dirAsDoubles[1], dirAsDoubles[0]); const a = ha.hovered ? 1 : arrowscalculator.OPACITY; instanceData_Arrows.push(...ha.worldLocation, 0, 0, 0, a, angle); } } // Render piece images for regular (piece) arrow indicators, one draw call per type. for (const [type, instanceData] of instanceDataByType) { createRenderable_Instanced_GivenInfo( vertexData_Pictures, instanceData, ATTRIB_INFO_PICTURES, 'TRIANGLES', 'arrowImages', [{ texture: texturecache.getTexture(type), uniformName: 'u_sampler' }], ).render(); } // Render all arrow direction triangles (regular piece arrows + hint arrows) together if (instanceData_Arrows.length > 0) { createRenderable_Instanced_GivenInfo( vertexData_Arrows, instanceData_Arrows, ATTRIB_INFO_ARROWS, 'TRIANGLES', 'arrows', ).render(); } } /** * Takes a piece arrow, appends its picture instance data into the per-type map * and (if not stacked) appends its triangle instance data to the arrows array. */ function concatData( instanceDataByType: Map, instanceData_Arrows: number[], arrow: Arrow, ): void { /** * Our pictures' instance data needs to contain: * * position offset (2 numbers) * unique color (4 numbers) */ const a = arrow.hovered ? 1 : arrow.opacity; let typeData = instanceDataByType.get(arrow.piece.type); if (typeData === undefined) { typeData = []; instanceDataByType.set(arrow.piece.type, typeData); } typeData.push(...arrow.worldLocation, 1, 1, 1, a); // Next append the data of the little arrow! if (arrow.stackIndex > 0) return; // We can skip, since it is a stacked picture! Each stack gets just one arrow. /** * Our arrow's instance data needs to contain: * * position offset (2 numbers) * unique color (4 numbers) * rotation offset (1 number) */ const dirAsDoubles = vectors.convertVectorToDoubles(arrow.direction); const angle = Math.atan2(dirAsDoubles[1], dirAsDoubles[0]); // Y value first // position color rotation instanceData_Arrows.push(...arrow.worldLocation, 0, 0, 0, a, angle); } /** * Returns the vertex data of a single arrow instance, * for this frame, only containing positional information. * @param halfWorldWidth - Half of the width of the arrow indicators for the current frame (dependant on scale). */ function getVertexDataOfArrow(halfWorldWidth: number): number[] { const size = halfWorldWidth * ARROW_SIZE_RATIO; // prettier-ignore return [ halfWorldWidth, -size, halfWorldWidth, size, halfWorldWidth + size, 0, ]; } // Exports ----------------------------------------------------------------------------- export default { // Frame lifecycle render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/arrows/arrowshifts.ts ================================================ // src/client/scripts/esm/game/rendering/arrows/arrowshifts.ts /** * This script manages mid-frame arrow modifications (arrow shifts). * * Other scripts call deleteArrow(), moveArrow(), animateArrow(), and addArrow() * between the arrows update() and render() calls. Those "shifts" are then * applied all at once by executeArrowShifts() before rendering. */ import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { Change } from '../../../../../../shared/chess/logic/boardchanges.js'; import type { Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import type { FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Arrow, ArrowPiece, SlideArrows } from './arrows.js'; import type { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import bd from '@naviary/bigdecimal'; import bounds from '../../../../../../shared/util/math/bounds.js'; import vectors from '../../../../../../shared/util/math/vectors.js'; import geometry from '../../../../../../shared/util/math/geometry.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import boardutil from '../../../../../../shared/chess/util/boardutil.js'; import boardchanges from '../../../../../../shared/chess/logic/boardchanges.js'; import organizedpieces from '../../../../../../shared/chess/logic/organizedpieces.js'; import mouse from '../../../util/mouse.js'; import arrows from './arrows.js'; import gameslot from '../../chess/gameslot.js'; import arrowscalculator from './arrowscalculator.js'; // Types ---------------------------------------------------- /** An Arrow Shift/Modification. */ type Shift = | { kind: 'delete'; start: Coords; } | { kind: 'move'; start: Coords; end: Coords; } | { kind: 'animate'; start: Coords; end: BDCoords; type: number; } | { kind: 'add'; type: number; end: Coords; }; // Constants ---------------------------------------------- const ONE = bd.fromBigInt(1n); // State -------------------------------------------------- /** * A list of arrow modifications made by other * scripts after update() and before render(). */ let shifts: Shift[] = []; // Functions ------------------------------------------------------------- /** Clears the pending shifts list. Called from arrows.reset() at the start of each frame. */ export function reset(): void { shifts.length = 0; } /** * Piece deleted from start coords * => Arrow line recalculated */ export function deleteArrow(start: Coords): void { if (!arrows.areArrowsActiveThisFrame()) return; overwriteArrows(start); shifts.push({ kind: 'delete', start }); } /** * Piece deleted on start coords and added on end coords * => Arrow lines recalculated */ export function moveArrow(start: Coords, end: Coords): void { if (!arrows.areArrowsActiveThisFrame()) return; overwriteArrows(start); shifts.push({ kind: 'move', start, end }); } /** * Piece deleted on start coords. Uniquely animate arrow on floating point end coords. * => Recalculate start coords arrow lines. * @param start * @param end - Floating point coords of the current animation position * @param type - The piece type, so we know what type of piece the arrow should be. * We CANNOT just read the type of piece at the destination square, because * the piece is not guaranteed to be there. In Atomic Chess, the piece can * move, and then explode itself, leaving its destination square empty. */ export function animateArrow(start: Coords, end: BDCoords, type: number): void { if (!arrows.areArrowsActiveThisFrame()) return; overwriteArrows(start); shifts.push({ kind: 'animate', start, end, type }); } /** * Piece added on end coords. * => Arrow lines recalculated */ export function addArrow(type: number, end: Coords): void { if (!arrows.areArrowsActiveThisFrame()) return; shifts.push({ kind: 'add', type, end }); } /** * Erases existing arrow shifts that should be overwritten by the new arrow. * Should only be called when shifting a new arrow. */ function overwriteArrows(start: Coords): void { /** * For each previous shift, if either their start or end * is on this start (deletion coords), then delete it! * * check to see if the start is the same as this end coords. * If so, replace that shift with a delete action, and retain the same order. */ shifts = shifts.filter((shift) => { // All shift kinds with a `start` property if (shift.kind === 'delete' || shift.kind === 'move' || shift.kind === 'animate') { if (coordutil.areCoordsEqual(shift.start, start)) return false; // Filter } // All shift kinds with a Coords `end` property. if (shift.kind === 'move' || shift.kind === 'add') { if (coordutil.areCoordsEqual(shift.end, start)) return false; // Filter } return true; // Pass }); } /** Execute any pending arrow shift modifications. */ export function executeArrowShifts(): void { const slideArrows = arrows.getSlideArrows(); const animatedArrows = arrows.getAnimatedArrows(); const mode = arrows.getMode(); const gamefile = gameslot.getGamefile()!; const changes: Change[] = []; const worldHalfWidth = arrowscalculator.getArrowIndicatorHalfWidth(); const pointerWorlds = mouse.getAllPointerWorlds(); const slideExceptions = arrowscalculator.getSlideExceptions(mode); shifts.forEach((shift) => { if (shift.kind === 'delete') { deletePiece(shift.start); } else if (shift.kind === 'add') { addPiece(shift.type, shift.end); // Add the piece to the gamefile, so that we can calculate the arrow lines correctly } else if (shift.kind === 'move') { const type = deletePiece(shift.start); if (type === undefined) throw Error( "Arrow shift: When moving arrow, no piece found at its start coords. Don't know what type of piece to add at the end coords!", ); // If this ever happens, maybe give movePiece a type argument along just as animateArrow() has. addPiece(type, shift.end); } else if (shift.kind === 'animate') { deletePiece(shift.start); // Delete the piece if it is present (may not be if in Atomic Chess it blew itself up) // This is an arrow animation for a piece IN MOTION, not a still animation. // Add an animated arrow for it, since it is gonna be at a floating point coordinate // Only add the arrow if the piece is JUST off-screen. // Add 1 square on each side of the screen box first. const boundingBoxFloat = arrowscalculator.getBoundingBoxFloat()!; const expandedFloatingBox = { left: bd.subtract(boundingBoxFloat.left, ONE), right: bd.add(boundingBoxFloat.right, ONE), bottom: bd.subtract(boundingBoxFloat.bottom, ONE), top: bd.add(boundingBoxFloat.top, ONE), }; // True if its square is at least PARTIALLY visible on screen. // We need no arrows for the animated piece, no matter the vector! if (bounds.boxContainsSquareBD(expandedFloatingBox, shift.end)) return; const piece: ArrowPiece = { type: shift.type, coords: shift.end, index: -1, floating: true, }; // Create a piece object for the arrow // Add an arrow for every applicable direction for (const lineKey of gamefile.boardsim.pieces.lines.keys()) { let line = vectors.getVec2FromKey(lineKey); // prettier-ignore if (arrowscalculator.isAnimatedArrowUnnecessary(gamefile.boardsim, piece.type, line, lineKey, mode)) continue; // Arrow mode isn't high enough, and the piece can't slide in the vector direction // Determine the line's dot product with the screen box. // Flip the vector if need be, to point it in the right direction. const thisPieceIntersections = geometry.findLineBoxIntersectionsBD(piece.coords, line, boundingBoxFloat); // prettier-ignore if (thisPieceIntersections.length < 2) continue; // Slide direction doesn't intersect with screen box, no arrow needed const positiveDotProduct = thisPieceIntersections[0]!.positiveDotProduct; // We know the dot product of both intersections will be identical, because the piece is off-screen. // Negate the vector if it is pointing AWAY from the screen (negative dot product side), // so that `processPiece` always receives a vector pointing TOWARD the piece. if (!positiveDotProduct) line = vectors.negateVector(line); // At what point does it intersect the screen? const intersect = positiveDotProduct ? thisPieceIntersections[0]!.coords : thisPieceIntersections[1]!.coords; const arrow: Arrow = arrowscalculator.processPiece(piece, line, intersect, 0, worldHalfWidth, pointerWorlds); // prettier-ignore animatedArrows.push(arrow); } } }); /** Helper function to delete an arrow's start piece off the board. */ function deletePiece(start: Coords): number | undefined { // Delete the piece from the gamefile, so that we can calculate the arrow lines correctly const originalPiece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, start); if (originalPiece === undefined) return; // The piece may have been blown up by itself. boardchanges.queueDeletePiece(changes, true, originalPiece); return originalPiece.type; } /** Helper function to add an arrow's end piece on the board. */ function addPiece(type: number, end: Coords): void { // Add the piece to the gamefile, so that we can calculate the arrow lines correctly const piece: Piece = { type, coords: end, index: -1 }; boardchanges.queueAddPiece(changes, piece); } // Apply the board changes boardchanges.runChanges(gamefile, changes, boardchanges.changeFuncs, true); // Recalculate the arrow lines for each shift shifts.forEach((shift) => { if (shift.kind === 'delete' || shift.kind === 'move' || shift.kind === 'animate') { // Recalculate the lines through the start coordinate recalculateLinesThroughCoords(slideArrows, gamefile, shift.start, worldHalfWidth, pointerWorlds, slideExceptions); // prettier-ignore } if (shift.kind === 'add' || shift.kind === 'move') { // Recalculate the lines through the end coordinate recalculateLinesThroughCoords(slideArrows, gamefile, shift.end, worldHalfWidth, pointerWorlds, slideExceptions); // prettier-ignore } }); // Restore the board state boardchanges.runChanges(gamefile, changes, boardchanges.changeFuncs, false); } /** * Recalculates all of the arrow lines the given piece * is on, adding them to this frame's list of arrows. */ function recalculateLinesThroughCoords( slideArrows: SlideArrows, gamefile: FullGame, coords: Coords, worldHalfWidth: number, pointerWorlds: DoubleCoords[], slideExceptions: Vec2Key[], ): void { for (const [slideKey, linegroup] of gamefile.boardsim.pieces.lines) { // For each slide direction in the game... const slide = coordutil.getCoordsFromKey(slideKey); const lineKey = organizedpieces.getKeyFromLine(slide, coords); // Delete the original arrow line if it exists if (slideKey in slideArrows) { delete slideArrows[slideKey]![lineKey]; if (Object.keys(slideArrows[slideKey]!).length === 0) delete slideArrows[slideKey]; } // Recalculate the arrow line... // Fetch the organized line that our piece is on this direction. const organizedLine = linegroup.get(lineKey); if (organizedLine === undefined) continue; // No pieces on line, empty const arrowsLineDraft = arrowscalculator.calcArrowsLineDraft(gamefile, slide, slideKey, organizedLine); // prettier-ignore if (arrowsLineDraft === undefined) continue; // Only intersects the corner of our screen, not visible. // Remove Unnecessary arrows... if (!slideExceptions.includes(slideKey)) { arrowscalculator.removeTypesThatCantSlideOntoScreenFromLineDraft(arrowsLineDraft); if (arrowsLineDraft.negDotProd.length === 0 && arrowsLineDraft.posDotProd.length === 0) continue; // No more pieces on this line } const { line } = arrowscalculator.convertLineDraftToLine(arrowsLineDraft, slide, slideKey, worldHalfWidth, pointerWorlds, false); // prettier-ignore slideArrows[slideKey] = slideArrows[slideKey] ?? {}; // Make sure this exists first. slideArrows[slideKey][lineKey] = line; } } // Exports ----------------------------------------------------------------------------- export default { // State management reset, // Queuing modifications deleteArrow, moveArrow, animateArrow, addArrow, // Executing modifications executeArrowShifts, }; ================================================ FILE: src/client/scripts/esm/game/rendering/boarddrag.ts ================================================ // src/client/scripts/esm/game/rendering/boarddrag.ts /** * This script handles the dragging of the board, * and throwing it after letting go. */ import type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import vectors from '../../../../../shared/util/math/vectors.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import mouse from '../../util/mouse.js'; import boardpos from './boardpos.js'; import drawrays from './highlights/annotations/drawrays.js'; import keybinds from '../misc/keybinds.js'; import selection from '../chess/selection.js'; import Transition from './transitions/Transition.js'; import drawarrows from './highlights/annotations/drawarrows.js'; import perspective from './perspective.js'; import etoolmanager from '../boardeditor/tools/etoolmanager.js'; import guipromotion from '../gui/guipromotion.js'; import { listener_overlay } from '../chess/game.js'; // Types ------------------------------------------------------------- /** * A board position/scale entry, used for calculating its velocity * for throwing the board after dragging it. */ interface PositionHistoryEntry { time: number; boardPos: BDCoords; boardScale: BigDecimal; } // Variables ------------------------------------------------------------- /** Whether we currently dragging the board */ let boardIsGrabbed: boolean = false; /** Equal to the board scale the moment a 2nd finger touched down. (pinching the board) */ let scale_WhenBoardPinched: BigDecimal | undefined; /** Equal to the distance between 2 fingers the moment they touched down. (pinching the board) */ let fingerPixelDist_WhenBoardPinched: number | undefined; /** The ID of the first pointer that grabbed the board */ let pointer1Id: string | undefined; /** The ID of the second pointer that grabbed the board */ let pointer2Id: string | undefined; /** What coordinates 1 finger has grabbed the board, if it has. */ let pointer1BoardPosGrabbed: BDCoords | undefined; /** What coordinates a 2nd finger has grabbed the board, if it has. */ let pointer2BoardPosGrabbed: BDCoords | undefined; /** Stores past board positions from the last few frames. Used to calculate throw velocity after dragging. */ const positionHistory: PositionHistoryEntry[] = []; const positionHistoryWindowMillis: number = 80; // The amount of milliseconds to look back into for board velocity calculation. // Functions ------------------------------------------------------------------- /** Whether the board is currently being dragged by one or more pointers. */ function isBoardDragging(): boolean { return boardIsGrabbed; } /** * Returns the ids of all pointers that started pressing down this frame * that are capable of dragging the board. That is: * A. Left mouse button pointers * B. Touch pointers */ function getBoardDraggablePointersDown(): string[] { const mouseKeybind = keybinds.getBoardDragMouseButton(); if (mouseKeybind === undefined) return []; // Prevent duplicates by using a Set return [ ...new Set([ ...listener_overlay.getPointersDown(mouseKeybind), ...listener_overlay.getTouchPointersDown(), ]), ]; } /** * Returns the ids of all existing pointers that are capable of dragging the board. That is: * A. Left mouse button pointers * B. Touch pointers */ function getBoardDraggablePointers(): string[] { const mouseKeybind = keybinds.getBoardDragMouseButton(); if (mouseKeybind === undefined) return []; // Prevent duplicates by using a Set return [ ...new Set([ ...listener_overlay.getAllPointers(mouseKeybind), ...listener_overlay.getAllTouchPointers(), ]), ]; } /** * Checks if the board needs to PINCHED (DOUBLE-GRABBED) by any new pointers presssed down this frame. * Will NOT initiate a single-pointer grab! */ function checkIfBoardPinched(): void { if (perspective.getEnabled() || Transition.areTransitioning() || guipromotion.isUIOpen()) return; if (pointer2Id !== undefined) return; // Already pinched // All existing pointers that are either left mouse button, or a touch. May have been previously claimed (steal). const allExistingPointers = getBoardDraggablePointers(); // This allows us to still start a pinch if we: // A. Are dragging a piece or drawing an annote with one finger. // B. Then put down a second finger to pinch the board. // Desired behavior: Terminate the drag/annote, and start pinching the board. if (!boardIsGrabbed && allExistingPointers.length < 2) return; // Not grabbed, and not enough pointers to pinch. // We know we have enough pointers to pinch the board! // If one of the pointers happens to already be in use, steal it! // All pointers pressed down this frame that are either left mouse button, or a touch const allPointersDown = getBoardDraggablePointersDown(); // For every existing pointer... (STEAL) for (const pointerId of allExistingPointers) { if (allPointersDown.includes(pointerId)) continue; // This pointer will be handled in the next loop, where it will also be claimed. // This pointer may have been claimed elsewhere, STEAL it. if (!boardIsGrabbed) { initSinglePointerDrag(pointerId); stealPointer(pointerId); } } // For every new pointer touched down / created this frame... for (const pointerId of allPointersDown) { if (!boardIsGrabbed) { listener_overlay.claimPointerDown(pointerId); // Remove the pointer down so other scripts don't use it initSinglePointerDrag(pointerId); } else if (pointer2Id === undefined) { listener_overlay.claimPointerDown(pointerId); // Remove the pointer down so other scripts don't use it initDoublePointerDrag(pointerId); } } } /** Checks if the board needs to SINGLE-GRABBED by any new pointers pressed down this frame. */ function checkIfBoardSingleGrabbed(): void { if (perspective.getEnabled() || Transition.areTransitioning() || guipromotion.isUIOpen()) return; if (boardIsGrabbed) return; // Already grabbed // All pointers down that are either left mouse button, or a touch const allPointersDown = getBoardDraggablePointersDown(); if (allPointersDown.length === 0) return; // No pointers down listener_overlay.claimPointerDown(allPointersDown[0]!); // Remove the pointer down so other scripts don't use it initSinglePointerDrag(allPointersDown[0]!); // If multiple pointers down, just use the first one. } /** * If the given pointer has been claimed by something else (piece dragging, arrow/ray drawing, etc), * this will STEAL it from them, so that it can be used for board pinching, which takes priority. * Essentially this just tells them to stop using it. */ function stealPointer(pointerId: string): void { selection.stealPointer(pointerId); drawarrows.stealPointer(pointerId); drawrays.stealPointer(pointerId); etoolmanager.stealPointer(pointerId); } /** Grabs board with the given pointer. */ function initSinglePointerDrag(pointerId: string): void { // console.log('Board grabbed with pointer', pointerId); pointer1Id = pointerId; pointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!; // console.log('pointer1BoardPosGrabbed', pointer1BoardPosGrabbed); boardIsGrabbed = true; boardpos.setPanVel([0, 0]); // Erase all momentum addCurrentPositionToHistory(); } /** Pinches board with given 2nd pointer. */ function initDoublePointerDrag(pointerId: string): void { // Cannot pinch with the same pointer. // This can happen in board editor if you drag board with right mouse, // drag offscreen, let go, then right click to drag board again. if (pointer1Id === pointerId) return; // Pixel distance const p1Pos = listener_overlay.getPointerPos(pointer1Id!)!; const p2Pos = listener_overlay.getPointerPos(pointerId)!; const dist = vectors.euclideanDistanceDoubles(p1Pos, p2Pos); if (dist === 0) { // Error gracefully. Allows a rare bug where some users mouse // makes two identical pointer down events in the same frame. console.error('Finger pixel dist is 0. Skipping pinch.'); return; } // console.log('Board pinched with pointer', pointerId); pointer2Id = pointerId; pointer2BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer2Id!)!; fingerPixelDist_WhenBoardPinched = dist; // Scale scale_WhenBoardPinched = boardpos.getBoardScale(); } /** * Checks if any of the pointers that are currenlty dragging the board * have been released, or no longer exist. If so, throw the board and cancel the drag. */ function checkIfBoardDropped(): void { if (!boardIsGrabbed) return; // Not grabbed const now = Date.now(); // All existing pointers that are either left mouse button, or a touch const allPointers = getBoardDraggablePointers(); const pointer1Released = !allPointers.includes(pointer1Id!); if (pointer2Id === undefined) { // 1 finger drag if (pointer1Released) { // Finger has been released throwBoard(now); cancelBoardDrag(); } // else still one finger holding the board } else { // 2 finger drag const pointer2Released = !allPointers.includes(pointer2Id); if (!pointer1Released && !pointer2Released) return; // Both fingers are still holding the board throwScale(now); if (pointer1Released && pointer2Released) { // Both fingers have been released throwBoard(now); cancelBoardDrag(); } else { // Only one finger has been released if (pointer2Released) { // Only Pointer 2 released pointer2Id = undefined; pointer2BoardPosGrabbed = undefined; // Recalculate pointer 1's grab position pointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!; } else if (pointer1Released) { // Only Pointer 1 released // Make pointer2 pointer1 pointer1Id = pointer2Id; // Recalculate pointer 2's grab position pointer1BoardPosGrabbed = mouse.getTilePointerOver_Float(pointer1Id!)!; pointer2Id = undefined; pointer2BoardPosGrabbed = undefined; } else throw Error('Umm how did we get here?'); scale_WhenBoardPinched = undefined; fingerPixelDist_WhenBoardPinched = undefined; } } } /** Forcefully terminates a board drag WITHOUT throwing the board. */ function cancelBoardDrag(): void { boardIsGrabbed = false; pointer1Id = undefined; pointer2Id = undefined; pointer1BoardPosGrabbed = undefined; pointer2BoardPosGrabbed = undefined; scale_WhenBoardPinched = undefined; fingerPixelDist_WhenBoardPinched = undefined; /** Clears the list of past positions. Call this to prevent teleportation giving momentum.*/ positionHistory.length = 0; } /** Called after letting go of the board. Applies velocity to the board according to how fast the mouse was moving */ function throwBoard(time: number): void { removeOldPositions(time); if (positionHistory.length < 2) return; const firstBoardState = positionHistory[0]!; const lastBoardState = positionHistory[positionHistory.length - 1]!; const deltaX = bd.subtract(lastBoardState.boardPos[0], firstBoardState.boardPos[0]); const deltaY = bd.subtract(lastBoardState.boardPos[1], firstBoardState.boardPos[1]); const deltaT = bd.fromNumber((lastBoardState.time - firstBoardState.time) / 1000); if (bd.isZero(deltaT)) return; // Prevent division by zero const boardScale = lastBoardState.boardScale; const newPanVel: DoubleCoords = [ bd.toNumber(bd.multiply(bd.divide(deltaX, deltaT), boardScale)), bd.toNumber(bd.multiply(bd.divide(deltaY, deltaT), boardScale)), ]; // console.log('Throwing board with velocity', newPanVel); boardpos.setPanVel(newPanVel); } /** * Called after letting go of the board with a second finger. Applies scale * velocity to the board according to how fast the fingers were pinching */ function throwScale(time: number): void { removeOldPositions(time); if (positionHistory.length < 2) return; const firstBoardState = positionHistory[0]!; const lastBoardState = positionHistory[positionHistory.length - 1]!; const ratio = bd.toNumber( bd.divideFloating(lastBoardState.boardScale, firstBoardState.boardScale), ); const deltaTime = (lastBoardState.time - firstBoardState.time) / 1000; if (deltaTime === 0) return; // Prevent division by zero boardpos.setScaleVel((ratio - 1) / deltaTime); } /** Called if the board is being dragged, calculates the new board position. */ function dragBoard(): void { if (!boardIsGrabbed) return; // Calculate new board position... if (pointer2Id === undefined) { // 1 Finger drag const mouseWorld = bdcoords.FromDoubleCoords(mouse.getPointerWorld(pointer1Id!)!); // console.log('Mouse world', mousePos); /** * worldCoordsX / boardScale + boardPosX = mouseCoordsX * worldCoordsY / boardScale + boardPosY = mouseCoordsY * * Solve for boardPosX & boardPosY: * * boardPosX = mouseCoordsX - worldCoordsX / boardScale * boardPosY = mouseCoordsY - worldCoordsY / boardScale */ const boardScale = boardpos.getBoardScale(); const newBoardPos: BDCoords = [ // negate and add pointer1BoardPosGrabbed instead of flipped, because we don't need high precision here. bd.add(bd.negate(bd.divide(mouseWorld[0], boardScale)), pointer1BoardPosGrabbed![0]), bd.add(bd.negate(bd.divide(mouseWorld[1], boardScale)), pointer1BoardPosGrabbed![1]), ]; boardpos.setBoardPos(newBoardPos); } else { // 2 Fingers grab/pinch (center the board position, & calculate scale) const pointer1Pos = listener_overlay.getPointerPos(pointer1Id!)!; const pointer2Pos = listener_overlay.getPointerPos(pointer2Id!)!; const pointer1World = mouse.convertMousePositionToWorldSpace( pointer1Pos, listener_overlay.element, ); const pointer2World = mouse.convertMousePositionToWorldSpace( pointer2Pos, listener_overlay.element, ); // Calculate the new scale by comparing the touches current distance in pixels to their distance when they first started pinching const thisPixelDist = vectors.euclideanDistanceDoubles(pointer1Pos, pointer2Pos); const ratio = bd.fromNumber(thisPixelDist / fingerPixelDist_WhenBoardPinched!); const newScale = bd.multiplyFloating(scale_WhenBoardPinched!, ratio); boardpos.setBoardScale(newScale); /** * For calculating the new board position, treat the two fingers * as one finger dragging from the midpoint between them. */ const midCoords: BDCoords = coordutil.lerpCoords( pointer1BoardPosGrabbed!, pointer2BoardPosGrabbed!, 0.5, ); const midPosWorld: BDCoords = bdcoords.FromDoubleCoords( coordutil.lerpCoordsDouble(pointer1World, pointer2World, 0.5), ); const newBoardPos: BDCoords = [ // negate and add midCoords instead of flipped, because we don't need high precision here. bd.add(bd.negate(bd.divide(midPosWorld[0], newScale)), midCoords[0]), bd.add(bd.negate(bd.divide(midPosWorld[1], newScale)), midCoords[1]), ]; boardpos.setBoardPos(newBoardPos); } addCurrentPositionToHistory(); } /** * Adds the board's current position and scale to its history. * Used for calculating the velocity of the board after letting go. * * History is only kept track of while dragging. */ function addCurrentPositionToHistory(): void { const now = Date.now(); removeOldPositions(now); positionHistory.push({ time: now, boardPos: boardpos.getBoardPos(), boardScale: boardpos.getBoardScale(), }); } /** * Removes all positions from the history that are older than the * positionHistoryWindowMillis. */ function removeOldPositions(now: number): void { const earliestTime = now - positionHistoryWindowMillis; while (positionHistory.length > 0 && positionHistory[0]!.time < earliestTime) positionHistory.shift(); } // Exports ------------------------------------------------------------ export default { isBoardDragging, checkIfBoardPinched, checkIfBoardSingleGrabbed, dragBoard, checkIfBoardDropped, cancelBoardDrag, }; ================================================ FILE: src/client/scripts/esm/game/rendering/boardpos.ts ================================================ // src/client/scripts/esm/game/rendering/boardpos.ts /** * This script stores the board position and scale, * and updates them according to their velocity. */ import type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../../../../shared/util/jsutil.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import camera from './camera.js'; import guipause from '../gui/guipause.js'; import Transition from './transitions/Transition.js'; import perspective from './perspective.js'; import loadbalancer from '../misc/loadbalancer.js'; import frametracker from './frametracker.js'; // BigDecimal Constants --------------------------------------------------- const ZERO = bd.fromBigInt(0n); const ONE = bd.fromBigInt(1n); // Variables ------------------------------------------------------------- /** * The position of the board in front of the camera. * The camera never moves, only the board beneath it. * A positon of [0,0] places the [0,0] square in the center of the screen. */ let boardPos: BDCoords = bdcoords.FromCoords([0n, 0n]); // Coordinates /** The current board panning velocity. */ let panVel: DoubleCoords = [0, 0]; /** * The current board scale (zoom). * Higher => zoomed IN * Lower => zoomed OUT */ let boardScale: BigDecimal = bd.fromBigInt(1n); // Default: 1 /** The current board scale (zoom) velocity. */ let scaleVel: number = 0; /** The hypotenuse of the x & y pan velocities cannot exceed this value in 2D mode. */ const panVelCap2D = 22.0; // Default: 22 /** The hypotenuse of the x & y pan velocities cannot exceed this value in 3D mode. */ const panVelCap3D = 16.0; // Default: 16 /** The furthest we can be zoomed IN. */ const maximumScale = bd.fromBigInt(5n); // Default: 5.0 const limitToDampScale = 0.000_01; // We need to soft limit the scale so the game doesn't break // Getters ------------------------------------------------------- function getBoardPos(): BDCoords { return coordutil.copyBDCoords(boardPos); } function getBoardScale(): BigDecimal { return bd.clone(boardScale); } /** * Call when you are CONFIDENT we are zoomed in enough that our scale * can be represented as a javascript number without overflowing to * Infinity or underflowing to 0. * * Typically used for graphics calculations, as the arithmetic * is faster than using BigDecimals. */ function getBoardScaleAsNumber(): number { return bd.toNumber(boardScale); } function getPanVel(): DoubleCoords { return [...panVel]; // Copies } function getRelativePanVelCap(): number { return perspective.getEnabled() ? panVelCap3D : panVelCap2D; } function getScaleVel(): number { return scaleVel; } function glimitToDampScale(): number { return limitToDampScale; } // Setters ---------------------------------------------------------------------------------------- function setBoardPos(newPos: BDCoords): void { // Enforce fixed point model. Catches bugs during development. if (!bd.hasDefaultPrecision(newPos[0])) throw Error( `Cannot set board position X to [${newPos[0].divex}] ${bd.toApproximateString(newPos[0])}. Does not have default precision.`, ); if (!bd.hasDefaultPrecision(newPos[1])) throw Error( `Cannot set board position Y to [${newPos[1].divex}] ${bd.toApproximateString(newPos[1])}. Does not have default precision.`, ); // console.log(`New board position [${(boardPos[0].divex)},${boardPos[1].divex}]`, coordutil.stringifyBDCoords(boardPos)); boardPos = jsutil.deepCopyObject(newPos); // Copy frametracker.onVisualChange(); } function setBoardScale(newScale: BigDecimal): void { if (bd.compare(newScale, ZERO) <= 0) return console.error(`Cannot set scale to a negative: ${bd.toApproximateString(newScale)}`); // console.error("New scale:", bd.toApproximateString(newScale)); // Cap the scale if (bd.compare(newScale, maximumScale) > 0) { newScale = maximumScale; scaleVel = 0; // Cut the scale momentum immediately } boardScale = newScale; frametracker.onVisualChange(); } function setPanVel(newPanVel: DoubleCoords): void { if (isNaN(newPanVel[0]) || isNaN(newPanVel[1])) return console.error(`Cannot set panVel to ${newPanVel}!`); // Can't enforce a cap, as otherwise we wouldn't // be able to throw the board as fast as possible. panVel = [...newPanVel]; } function setScaleVel(newScaleVel: number): void { if (isNaN(newScaleVel)) return console.error(`Cannot set scaleVel to ${newScaleVel}!`); if (Math.abs(newScaleVel) >= 100) console.warn(`Very large scaleVel: (${newScaleVel})`); scaleVel = newScaleVel; } // Other Utility -------------------------------------------------------- /** Erases all board pan & scale velocity. */ function eraseMomentum(): void { panVel = [0, 0]; scaleVel = 0; } function boardHasMomentum(): boolean { return panVel[0] !== 0 || panVel[1] !== 0; } /** * We are considered "zoomed out" if every tile is smaller than one virtual pixel. * If so, the game has very different behavior, such as: * * Legal moves highlights and Ray annotations rendering as highlight lines. * * Pieces rendering as mini-images. * * Annotations rendered at a fixed size on screen. */ function areZoomedOut(): boolean { return bd.compare(boardScale, camera.getScaleWhenZoomedOut()) < 0; } /** * This is true when your device is physically incapable * of reprenting single tiles with a single of your monitor's pixels. * On retina displays you have to zoom out even more to reach this. */ function isScaleSmallForInvisibleTiles(): boolean { return bd.compare(boardScale, camera.getScaleWhenTilesInvisible()) < 0; } // Updating ------------------------------------------------------------------- // Called from game.updateBoard() function update(): void { if (guipause.areWePaused()) return; // Exit if paused if (Transition.areTransitioning()) return; // Exit if we are teleporting if (loadbalancer.areWeAFK()) return; // Exit if we're AFK. Save our CPU! panBoard(); recalcScale(); } /** Shifts the board position by its velocity. */ function panBoard(): void { if (panVel[0] === 0 && panVel[1] === 0) return; // Exit if we're not moving const panVelBD: BDCoords = bdcoords.FromDoubleCoords(panVel); // What the change would be if all frames were the exact same time length. const baseXChange = bd.divide(panVelBD[0], boardScale); const baseYChange = bd.divide(panVelBD[1], boardScale); // Account for delta time const deltaTimeBD: BigDecimal = bd.fromNumber(loadbalancer.getDeltaTime()); const actualXChange = bd.multiply(baseXChange, deltaTimeBD); const actualYChange = bd.multiply(baseYChange, deltaTimeBD); const newPos: BDCoords = [ bd.add(boardPos[0], actualXChange), bd.add(boardPos[1], actualYChange), ]; setBoardPos(newPos); } /** Shifts the board scale by its scale velocity. */ function recalcScale(): void { if (scaleVel === 0) return; // Exit if we're not zooming const scaleVelBD: BigDecimal = bd.fromNumber(scaleVel); const deltaTimeBD: BigDecimal = bd.fromNumber(loadbalancer.getDeltaTime()); let product = bd.multiply(scaleVelBD, deltaTimeBD); // scaleVel * deltaTime product = bd.clamp(product, bd.fromNumber(-0.5), bd.fromNumber(0.5)); // Prevent extreme zoom changes from low lps const factor2 = bd.add(product, ONE); // scaleVel * deltaTime + 1 const newScale = bd.multiplyFloating(boardScale, factor2); // boardScale * (scaleVel * deltaTime + 1) setBoardScale(newScale); } // Exports ------------------------------------------------------------------- export default { // Getters getBoardPos, getBoardScale, getBoardScaleAsNumber, getPanVel, getRelativePanVelCap, getScaleVel, glimitToDampScale, // Setters setBoardPos, setBoardScale, setPanVel, setScaleVel, // Other Utility eraseMomentum, boardHasMomentum, areZoomedOut, isScaleSmallForInvisibleTiles, // Updating update, }; ================================================ FILE: src/client/scripts/esm/game/rendering/boardtiles.ts ================================================ // src/client/scripts/esm/game/rendering/boardtiles.ts /** * This script renders the board, and changes it's color. * We also keep track of what tile the mouse is currently hovering over. */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { BDCoords, DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import type { BoundingBox, BoundingBoxBD } from '../../../../../shared/util/math/bounds.js'; import type { AttributeInfo, Renderable, TextureInfo } from '../../webgl/Renderable.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import math from '../../../../../shared/util/math/math.js'; import jsutil from '../../../../../shared/util/jsutil.js'; import gamefileutility from '../../../../../shared/chess/util/gamefileutility.js'; import style from '../gui/style.js'; import camera from './camera.js'; import gameslot from '../chess/gameslot.js'; import boardpos from './boardpos.js'; import imagecache from '../../chess/rendering/imagecache.js'; import primitives from './primitives.js'; import preferences from '../../components/header/preferences.js'; import piecemodels from './piecemodels.js'; import perspective from './perspective.js'; import { GameBus } from '../GameBus.js'; import frametracker from './frametracker.js'; import guipromotion from '../gui/guipromotion.js'; import texturecache from '../../chess/rendering/texturecache.js'; import TextureLoader from '../../webgl/TextureLoader.js'; import webgl, { gl } from './webgl.js'; import checkerboardgenerator from '../../chess/rendering/checkerboardgenerator.js'; import { createRenderable, createRenderable_GivenInfo } from '../../webgl/Renderable.js'; // Types --------------------------------------------------------------------------- /** * Optional noise textures to bind during rendering, * for the uber shader to apply board Zone effects. */ type NoiseTextures = { perlinNoise?: WebGLTexture; whiteNoise?: WebGLTexture }; // Constants --------------------------------------------------------------------------- /** Without this, the center of tiles would be their bottom-left corner. Range: 0-1 */ const squareCenter: number = 0.5; /** Z level for perspective mode rendering of the board tiles. */ const perspectiveMode_z = -0.01; // BigDecimal constants const ONE = bd.fromNumber(1.0); const TWO = bd.fromNumber(2.0); const TEN = bd.fromNumber(10); // Variables --------------------------------------------------------------------------- /** 2x2 Opaque, no mipmaps. Used in perspective mode. Medium moire, medium blur, no antialiasing. */ let tilesTexture_2: WebGLTexture | undefined; // Opaque, no mipmaps /** 256x256 Opaque, yes mipmaps. Used in 2D mode. Zero moire, yes antialiasing. */ let tilesTexture_256mips: WebGLTexture | undefined; /** * A mask texture for the tiles, used to apply Zone effects to selective light/dark tiles. * White pixels represent light tile pixels, black pixels represent dark tile pixels. * Independent of theme. */ let tilesMask: WebGLTexture | undefined; /** * The *exact* bounding box of the board currently visible on the canvas. * This differs from the camera's bounding box because this is effected by the camera's scale (zoom). */ let boundingBoxFloat: BoundingBoxBD; /** * The bounding box of the board currently visible on the canvas, * rounded away from the center of the canvas to encapsulate the whole of any partially visible squares. * This differs from the camera's bounding box because this is effected by the camera's scale (zoom). * CONTAINS INTEGER SQUARE VALUES. No floating points! */ let boundingBox: BoundingBox; /** * The bounding box of the board currently visible on the canvas when the CAMERA IS IN DEBUG MODE, * rounded away from the center of the canvas to encapsulate the whole of any partially visible squares. * This differs from the camera's bounding box because this is effected by the camera's scale (zoom). */ let boundingBox_debugMode: BoundingBox; /** Color [r,g,b,a] of the light tiles. */ let lightTiles: Color; /** Color [r,g,b,a] of the dark tiles. */ let darkTiles: Color; // Initialization -------------------------------------------------------------------------------- // Add event listener for theme changes document.addEventListener('theme-change', (_event) => { // Custom Event listener. console.log(`Theme change event detected: ${preferences.getTheme()}`); updateTheme(); const gamefile = gameslot.getGamefile(); if (!gamefile) return; imagecache.deleteImageCache(); // texturecache.deleteTextureCache(gl); imagecache.initImagesForGame(gamefile.boardsim).then(() => { // Regenerate piece textures with the new tinted images texturecache.initTexturesForGame(gl, gamefile.boardsim); piecemodels.regenAll(gamefile.boardsim, gameslot.getMesh()); }); // Reinit the promotion UI guipromotion.resetUI(); guipromotion.initUI(gamefile.basegame.gameRules.promotionsAllowed); }); GameBus.addEventListener('game-concluded', () => { darkenColor(); }); GameBus.addEventListener('game-unloaded', () => { // Resets the board color (the color changes when checkmate happens) updateTheme(); }); /** Loads the tiles texture. */ function init(): void { // Generate the tiles mask texture // Using 256x256 instead of 2x2 avoids creating an ring of higher moire around the camera in perspective mode. checkerboardgenerator.createCheckerboardIMG('white', 'black', 256).then((tilesMask_IMG) => { tilesMask = TextureLoader.loadTexture(gl, tilesMask_IMG, { mipmaps: false }); }); // Initial generation of tile textures updateTheme(); recalcVariables(); // Variables dependant on the board position & scale } async function initTextures(): Promise { const lightTilesCssColor = style.arrayToCssColor(lightTiles); const darkTilesCssColor = style.arrayToCssColor(darkTiles); // Generate both images in parallel const [tilesTexture_2_IMG, tilesTexture_256mips_IMG] = await Promise.all([ checkerboardgenerator.createCheckerboardIMG(lightTilesCssColor, darkTilesCssColor, 2), checkerboardgenerator.createCheckerboardIMG(lightTilesCssColor, darkTilesCssColor, 256), ]); tilesTexture_2 = TextureLoader.loadTexture(gl, tilesTexture_2_IMG, { mipmaps: false }); tilesTexture_256mips = TextureLoader.loadTexture(gl, tilesTexture_256mips_IMG, { mipmaps: true, }); frametracker.onVisualChange(); } // Updating -------------------------------------------------------------------------------- // Recalculate board velicity, scale, and other common variables. function recalcVariables(): void { recalcBoundingBox(); } function recalcBoundingBox(): void { boundingBoxFloat = getBoundingBoxOfBoard( boardpos.getBoardPos(), boardpos.getBoardScale(), false, ); boundingBox = roundAwayBoundingBox(boundingBoxFloat); const boundingBoxFloat_debugMode = getBoundingBoxOfBoard( boardpos.getBoardPos(), boardpos.getBoardScale(), true, ); boundingBox_debugMode = roundAwayBoundingBox(boundingBoxFloat_debugMode); } // Public API --------------------------------------------------------------------------------- function getSquareCenter(): BigDecimal { return bd.fromNumber(squareCenter); } function getSquareCenterAsNumber(): number { return squareCenter; } function gtileWidth_Pixels(debugMode = camera.getDebug()): BigDecimal { // If we're in developer mode, our screenBoundingBox is different const screenBoundingBox = camera.getScreenBoundingBox(debugMode); const factor1: BigDecimal = bd.fromNumber((camera.canvas.height * 0.5) / screenBoundingBox.top); const tileWidthPixels_Physical = bd.multiplyFloating(factor1, boardpos.getBoardScale()); // Greater for retina displays const divisor = bd.fromNumber(window.devicePixelRatio); const tileWidthPixels_Virtual = bd.divideFloating(tileWidthPixels_Physical, divisor); return tileWidthPixels_Virtual; } /** * Returns a copy of the board bounding box, rounded away from the center * of the canvas to encapsulate the whole of any partially visible squares. * CONTAINS INTEGER SQUARE VALUES. No floating points! * @returns The board bounding box */ function gboundingBox(debugMode = camera.getDebug()): BoundingBox { return debugMode ? jsutil.deepCopyObject(boundingBox_debugMode) : jsutil.deepCopyObject(boundingBox); } /** * Returns a copy of the *exact* board bounding box. * @returns The board bounding box */ function gboundingBoxFloat(): BoundingBoxBD { return jsutil.deepCopyObject(boundingBoxFloat); } /** * Calculates the bounding box of the board visible on screen, * when the camera is at the specified position, up to a certain precision level. * * This is different from the bounding box of the canvas, because * this is effected by the camera's scale (zoom) property. * * Returns in float form. To round away from the origin to encapsulate * the whole of all tiles at least partially visible, further use {@link roundAwayBoundingBox} * @param [position] The position of the camera. * @param [scale] The scale (zoom) of the camera. * @param debugMode - Whether developer mode is enabled. * @returns The bounding box */ function getBoundingBoxOfBoard( position: BDCoords = boardpos.getBoardPos(), scale: BigDecimal = boardpos.getBoardScale(), debugMode?: boolean, ): BoundingBoxBD { const screenBoundingBox = camera.getScreenBoundingBox(debugMode); function getAxisEdges(position: BigDecimal, screenEnd: number): [BigDecimal, BigDecimal] { const screenEndBD = bd.fromNumber(screenEnd); const distToEdgeInSquares: BigDecimal = bd.divideFloating(screenEndBD, scale); const start = bd.subtract(position, distToEdgeInSquares); const end = bd.add(position, distToEdgeInSquares); return [start, end]; } const [left, right] = getAxisEdges(position[0], screenBoundingBox.right); const [bottom, top] = getAxisEdges(position[1], screenBoundingBox.top); return { left, right, bottom, top }; } /** * Returns the expected render range bounding box when we're in perspective mode. * @param {number} rangeOfView - The distance in tiles (when scale is 1) to render the legal move fields in perspective mode. * @returns {BoundingBox} The perspective mode render range bounding box */ function generatePerspectiveBoundingBox(rangeOfView: number): BoundingBoxBD { // ~18 const position = boardpos.getBoardPos(); const scale = boardpos.getBoardScale(); const rangeOfViewBD = bd.fromNumber(rangeOfView); const renderDistInSquares = bd.divideFloating(rangeOfViewBD, scale); return { left: bd.subtract(position[0], renderDistInSquares), right: bd.add(position[0], renderDistInSquares), bottom: bd.subtract(position[1], renderDistInSquares), top: bd.add(position[1], renderDistInSquares), }; } /** * Returns a new board bounding box, with its edges rounded away from the * center of the canvas to encapsulate the whole of any squares partially included. * STILL IS AN INTEGER BOUNDING BOX, * @param src - The source board bounding box * @returns The rounded bounding box */ function roundAwayBoundingBox(src: BoundingBoxBD): BoundingBox { const squareCenter = getSquareCenter(); const squareCenterMinusOne = bd.subtract(squareCenter, ONE); const left = bd.toBigInt(bd.floor(bd.add(src.left, squareCenter))); // floor(left + squareCenter) const right = bd.toBigInt(bd.ceil(bd.add(src.right, squareCenterMinusOne))); // ceil(right + squareCenter - 1) const bottom = bd.toBigInt(bd.floor(bd.add(src.bottom, squareCenter))); // floor(bottom + squareCenter) const top = bd.toBigInt(bd.ceil(bd.add(src.top, squareCenterMinusOne))); // ceil(top + squareCenter - 1) return { left, right, bottom, top }; } /** Resets the board color, sky, and navigation bars (the color changes when checkmate happens). */ function updateTheme(): void { const gamefile = gameslot.getGamefile(); if (gamefile && gamefileutility.isGameOver(gamefile.basegame)) darkenColor(); // Reset to slightly darkened board else resetColor(); // Reset to defaults updateSkyColor(); updateNavColor(); } function resetColor( newLightTiles = preferences.getColorOfLightTiles(), newDarkTiles = preferences.getColorOfDarkTiles(), ): void { lightTiles = newLightTiles; // true for white darkTiles = newDarkTiles; // false for dark initTextures(); frametracker.onVisualChange(); } // Updates sky color based on current board color function updateSkyColor(): void { const avgR = (lightTiles[0] + darkTiles[0]) / 2; const avgG = (lightTiles[1] + darkTiles[1]) / 2; const avgB = (lightTiles[2] + darkTiles[2]) / 2; // BEFORE STAR FIELD ANIMATION // const dimAmount = 0.27; // Default: 0.27 // const skyR = avgR - dimAmount; // const skyG = avgG - dimAmount; // const skyB = avgB - dimAmount; // AFTER STAR FIELD ANIMATION const baseDim = 0.27; const multiplierDim = 0.6; const skyR = (avgR - baseDim) * multiplierDim; const skyG = (avgG - baseDim) * multiplierDim; const skyB = (avgB - baseDim) * multiplierDim; webgl.setClearColor([skyR, skyG, skyB]); // webgl.setClearColor([0,0,0]); // Solid Black } function updateNavColor(): void { // Determine the new "white" color const avgR = (lightTiles[0] + darkTiles[0]) / 2; const avgG = (lightTiles[1] + darkTiles[1]) / 2; const avgB = (lightTiles[2] + darkTiles[2]) / 2; // With the default theme, these should be max let navR = 255; let navG = 255; let navB = 255; if (preferences.getTheme() !== 'white') { const brightAmount = 0.6; // 50% closer to white navR = (1 - (1 - avgR) * (1 - brightAmount)) * 255; navG = (1 - (1 - avgG) * (1 - brightAmount)) * 255; navB = (1 - (1 - avgB) * (1 - brightAmount)) * 255; } style.setNavStyle(` .navigation { background: linear-gradient(to top, rgba(${navR}, ${navG}, ${navB}, 0.104), rgba(${navR}, ${navG}, ${navB}, 0.552), rgba(${navR}, ${navG}, ${navB}, 0.216)); } .footer { background: linear-gradient(to bottom, rgba(${navR}, ${navG}, ${navB}, 0.307), rgba(${navR}, ${navG}, ${navB}, 1), rgba(${navR}, ${navG}, ${navB}, 0.84)); } `); } function darkenColor(): void { const defaultLightTiles = preferences.getColorOfLightTiles(); const defaultDarkTiles = preferences.getColorOfDarkTiles(); const darkenBy = 0.09; const darkWR = Math.max(defaultLightTiles[0] - darkenBy, 0); const darkWG = Math.max(defaultLightTiles[1] - darkenBy, 0); const darkWB = Math.max(defaultLightTiles[2] - darkenBy, 0); const darkDR = Math.max(defaultDarkTiles[0] - darkenBy, 0); const darkDG = Math.max(defaultDarkTiles[1] - darkenBy, 0); const darkDB = Math.max(defaultDarkTiles[2] - darkenBy, 0); resetColor([darkWR, darkWG, darkWB, 1], [darkDR, darkDG, darkDB, 1]); } // Rendering ------------------------------------------------------------------------- // Renders board tiles function render(noiseTextures?: NoiseTextures, uniforms?: Record): void { // This prevents tearing when rendering in the same z-level and in perspective. webgl.executeWithDepthFunc_ALWAYS(() => { renderSolidCover(); // This is needed even outside of perspective, so when we zoom out, the rendered fractal transprent boards look correct. // renderMainBoard(noiseTextures, uniforms); renderFractalBoards(noiseTextures, uniforms); }); } // Renders an upside down grey cone centered around the camera, and level with the horizon. function renderSolidCover(): void { // const dist = perspective.distToRenderBoard; const dist = camera.getZFar() / Math.SQRT2; const z = getRelativeZ(); const cameraZ = camera.getPosition(true)[2]; const r = (lightTiles[0] + darkTiles[0]) / 2; const g = (lightTiles[1] + darkTiles[1]) / 2; const b = (lightTiles[2] + darkTiles[2]) / 2; const a = (lightTiles[3] + darkTiles[3]) / 2; const data = primitives.BoxTunnel(-dist, -dist, cameraZ, dist, dist, z, r, g, b, a); data.push(...primitives.Quad_Color3D(-dist, -dist, dist, dist, z, [r, g, b, a])); // Floor of the box const model = createRenderable(data, 3, 'TRIANGLES', 'color', true); model.render(); } function renderFractalBoards(noiseTextures?: NoiseTextures, uniforms?: Record): void { const z = getRelativeZ(); // Determine at what "e" the main boards tiles are 1 virtual pixel wide. const scaleWhen1TileIs1VirtualPixel = camera.getScaleWhenZoomedOut(); const eWhen1TileIs1VirtualPixel = bd.log10(scaleWhen1TileIs1VirtualPixel); const currentE = bd.log10(boardpos.getBoardScale()); // console.log("currentE:", currentE); // Board 1 (most zoomed in, always rendered, but may be fading out) const board1_E = Math.floor((currentE - eWhen1TileIs1VirtualPixel) / 3) * 3 + eWhen1TileIs1VirtualPixel; // console.log("board1_E:", board1_E); /** * How many orders of magnitude of the scale to transition * board 1's opacity from 1.0 to 0.0. Larger = slower fade. */ const E_FADE_DIST = 0.9; const board1_Opacity = Math.min(-(board1_E - currentE) / E_FADE_DIST, 1.0); const board1_Opacity_Eased = math.easeOut(board1_Opacity); // console.log("nextZoomedInOpacity:", board1_Opacity_Eased); // Board 2 (more zoomed out, always 1.0 opacity, but ONLY rendered when board 1 is fading out) const board2_E = board1_E - 3; // console.log("board2_E:", board2_E); // ONLY render board2 if the first board has started fading. // It's always rendered on bottom at 1.0 opacity. if (board1_Opacity_Eased < 1.0) { // console.log("Rendering 2nd board"); const power = -Math.round(board2_E - eWhen1TileIs1VirtualPixel); // Rounding is ONLY necessary due to correct tiny floating point inaccuracies. This MUST be an integer. const zoom = bd.pow(TEN, power); generateBoardModel(noiseTextures, zoom, 1.0)?.render([0, 0, z], undefined, uniforms); } // ALWAYS render board 1 (most zoomed in). // This is rendered on top, and may be fading out. const power = -Math.round(board1_E - eWhen1TileIs1VirtualPixel); // Rounding is ONLY necessary due to correct tiny floating point inaccuracies. This MUST be an integer. const zoom = bd.pow(TEN, power); generateBoardModel(noiseTextures, zoom, board1_Opacity_Eased)?.render( [0, 0, z], undefined, uniforms, ); } /** Returns what Z level the board tiles should be rendered at this frame. */ function getRelativeZ(): number { return perspective.getEnabled() ? perspectiveMode_z : 0; } /** * Generates the buffer model of the light tiles. * The dark tiles are rendered separately and underneath. * @param noise - Noise textures for zone effects, if they are loaded. * @param zoom - The zoom level to generate the board model at. Main board: 1.0 */ function generateBoardModel( { perlinNoise, whiteNoise }: NoiseTextures = {}, zoom: BigDecimal, opacity: number = 1.0, ): Renderable | undefined { if (!tilesMask) return; // Mask texture not loaded yet const boardScale = boardpos.getBoardScale(); /** Whether this is NOT the main board (zoom level 1.0) */ const isFractal = !bd.areEqual(zoom, ONE); // Fractal boards get the texture with no antialiasing, but some moire. const boardTexture = isFractal || perspective.getEnabled() ? tilesTexture_2 : tilesTexture_256mips; if (!boardTexture) return; // Texture not loaded yet /** The scale of the RENDERED board. Final result should always be within a small, visible range. */ const zoomTimesScale = bd.toNumber(bd.multiplyFloating(boardScale, zoom)); const zoomTimesScaleTwo = zoomTimesScale * 2; const { left, right, bottom, top } = camera.getRespectiveScreenBox(); const boardPos = boardpos.getBoardPos(); /** Calculates the texture coords for one axis (X/Y) of the tiles model. */ function getAxisTexCoords(boardPos: BigDecimal, start: number, end: number): DoubleCoords { const squareCenter = getSquareCenter(); const boardPosAdjusted: BigDecimal = bd.add(boardPos, squareCenter); const addend1: BigDecimal = bd.divide(boardPosAdjusted, zoom); const addend2: BigDecimal = bd.fromNumber(start / zoomTimesScale); const sum: BigDecimal = bd.add(addend1, addend2); const mod2: number = bd.toNumber(bd.mod(sum, TWO)); const texstart: number = mod2 / 2; const diff = end - start; const texdiff = diff / zoomTimesScaleTwo; const texend = texstart + texdiff; return [texstart, texend]; } const [texstartX, texendX] = getAxisTexCoords(boardPos[0], left, right); const [texstartY, texendY] = getAxisTexCoords(boardPos[1], bottom, top); // prettier-ignore const data = primitives.Quad_ColorTexture(left, bottom, right, top, texstartX, texstartY, texendX, texendY, 1, 1, 1, opacity); const attributeInfo: AttributeInfo = [ { name: 'a_position', numComponents: 2 }, { name: 'a_texturecoord', numComponents: 2 }, { name: 'a_color', numComponents: 4 }, ]; const textures: TextureInfo[] = [ { texture: boardTexture, uniformName: 'u_colorTexture' }, { texture: tilesMask, uniformName: 'u_maskTexture' }, ]; if (perlinNoise) textures.push({ texture: perlinNoise, uniformName: 'u_perlinNoiseTexture' }); if (whiteNoise) textures.push({ texture: whiteNoise, uniformName: 'u_whiteNoiseTexture' }); return createRenderable_GivenInfo( data, attributeInfo, 'TRIANGLES', 'board_uber_shader', textures, ); } // Exports ------------------------------------------------------------------------- export default { // Initialization init, // Updating recalcVariables, // Public API getSquareCenter, getSquareCenterAsNumber, gtileWidth_Pixels, gboundingBox, gboundingBoxFloat, getBoundingBoxOfBoard, generatePerspectiveBoundingBox, roundAwayBoundingBox, resetColor, darkenColor, // Rendering render, renderSolidCover, }; ================================================ FILE: src/client/scripts/esm/game/rendering/border.ts ================================================ // src/client/scripts/esm/game/rendering/border.ts /** * This script renders the border, and star field * animation of games with a world border. */ import bounds, { BoundingBox, DoubleBoundingBox, UnboundedRectangle, } from '../../../../../shared/util/math/bounds.js'; import meshes from './meshes.js'; import camera from './camera.js'; import primitives from './primitives.js'; import boardtiles from './boardtiles.js'; import perspective from './perspective.js'; import { createRenderable } from '../../webgl/Renderable.js'; /** * Draws a square on screen containing the entire * playable area, just inside the world border. */ function drawPlayableRegionMask(worldBorder: UnboundedRectangle | undefined): void { // No border, and in perspective mode => This is the best mask we can get! // This is crucial for making as if the board goes infinitely into the horizon. // Otherwise without this the solid cover isn't visible. if (!worldBorder && perspective.getEnabled()) return boardtiles.renderSolidCover(); const screenBox = camera.getRespectiveScreenBox(); let worldBox: DoubleBoundingBox; if (worldBorder) { // 0n works because, below, if the sides are at infinity anyway, they get capped to the screen box. The intermediate worldBox makes no difference to the final result for those sides. const worldBorderNotNull: BoundingBox = { left: worldBorder.left ?? 0n, right: worldBorder.right ?? 0n, bottom: worldBorder.bottom ?? 0n, top: worldBorder.top ?? 0n, }; const boundingBoxBD = meshes.expandTileBoundingBoxToEncompassWholeSquare(worldBorderNotNull); worldBox = meshes.applyWorldTransformationsToBoundingBox(boundingBoxBD); // Cap the world box to the screen box. // Fixes graphical glitches when the vertex data is beyond float32 range. // Null sides of worldBorder represent infinity, so we treat them as ±Infinity // so that clampDoubleBoundingBox clamps those sides to the screen edge. worldBox = bounds.clampDoubleBoundingBox( { left: worldBorder.left === null ? -Infinity : worldBox.left, right: worldBorder.right === null ? Infinity : worldBox.right, bottom: worldBorder.bottom === null ? -Infinity : worldBox.bottom, top: worldBorder.top === null ? Infinity : worldBox.top, }, screenBox, ); if (bounds.areBoxesDisjoint(worldBox, screenBox)) return; // No need to draw if playable area not on screen } else { // No world border, just use the screen box worldBox = screenBox; } const { left, right, bottom, top } = worldBox; const vertexData = primitives.Quad_Color(left, bottom, right, top, [0, 0, 0, 1]); // Color doesn't matter since it's a mask createRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render(); } // Exports ------------------------------------- export default { drawPlayableRegionMask, }; ================================================ FILE: src/client/scripts/esm/game/rendering/camera.ts ================================================ // src/client/scripts/esm/game/rendering/camera.ts /** * This script handles and stores the matrixes of our shader programs, which * store the location of the camera, and contains data about our canvas and window. * Note that our camera is going to be at a FIXED location no matter what our board * location is or our scale is, the camera remains still while the board moves beneath us. * * viewMatrix is the camera location and rotation. * projMatrix needed for perspective mode rendering (is even enabled in 2D view). * modelMatrix is custom for each rendered object, translating it how desired. */ import type { Vec3 } from '../../../../../shared/util/math/vectors.js'; import type { DoubleBoundingBox } from '../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../../../../shared/util/jsutil.js'; import mat4 from './gl-matrix.js'; import toast from '../gui/toast.js'; import stats from '../gui/stats.js'; import { gl } from './webgl.js'; import perspective from './perspective.js'; import preferences from '../../components/header/preferences.js'; import guigameinfo from '../gui/guigameinfo.js'; import screenshake from './screenshake.js'; import guidrawoffer from '../gui/guidrawoffer.js'; import frametracker from './frametracker.js'; /** A 4x4 matrix, represented as a 16-element Float32Array */ type Mat4 = Float32Array; /** If true, the camera is stationed farther back. */ let DEBUG: boolean = false; // This will NEVER change! The camera stays while the board position is what moves! // What CAN change is the rotation of the view matrix! const position: Vec3 = [0, 0, 12]; // [x, y, z] const position_devMode: Vec3 = [0, 0, 18]; // Default: 18 /** Field of view, in radians */ let fieldOfView: number; // The closer near & far limits are in terms of orders of magnitude, the more accurate // and less often things appear out of order. Should be within 5-6 magnitude orders. const zNear: number = 1; const zFar: number = 1500 * Math.SQRT2; // Default 1500. Has to at least be perspective.distToRenderBoard * sqrt(2) /** The canvas document element that WebGL renders the game onto. */ const canvas: HTMLCanvasElement = document.getElementById('game') as HTMLCanvasElement; let canvasWidthVirtualPixels: number; let canvasHeightVirtualPixels: number; let aspect: number; // Aspect ratio of the canvas width to height. /** * The location in world-space of the edges of the screen. * SMALL NUMBERS. Not affected by position or scale (zoom). * So we don't need to use BigDecimals. */ let screenBoundingBox: DoubleBoundingBox; /** * The location in world-space of the edges of the screen, when in developer mode. * SMALL NUMBERS. Not affected by position or scale (zoom). * So we don't need to use BigDecimals. */ let screenBoundingBox_devMode: DoubleBoundingBox; /** Contains the matrix for transforming our camera to look like it's in perspective. * This ONLY needs to update on the gpu whenever the screen size changes. */ let projMatrix: Mat4; // Same for every shader program /** Contains the camera's position and rotation, updated once per frame on the gpu. * * When compared to the world matrix, that uniform is updated with every draw call, * because it specifies the translation and rotation of the bound mesh. */ let viewMatrix: Mat4; // Returns devMode-sensitive camera position. function getPosition(ignoreDevmode?: boolean): Vec3 { return jsutil.deepCopyObject(!ignoreDevmode && DEBUG ? position_devMode : position); } function getZFar(): number { return zFar; } function getCanvasWidthVirtualPixels(): number { return canvasWidthVirtualPixels; } function getCanvasHeightVirtualPixels(): number { return canvasHeightVirtualPixels; } function toggleDebug(): void { DEBUG = !DEBUG; frametracker.onVisualChange(); // Visual change, render the screen this frame onPositionChange(); perspective.initCrosshairModel(); toast.show(`Toggled camera debug: ${DEBUG}`); } function getDebug(): boolean { return DEBUG; } /** * Returns a copy of the current screen bounding box, * or the world-space coordinates of the edges of the canvas. * @param [debugMode] Whether developer mode is enabled. If omitted, the current debug status is used. * @param [pad] Whether to add a small padding to the box to account for screen shakes. * @returns The bounding box of the screen */ function getScreenBoundingBox(debugMode: boolean = DEBUG, pad: boolean = false): DoubleBoundingBox { const box = jsutil.deepCopyObject(debugMode ? screenBoundingBox_devMode : screenBoundingBox); if (pad) { const width = box.right - box.left; const height = box.top - box.bottom; const longestSide = Math.max(width, height); let PAD_AMOUNT = 0.04; // 4% of longest side PAD_AMOUNT = Math.max(PAD_AMOUNT, (PAD_AMOUNT * width) / height); // Increase that if the screen is wider than taller // Add a small padding to the box so that things right at the edge don't get cut off const paddingAmountX = longestSide * PAD_AMOUNT; const paddingAmountY = longestSide * PAD_AMOUNT; box.left -= paddingAmountX; box.right += paddingAmountX; box.bottom -= paddingAmountY; box.top += paddingAmountY; } return box; } /** * Returns the respective world-space bounding box containing the whole screen, * depending on whether we're in perspective mode or not. * Intended for knowing how far out to render items to the edge. * * In perspective, the range of visibility is much greater. * * Ignorant of debug mode. */ function getRespectiveScreenBox(): DoubleBoundingBox { if (perspective.getEnabled()) return getPerspectiveScreenBox(); else return getScreenBoundingBox(false, true); } /** Returns the world bounding box of the visible range when in perspective mode. */ function getPerspectiveScreenBox(): DoubleBoundingBox { const dist = perspective.distToRenderBoard; return { left: -dist, right: dist, bottom: -dist, top: dist }; } /** * Returns the length from the bottom of the screen to the top, in tiles when at a zoom of 1. * This is the same as the height of {@link getScreenBoundingBox}. * @param [debugMode] Whether developer mode is enabled. If omitted, the current debug status is used. * @returns The height of the screen in squares */ function getScreenHeightWorld(debugMode: boolean = DEBUG): number { const boundingBox = getScreenBoundingBox(debugMode); return boundingBox.top - boundingBox.bottom; } /** * Returns a copy of the current view matrix. * @returns The view matrix */ function getViewMatrix(): Mat4 { return jsutil.copyFloat32Array(viewMatrix); } /** * Returns a copy of both the projMatrix and viewMatrix */ function getProjAndViewMatrixes(): { projMatrix: Mat4; viewMatrix: Mat4 } { return { projMatrix: jsutil.copyFloat32Array(projMatrix), viewMatrix: jsutil.copyFloat32Array(viewMatrix), }; } // Initiates the matrixes (uniforms) of our shader programs: viewMatrix (Camera), projMatrix (Projection), modelMatrix (world translation) function init(): void { initFOV(); initMatrixes(); document.addEventListener('fov-change', () => onFOVChange()); window.addEventListener('resize', () => onScreenResize()); } // Inits the matrix uniforms: viewMatrix (camera) & projMatrix function initMatrixes(): void { projMatrix = mat4.create(); // Same for every shader program updateCanvasDimensions(); initPerspective(); // Initiates perspective, including the projection matrix initViewMatrix(); // Camera // World matrix only needs to be initiated when rendering objects } // Call this when window resized. Also updates the projection matrix. function initPerspective(): void { initProjMatrix(); } // Also updates viewport, and updates canvas-dependant variables function updateCanvasDimensions(): void { // Get the canvas element's bounding rectangle const rect = canvas.getBoundingClientRect(); canvasWidthVirtualPixels = rect.width; canvasHeightVirtualPixels = rect.height; // Size of entire window in physical pixels, not virtual. Retina displays have a greater width. canvas.width = canvasWidthVirtualPixels * window.devicePixelRatio; canvas.height = canvasHeightVirtualPixels * window.devicePixelRatio; gl.viewport(0, 0, canvas.width, canvas.height); recalcCanvasVariables(); // Recalculate canvas-dependant variables // Dispatch event to notify other application code of the new canvas dimensions const detail = { width: canvas.width, height: canvas.height }; document.dispatchEvent(new CustomEvent('canvas_resize', { detail })); } function recalcCanvasVariables(): void { aspect = (gl.canvas as HTMLCanvasElement).clientWidth / (gl.canvas as HTMLCanvasElement).clientHeight; initScreenBoundingBox(); } // Set view matrix function setViewMatrix(newMatrix: Mat4): void { viewMatrix = newMatrix; } // Initiates the camera matrix. View matrix. function initViewMatrix(ignoreRotations?: boolean): void { const newViewMatrix: Mat4 = mat4.create(); const cameraPos = getPosition(); // devMode-sensitive // Translates the view (camera) matrix to be looking at point.. // Camera, Position, Looking-at, Up-direction mat4.lookAt(newViewMatrix, cameraPos, [0, 0, 0], [0, 1, 0]); // Screen Shake Integration const shakeMatrix = screenshake.getShakeMatrix(); // Apply to our view matrix to shake the camera mat4.multiply(newViewMatrix, newViewMatrix, shakeMatrix); if (!ignoreRotations) perspective.applyRotations(newViewMatrix); viewMatrix = newViewMatrix; // We NO LONGER send the updated matrix to the shaders as a uniform anymore, // because the combined transformMatrix is recalculated on every draw call. } /** Inits the projection matrix uniform and sends that over to the gpu for each of our shader programs. */ function initProjMatrix(): void { mat4.perspective(projMatrix, fieldOfView, aspect, zNear, zFar); // We NO LONGER send the updated matrix to the shaders as a uniform anymore, // because the combined transformMatrix is recalculated on every draw call. frametracker.onVisualChange(); } // Return the world-space x & y positions of the screen edges. Not affected by scale or board position. function initScreenBoundingBox(): void { // Camera dist let dist = position[2]; // const dist = 7; const thetaY = fieldOfView / 2; // Radians // Length of missing side: // tan(theta) = x / dist // x = tan(theta) * dist let distToVertEdge = Math.tan(thetaY) * dist; let distToHorzEdge = distToVertEdge * aspect; screenBoundingBox = { left: -distToHorzEdge, right: distToHorzEdge, bottom: -distToVertEdge, top: distToVertEdge, }; // Now init the developer-mode screen bounding box dist = position_devMode[2]; distToVertEdge = Math.tan(thetaY) * dist; distToHorzEdge = distToVertEdge * aspect; screenBoundingBox_devMode = { left: -distToHorzEdge, right: distToHorzEdge, bottom: -distToVertEdge, top: distToVertEdge, }; } function onScreenResize(): void { updateCanvasDimensions(); // Also updates viewport stats.updateStatsCSS(); initPerspective(); // The projection matrix needs to be recalculated every screen resize perspective.initCrosshairModel(); frametracker.onVisualChange(); // Visual change. Render the screen this frame. guidrawoffer.updateVisibilityOfNamesAndClocksWithDrawOffer(); // Hide the names and clocks depending on if the draw offer UI is cramped guigameinfo.updateAlignmentUsernames(); // console.log('Resized window.') } // Converts to radians function initFOV(): void { fieldOfView = (preferences.getPerspectiveFOV() * Math.PI) / 180; } function onFOVChange(): void { // console.log("Detected field of view change custom event!"); initFOV(); initProjMatrix(); recalcCanvasVariables(); // The only thing inside here we don't actually need to change is the aspect variable, but it doesn't matter. perspective.initCrosshairModel(); } // Call both when camera moves or rotates function onPositionChange(): void { initViewMatrix(); } /** * Returns the scale at which 1 physical pixel on the screen equals 1 tile. */ function getScaleWhenTilesInvisible(): BigDecimal { // We can cast this to a BigDecimal last because we know the resulting scale isn't arbitrarily small. return bd.fromNumber((screenBoundingBox.right * 2) / canvas.width); } /** * Returns the scale at which the game is considered *zoomed out*. * Each tile equals 1 virtual pixel on the screen. */ function getScaleWhenZoomedOut(): BigDecimal { const WDPR_BD = bd.fromNumber(window.devicePixelRatio); return bd.multiply(getScaleWhenTilesInvisible(), WDPR_BD); } export type { Mat4 }; export default { getPosition, canvas, getCanvasWidthVirtualPixels, getCanvasHeightVirtualPixels, toggleDebug, getDebug, getScreenBoundingBox, getRespectiveScreenBox, getPerspectiveScreenBox, getScreenHeightWorld, getViewMatrix, setViewMatrix, getProjAndViewMatrixes, init, onPositionChange, initViewMatrix, getZFar, getScaleWhenTilesInvisible, getScaleWhenZoomedOut, }; ================================================ FILE: src/client/scripts/esm/game/rendering/coordinates.ts ================================================ // src/client/scripts/esm/game/rendering/coordinates.ts /** * Board Coordinates * * Renders coordinate labels (file numbers along the bottom, rank numbers along * the left side) in a style similar to classical chess board notation. * * Labels are fixed-size in screen pixels regardless of zoom level. * When zoomed out, labels are skipped to prevent overlap, using the step * sequence [1, 2, 5, 10, 20, 50, 100, ...]. The step is determined by the * widest file label in the current view (the more restrictive axis), which * automatically ensures rank labels also won't overlap. */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import type { DoubleBoundingBox } from '../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal, toNumber } from '@naviary/bigdecimal'; import bounds from '../../../../../shared/util/math/bounds.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import space from '../misc/space.js'; import camera from './camera.js'; import arrows from './arrows/arrows.js'; import boardpos from './boardpos.js'; import boardtiles from './boardtiles.js'; import primitives from './primitives.js'; import guigameinfo from '../gui/guigameinfo.js'; import perspective from './perspective.js'; import preferences from '../../components/header/preferences.js'; import textrenderer from './text/textrenderer.js'; import arrowscalculator from './arrows/arrowscalculator.js'; import { createRenderable } from '../../webgl/Renderable.js'; import { ATLAS_DESCENDER_FRACTION } from './text/glyphatlas.js'; // Constants ------------------------------------------------------------------------- /** Virtual-pixel height of each coordinate label at full size. Zoom-independent. */ const LABEL_SIZE_PX = 24; /** * Controls how labels shrink on small screens. * The smaller canvas dimension (min of width and height) is used as the screen-size metric. */ const LABEL_SHRINK = { /** * Virtual-pixel threshold for the smaller canvas dimension. * Above this value labels are always {@link LABEL_SIZE_PX} tall; below it they start shrinking. */ threshold: 1000, /** * How aggressively labels shrink below the threshold. * At `1.0` labels scale fully to zero as the screen shrinks to zero. * At `0.5` they only ever shrink to half of {@link LABEL_SIZE_PX} no matter how small the screen gets. * Valid range: [0, 1]. */ rate: 0.6, } as const; /** Virtual-pixel gap between the screen edge and the near edge of each label. */ const LABEL_PADDING_PX = 5; /** RGBA color applied to all coordinate labels. */ const LABEL_COLOR: Color = [0, 0, 0, 0.65]; /** Labels with more characters than this threshold switch to the abbreviated "...XX" format. */ const MAX_FULL_DISPLAY_LENGTH = 7; /** Gap between adjacent labels as a multiple of the label height. */ const LABEL_GAP_SIZE = 0.4; /** * Extra padding, in virual-pixels, added to each side * of a label's hitbox when testing against arrow indicator hitboxes. */ const LABEL_ARROW_PADDING_PX = 6; /** Whether to render a wireframe outline of each label's bounding box for debugging. */ const DEBUG_RENDER_LABEL_BOUNDS = false; // Functions ------------------------------------------------------------------------- /** Returns the label size in virtual pixels for the current frame. */ function calcLabelSizePx(): number { const minDim = Math.min( camera.getCanvasWidthVirtualPixels(), camera.getCanvasHeightVirtualPixels(), ); if (minDim >= LABEL_SHRINK.threshold) return LABEL_SIZE_PX; const ratio = minDim / LABEL_SHRINK.threshold; return LABEL_SIZE_PX * (1 - LABEL_SHRINK.rate * (1 - ratio)); } /** * Returns the display string for a coordinate label, abbreviating large * values to "...XX" (or "-...XX" for negatives) using the last two digits. */ function formatCoord(coord: bigint): string { const full = coord.toString(); if (full.length <= MAX_FULL_DISPLAY_LENGTH) return full; const prefix = coord < 0n ? '-...' : '...'; return prefix + full.slice(-2); } /** * Returns the smallest value from the sequence [1, 2, 5, 10, 20, 50, 100, 200, 500, ...] * such that `step * scale >= threshold`. */ function computeStep(threshold: number, scale: BigDecimal): bigint { const magnitudes = [1n, 2n, 5n]; let power = 1n; while (true) { for (const m of magnitudes) { const step = m * power; // Multiplies rather than divides so that an arbitrarily small `scale` (BigDecimal) // never causes float overflow or a division-by-zero. `toNumber` is safe here because // values too large to represent become Infinity (still >= threshold) and values too // small become 0 (still < threshold). if (toNumber(bd.multiply(bd.fromBigInt(step), scale)) >= threshold) return step; } power *= 10n; } } /** Returns the smallest multiple of `multiple` that is >= `n`. */ function ceilToMultiple(n: bigint, multiple: bigint): bigint { const mod = ((n % multiple) + multiple) % multiple; return mod === 0n ? n : n + multiple - mod; } // API ------------------------------------------------------------------------- /** Renders the file (x-axis) and rank (y-axis) coordinate labels for the current frame. */ function render(): void { if (!preferences.getCoordinatesEnabled()) return; // Not enabled in the setting dropdown if (perspective.getEnabled()) return; const scale = boardpos.getBoardScale(); const labelSizePx = calcLabelSizePx(); const sizeWorld = space.convertPixelsToWorldSpace_Virtual(labelSizePx); const paddingWorld = space.convertPixelsToWorldSpace_Virtual(LABEL_PADDING_PX); const screenBox = camera.getScreenBoundingBox(false); const tileBox = boardtiles.gboundingBox(false); // Shrink the bounding box by 1 on each side to skip cut off edge tiles. tileBox.left += 1n; tileBox.right -= 1n; tileBox.bottom += 1n; tileBox.top -= 1n; if (tileBox.left > tileBox.right || tileBox.bottom > tileBox.top) return; // The step is driven by the widest visible file label (width-based overlap). // File labels overlap sooner than rank labels because characters are wider than // they are tall, so a step sufficient for files is automatically sufficient for ranks. // If both endpoints are abbreviated but the visible range spans the non-abbreviated zone, // the endpoints would underestimate the widest label. Guard against that by also // measuring the widest possible non-abbreviated label when the zone is in range. const unabbrevMax = 10n ** BigInt(MAX_FULL_DISPLAY_LENGTH) - 1n; // e.g. 9999999n const unabbrevMin = -(10n ** BigInt(MAX_FULL_DISPLAY_LENGTH - 1) - 1n); // e.g. -999999n // Only needed when at least one endpoint is abbreviated (outside the non-abbreviated zone) // but the range still spans into it, meaning interior labels will be wider than the endpoints. const hasUnabbrevInRange = (tileBox.left < unabbrevMin || tileBox.right > unabbrevMax) && tileBox.left <= unabbrevMax && tileBox.right >= unabbrevMin; const widestFileLabelWidth = Math.max( textrenderer.getTextWidth(formatCoord(tileBox.left), sizeWorld), textrenderer.getTextWidth(formatCoord(tileBox.right), sizeWorld), hasUnabbrevInRange ? textrenderer.getTextWidth('9'.repeat(MAX_FULL_DISPLAY_LENGTH), sizeWorld) : 0, ); const threshold = widestFileLabelWidth + sizeWorld * LABEL_GAP_SIZE; const stepBig = computeStep(threshold, scale); // Pre-compute arrow indicator hitboxes for this frame to skip overlapping labels. const arrowHalfWidth = arrowscalculator.getArrowIndicatorHalfWidth() + space.convertPixelsToWorldSpace_Virtual(LABEL_ARROW_PADDING_PX); const arrowLocations = arrows.getAllArrowWorldLocations(); const isBlackPerspective = perspective.getIsViewingBlackPerspective(); // Arrow hitbox locations in black's perspective need to be negated so overlap detection remains accurate. const effectiveArrowLocations: DoubleCoords[] = isBlackPerspective ? arrowLocations.map((loc) => [-loc[0], -loc[1]]) : arrowLocations; // X-axis: file labels centered on each file column, fixed at the bottom of the screen. // Shifted down by ATLAS_DESCENDER_FRACTION so the invisible descender space goes below // the screen edge rather than adding unwanted gap above the visible characters. // Shifted up by the game info bar height so labels aren't covered when it's visible. const gameInfoBarOffsetWorld = space.convertPixelsToWorldSpace_Virtual( guigameinfo.getHeightOfGameInfoBar(), ); const fileWorldY = screenBox.bottom + gameInfoBarOffsetWorld + paddingWorld + sizeWorld * (0.5 - ATLAS_DESCENDER_FRACTION); const firstFile = ceilToMultiple(tileBox.left, stepBig); // Y-axis: rank labels left-aligned from the left edge of the screen, at each rank row. const rankWorldX = screenBox.left + paddingWorld; const firstRank = ceilToMultiple(tileBox.bottom, stepBig); // Render without any rotation so glyphs always appear upright. // In black's perspective the view matrix carries a 180° Z-rotation that would otherwise flip the text. perspective.renderWithoutPerspectiveRotations(() => { for (let file = firstFile; file <= tileBox.right; file += stepBig) { let worldX = space.convertCoordToWorldSpace(bdcoords.FromCoords([file, 0n]))[0]; if (isBlackPerspective) worldX = -worldX; // Invert world coords // prettier-ignore renderLabel(formatCoord(file), [worldX, fileWorldY], sizeWorld, 'center', arrowHalfWidth, effectiveArrowLocations); } for (let rank = firstRank; rank <= tileBox.top; rank += stepBig) { let worldY = space.convertCoordToWorldSpace(bdcoords.FromCoords([0n, rank]))[1]; if (isBlackPerspective) worldY = -worldY; // Invert world coords // prettier-ignore renderLabel(formatCoord(rank), [rankWorldX, worldY], sizeWorld, 'left', arrowHalfWidth, effectiveArrowLocations); } }); } /** * Renders a single coordinate label at the given position, unless its hitbox * intersects an arrow indicator hitbox (expanded by the current arrow padding). */ function renderLabel( label: string, coords: DoubleCoords, sizeWorld: number, align: 'left' | 'center' | 'right', arrowHalfWidth: number, arrowLocations: DoubleCoords[], ): void { const labelBounds = textrenderer.getTextBounds(label, coords, sizeWorld, align); for (const loc of arrowLocations) { if ( !bounds.areBoxesDisjoint(labelBounds, { left: loc[0] - arrowHalfWidth, right: loc[0] + arrowHalfWidth, bottom: loc[1] - arrowHalfWidth, top: loc[1] + arrowHalfWidth, }) ) return; // Skip, it overlaps an arrow indicator. } // Proceed to render textrenderer.render(label, coords, sizeWorld, LABEL_COLOR, align); if (DEBUG_RENDER_LABEL_BOUNDS) renderLabelBoundsOutline(labelBounds); } /** * [DEBUG] Renders a wireframe outline of a label's bounding box. * Only called when {@link DEBUG_RENDER_LABEL_BOUNDS} is `true`. */ function renderLabelBoundsOutline(labelBounds: DoubleBoundingBox): void { const DEBUG_BOUNDS_COLOR: Color = [1, 0, 0, 1]; // Red const data = primitives.Rect( labelBounds.left, labelBounds.bottom, labelBounds.right, labelBounds.top, DEBUG_BOUNDS_COLOR, ); createRenderable(data, 2, 'LINE_LOOP', 'color', true).render(); } // Exports ------------------------------------------------------------------------- export default { render }; ================================================ FILE: src/client/scripts/esm/game/rendering/dragging/draganimation.ts ================================================ // src/client/scripts/esm/game/rendering/dragging/draganimation.ts /** * This script hides the original piece and renders a copy at the pointer location. * It also highlights the square that the piece would be dropped on (to do) * and plays the sound when the piece is dropped. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { Coords, DoubleCoords } from '../../../../../../shared/chess/util/coordutil.js'; import bd from '@naviary/bigdecimal'; import typeutil from '../../../../../../shared/chess/util/typeutil.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import space from '../../misc/space.js'; import mouse from '../../../util/mouse.js'; import meshes from '../meshes.js'; import camera from '../camera.js'; import boardpos from '../boardpos.js'; import keybinds from '../../misc/keybinds.js'; import selection from '../../chess/selection.js'; import animation from '../animation.js'; import { Mouse } from '../../input.js'; import droparrows from './droparrows.js'; import boardtiles from '../boardtiles.js'; import primitives from '../primitives.js'; import preferences from '../../../components/header/preferences.js'; import perspective from '../perspective.js'; import { GameBus } from '../../GameBus.js'; import texturecache from '../../../chess/rendering/texturecache.js'; import frametracker from '../frametracker.js'; import legalmovemodel from '../highlights/legalmovemodel.js'; import instancedshapes from '../instancedshapes.js'; import { listener_overlay } from '../../chess/game.js'; import { createRenderable, createRenderable_Instanced } from '../../../webgl/Renderable.js'; // Variables -------------------------------------------------------------------------------------- const z: number = 0.01; /** * The minimum size of the rendered dragged piece on screen, in virtual pixels. * When zoomed out, this prevents it becoming tiny relative to the other pieces. */ const dragMinSizeVirtualPixels = { /** 2D desktop mode */ mouse: 50, // Only applicable in 2D mode, not perspective /** Mobile/touchscreen mode */ touch: 50, } as const; /** * The width of the box/rank/file outline used to emphasize the hovered square. */ const outlineWidth = { /** 2D desktop mode */ mouse: 0.08, // Since on touchscreen the rank/column outlines are ALWAYS enabled, // make them a little less noticeable/distracting. /** Mobile/touchscreen mode */ touch: 0.065, } as const; /** When using a touchscreen, the piece is shifted upward by this amount to prevent it being covered by fingers. */ const touchscreenOffset: number = 1.6; // Default: 2 /** When each square becomes smaller than this in virtual pixels, we render rank/column outlines instead of the outline box. */ const minSizeToDrawOutline: number = 40; /** Adjustments for the dragged piece while in perspective mode. */ const perspectiveConfigs: { z: number; shadowColor: Color } = { /** The height the piece is rendered above the board when in perspective mode. */ z: 0.6, /** The color of the shadow of the dragged piece. */ shadowColor: [0.1, 0.1, 0.1, 0.5], } as const; /** If true, `pieceSelected` is currently being held. */ let areDragging = false; /** * When true, the rank/file outline is always rendered during dragging, * regardless of zoom level. Set by dragarrows.ts during slide zone mode. */ let forceRankFileOutline: boolean = false; /** * When true, the next time a piece is dropped on its own square, it will NOT be unselected. * But if this is false, it WOULD be unselected. * Pieces are unselected every second time dropped. */ let parity: boolean = true; /** The ID of the pointer that is dragging the piece. */ let pointerId: string | undefined; /** The coordinates of the piece before it was dragged. */ let startCoords: Coords | undefined; /** The world location the piece has been dragged to. */ let worldLocation: DoubleCoords | undefined; /** The square that the piece would be moved to if dropped now. It will be outlined. */ let hoveredCoords: Coords | undefined; /** The type of piece being dragged. */ let pieceType: number | undefined; // Functions -------------------------------------------------------------------------------------- function areDraggingPiece(): boolean { return areDragging; } /** Forces the rank/file outline to always render during dragging. Used by dragarrows.ts in slide zone mode. */ function setForceRankFileOutline(value: boolean): void { forceRankFileOutline = value; } /** If true, the last pick-up action newly selected that piece, vs picking up an already-selected piece. */ function getDragParity(): boolean { return parity; } /** * Start dragging a piece. * @param type - The type of piece being dragged * @param pieceCoords - the square the piece was on */ function pickUpPiece(piece: Piece, resetParity: boolean): void { if (!keybinds.getEffectiveDragEnabled()) return; // Dragging is disabled areDragging = true; if (resetParity) parity = true; const respectiveListener = mouse.getRelevantListener(); pointerId = respectiveListener.getMouseId(Mouse.LEFT); startCoords = piece.coords; pieceType = piece.type; // If any one animation's end coords is currently being animated towards the coords of the picked up piece, clear the animation. if ( animation.animations.some((a) => coordutil.areCoordsEqual(piece.coords, a.path[a.path.length - 1]!), ) ) animation.clearAnimations(true); } /** * Call AFTER selection.update() */ function updateDragLocation(): void { if (!areDragging) return; /** * If the promotion UI is open, change the world location of * the dragged piece to the promotion square */ const squarePawnPromotingOn = selection.getSquarePawnIsCurrentlyPromotingOn(); if (squarePawnPromotingOn !== undefined) { const worldCoords = space.convertCoordToWorldSpace( bdcoords.FromCoords(squarePawnPromotingOn), ); worldLocation = worldCoords; hoveredCoords = squarePawnPromotingOn; return; } else { // Normal drag location worldLocation = mouse.getPointerWorld(pointerId!); hoveredCoords = worldLocation ? space.convertWorldSpaceToCoords_Rounded(worldLocation) : undefined; } } /** Call AFTER {@link updateDragLocation} and BEFORE {@link renderPiece} */ function setDragLocationAndHoverSquare(worldLoc: DoubleCoords, hoverSquare: Coords): void { worldLocation = worldLoc; hoveredCoords = hoverSquare; } /** Returns the id of the pointer currently dragging a piece. */ function getPointerIdDraggingPiece(): string | undefined { if (!areDragging) throw Error('Unexpected!'); return pointerId; } /** * Returns the square the dragged piece is currently hovering over. * Set by updateDragLocation or setDragLocationAndHoverSquare * by the droparrows or dragarrows features. */ function getHoveredCoords(): Coords | undefined { return hoveredCoords; } /** Whether the pointer dragging the selected piece has released yet. */ function hasPointerReleased(): boolean { if (!areDragging) throw Error("Don't call hasPointerReleased() when not dragging a piece"); const respectiveListener = mouse.getRelevantListener(); return !respectiveListener.isPointerHeld(pointerId!); } // /** Returns the pointer id that is dragging the piece. */ // function getPointerId(): string { // if (!areDragging) throw Error("Don't call getPointerId() when not dragging a piece"); // return pointerId!; // } /** * Stop dragging the piece. */ function dropPiece(): void { // console.error("Dropped piece"); if (!areDragging) return; areDragging = false; pieceType = undefined; startCoords = undefined; worldLocation = undefined; hoveredCoords = undefined; parity = false; // The next time this piece is dropped on its home square, it will be deselected droparrows.onDragTermination(); frametracker.onVisualChange(); // Rapidly picking up and dropping a piece triggers a simulated click. // If we don't claim it here, annotations will read it to Collapse annotations. if (mouse.isMouseClicked(Mouse.LEFT)) mouse.claimMouseClick(Mouse.LEFT); } GameBus.addEventListener('piece-unselected', () => { cancelDragging(); }); /** Puts the dragged piece back. Doesn't make a move. */ function cancelDragging(): void { dropPiece(); parity = true; } // Rendering -------------------------------------------------------------------------------------------- // Hides the original piece by rendering a transparent square model above it in the depth field. function renderTransparentSquare(): void { if (!startCoords) return; const color: Color = [0, 0, 0, 0]; const data = meshes.QuadWorld_Color(startCoords, color); // Hide orginal piece return createRenderable(data, 2, 'TRIANGLES', 'color', true).render([0, 0, z]); } // Renders the box outline, the dragged piece and its shadow function renderPiece(): void { if (!areDragging || perspective.isLookingUp() || !worldLocation) return; renderOutline(); renderPieceModel(); } /** Generates the model of the dragged piece and its shadow. */ function renderPieceModel(): void { if (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(pieceType!))) return; // No SVG/texture for this piece (void), can't render it. const perspectiveEnabled = perspective.getEnabled(); const touchscreenUsed = listener_overlay.isPointerTouch(pointerId!); const boardScale = boardpos.getBoardScaleAsNumber(); const rotation = perspective.getIsViewingBlackPerspective() ? -1 : 1; const { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords(); // In perspective the piece is rendered above the surface of the board. const height = perspectiveEnabled ? perspectiveConfigs.z * boardScale : z; // If touchscreen is being used the piece is rendered larger and offset upward to prevent // it being covered by the finger. let size: number = boardScale; if (!selection.getSquarePawnIsCurrentlyPromotingOn() && !perspective.getEnabled()) { // Apply a minimum size only if we're not currently promoting a pawn (promote UI open) and not in perspective mode. // The minimum world space the dragged piece should be rendered const minSizeWorldSpace = touchscreenUsed ? space.convertPixelsToWorldSpace_Virtual(dragMinSizeVirtualPixels.touch) // Mobile/touchscreen mode : space.convertPixelsToWorldSpace_Virtual(dragMinSizeVirtualPixels.mouse); // 2D desktop mode size = Math.max(size, minSizeWorldSpace); // Apply the minimum size } const halfSize = size / 2; const left = worldLocation![0] - halfSize; const bottom = worldLocation![1] - halfSize + (touchscreenUsed ? touchscreenOffset * rotation : 0); const right = worldLocation![0] + halfSize; const top = worldLocation![1] + halfSize + (touchscreenUsed ? touchscreenOffset * rotation : 0); const data: number[] = []; // prettier-ignore if (perspectiveEnabled) data.push(...primitives.Quad_ColorTexture3D(left, bottom, right, top, z, texleft, texbottom, texright, textop, ...perspectiveConfigs.shadowColor)); // Shadow // prettier-ignore data.push(...primitives.Quad_ColorTexture3D(left, bottom, right, top, height, texleft, texbottom, texright, textop, 1, 1, 1, 1)); // Piece createRenderable( data, 3, 'TRIANGLES', 'colorTexture', true, texturecache.getTexture(pieceType!), ).render(); } /** * Renders the outline emphasizing the hovered square. * If mouse is being used the square is outlined. * On touchscreen (or in slide zone mode) the entire rank and file are outlined. */ // prettier-ignore function renderOutline(): void { const pointerIsTouch = listener_overlay.isPointerTouch(pointerId!); // The coordinates of the edges of the square const { left, right, bottom, top } = meshes.getCoordBoxWorld(hoveredCoords!); const boardScale = boardpos.getBoardScaleAsNumber(); const width = (pointerIsTouch ? outlineWidth.touch : outlineWidth.mouse) * boardScale; const color = preferences.getBoxOutlineColor(); // Outline the entire rank & file when: // 1. We're not hovering over the start square. // 2. It is a touch screen, OR we are zoomed out enough. if ( !coordutil.areCoordsEqual(hoveredCoords!, startCoords!) && (forceRankFileOutline || pointerIsTouch || bd.toNumber(boardtiles.gtileWidth_Pixels()) < minSizeToDrawOutline) ) { // Outline the entire rank and file const screenBox = camera.getRespectiveScreenBox(); const data: number[] = []; data.push(...primitives.Quad_Color(left, screenBox.bottom, left + width, screenBox.top, color)); // left data.push(...primitives.Quad_Color(screenBox.left, bottom, screenBox.right, bottom + width, color)); // bottom data.push(...primitives.Quad_Color(right - width, screenBox.bottom, right, screenBox.top, color)); // right data.push(...primitives.Quad_Color(screenBox.left, top - width, screenBox.right, top, color)); // top createRenderable(data, 2, 'TRIANGLES', 'color', true).render(); } else { // Outline the hovered square using an instanced box outline model const vertexData = instancedshapes.getDataBoxOutline(); const offset = legalmovemodel.getOffset(); const offsetCoord = coordutil.subtractCoords(hoveredCoords!, offset); const instanceData: number[] = [Number(offsetCoord[0]), Number(offsetCoord[1])]; const { position, scale } = meshes.getBoardRenderTransform(offset); createRenderable_Instanced(vertexData, instanceData, 'TRIANGLES', 'colorInstanced', true) .render(position, scale); } } export default { areDraggingPiece, getDragParity, pickUpPiece, updateDragLocation, setDragLocationAndHoverSquare, setForceRankFileOutline, getPointerIdDraggingPiece, getHoveredCoords, hasPointerReleased, dropPiece, renderTransparentSquare, renderPiece, }; ================================================ FILE: src/client/scripts/esm/game/rendering/dragging/dragarrows.ts ================================================ // src/client/scripts/esm/game/rendering/dragging/dragarrows.ts /** * This script handles clicking and dragging arrow indicators that point to your * own off-screen pieces, allowing you to drag and move that piece without * needing to pan or zoom to it. * * This is the companion feature to droparrows.ts (which handles dropping your * dragged piece onto arrows to capture off-screen opponent pieces). */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js'; import type { HoveredArrow } from '../arrows/arrows.js'; import type { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import type { Coords, BDCoords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import vectors from '../../../../../../shared/util/math/vectors.js'; import geometry from '../../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import boardutil from '../../../../../../shared/chess/util/boardutil.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import legalmoves from '../../../../../../shared/chess/logic/legalmoves.js'; import space from '../../misc/space.js'; import mouse from '../../../util/mouse.js'; import camera from '../camera.js'; import meshes from '../meshes.js'; import arrows from '../arrows/arrows.js'; import gameslot from '../../chess/gameslot.js'; import keybinds from '../../misc/keybinds.js'; import selection from '../../chess/selection.js'; import { Mouse } from '../../input.js'; import maskedDraw from '../../../webgl/maskedDraw.js'; import primitives from '../primitives.js'; import droparrows from './droparrows.js'; import guigameinfo from '../../gui/guigameinfo.js'; import arrowshifts from '../arrows/arrowshifts.js'; import frametracker from '../frametracker.js'; import loadbalancer from '../../misc/loadbalancer.js'; import draganimation from './draganimation.js'; import guinavigation from '../../gui/guinavigation.js'; import legalmovemodel from '../highlights/legalmovemodel.js'; import arrowscalculator from '../arrows/arrowscalculator.js'; import { ARROW_SIZE_RATIO } from '../arrows/arrowsgraphics.js'; import { createRenderable } from '../../../webgl/Renderable.js'; // Types --------------------------------------------------------------------------------- /** * State stored when the user presses the mouse on an own-piece arrow indicator. * Persists until the pointer is released or the drag is fully initiated. */ interface CandidateArrow { /** Integer board coordinates of the off-screen piece the arrow points to. */ pieceCoords: Coords; /** The type of the off-screen piece the arrow points to. */ pieceType: number; /** The direction vector of the arrow indicator. */ direction: Vec2; /** The input pointer ID holding the mouse button down. */ pointerId: string; } // Constants ------------------------------------------------------------------------------- /** Settings for the animated arrows shown beside the candidate arrow indicator. */ const CANDIDATE_ANIM = { /** Period of the oscillation, in milliseconds. */ PERIOD_MS: 800, /** Amplitude of the oscillation, as a multiple of the arrow indicator half-width. */ AMPLITUDE: 0.3, /** Initial phase offset as a fraction of the full period (0–1). */ PHASE_INITIAL: 0.1, /** Color of the arrows [r, g, b, a]. */ COLOR: [0, 0, 0, 0.8] satisfies Color, } as const; /** The width of the slide zone, as a percentage of arrow indicator images. */ const SLIDE_ZONE_WIDTH = 1.7; /** Radial gradient rendered inside the slide zone. */ const SLIDE_ZONE_GRADIENT = { COLORS: [ [1, 1, 1, 0.2], [1, 1, 1, 0.6], ] satisfies Color[], /** World units between each individual color ring. */ SPACING: 5, /** World units per second the phase advances. */ VELOCITY: 9, } as const; // State --------------------------------------------------------------------------------- /** The candidate arrow — set when mouse is pressed on an own-piece arrow, cleared when pointer releases. */ let candidate: CandidateArrow | undefined; /** * Whether the drag has been activated (mouse moved past the activation threshold). * Can only ever be true if candidate is also defined. */ let isDragActive: boolean = false; /** Whether the dragged piece is currently positioned inside the slide zone. */ let currentlyInSlideZone: boolean = false; /** Timestamp when the current candidate was set, used for the candidate animation. */ let candidateAnimStartTime: number = 0; /** Current phase offset for the slide zone radial gradient, in world units. */ let slideZonePhase: number = 0; // Main update --------------------------------------------------------------------------- /** * Main per-frame update. * * CALL AFTER droparrows.shiftArrows() and BEFORE arrows.executeArrowShifts(). */ function update(): void { if (!gameslot.getGamefile()) return; if (!arrows.areArrowsActiveThisFrame()) return; if (isDragActive) { updateActiveDrag(); } else if (candidate !== undefined) { updateCandidate(); } else { detectCandidateArrow(); } if (candidate !== undefined) { // Keep rendering while the candidate animation is active, // OR there's an active drag. frametracker.onVisualChange(); if (isDragActive) { // Update the phase of the slide zone gradient to create a moving effect slideZonePhase = (slideZonePhase + SLIDE_ZONE_GRADIENT.VELOCITY * loadbalancer.getDeltaTime()) % (SLIDE_ZONE_GRADIENT.COLORS.length * SLIDE_ZONE_GRADIENT.SPACING); frametracker.onVisualChange(); // Render this frame (slide zone is being animated) } } } /** Branch A: drag is active. Manage slide zone positioning and arrow shifts. */ function updateActiveDrag(): void { if (!draganimation.areDraggingPiece()) { // The drag was completed or cancelled by selection.ts (piece was dropped/moved). reset(); return; } if (findCandidateHoveredArrow() !== undefined) { // Mouse moved back within threshold — deactivate the drag. draganimation.setForceRankFileOutline(false); isDragActive = false; // console.log('Set isDragActive = false'); selection.unselectPiece(); // Fires 'piece-unselected' → draganimation.cancelDragging() return; } const mouseWorld = mouse.getPointerWorld(candidate!.pointerId); if (!mouseWorld) return; manageActiveDrag(mouseWorld); } /** Branch B: candidate exists but drag not yet active. Check threshold and initiate drag. */ function updateCandidate(): void { const respectiveListener = mouse.getRelevantListener(); if (!respectiveListener.isPointerHeld(candidate!.pointerId)) { // Pointer released without crossing threshold — clear candidate, allow normal arrow click. candidate = undefined; // console.log('Set candidate = undefined'); return; } if (findCandidateHoveredArrow() !== undefined) return; // Still within threshold — wait. // Threshold crossed — initiate drag of the off-screen piece. const gamefile = gameslot.getGamefile()!; const piece: Piece | undefined = boardutil.getPieceFromCoords( gamefile.boardsim.pieces, candidate!.pieceCoords, ); if (!piece) { // Piece disappeared (shouldn't happen during candidate phase, but guard it). candidate = undefined; // console.log('Set candidate = undefined'); return; } selection.selectPiece(gamefile, gameslot.getMesh(), piece, true); mouse.cancelMouseClick(Mouse.LEFT); // Prevent the eventual release from being treated as a click/teleport. isDragActive = true; // console.log('Set isDragActive = true'); draganimation.setForceRankFileOutline(true); frametracker.onVisualChange(); } /** Branch C: no candidate. Check for a new mouse-down on an own-piece arrow. */ function detectCandidateArrow(): void { if (!mouse.isMouseDown(Mouse.LEFT)) return; const hoveredArrowsList = arrows.getHoveredArrows(); if (hoveredArrowsList.length === 0) return; // Claim the mouse down for any arrow hover to prevent board drag. // Mouse down for move hint arrow indicators must be claimed separately. mouse.claimMouseDown(Mouse.LEFT); // Early exit on dragging disabled now, since the mouse down has been claimed. if (!keybinds.getEffectiveDragEnabled()) return; const gamefile = gameslot.getGamefile()!; for (const hoveredArrow of hoveredArrowsList) { if (hoveredArrow.piece.floating) continue; // Ignore animated arrows. if (!hoveredArrow.ownsSlide) continue; // Piece can't slide in this direction. const pieceType = hoveredArrow.piece.type; if (selection.canSelectPieceType(gamefile.basegame, pieceType) !== 2) continue; // Not own draggable piece. const pieceCoords = bdcoords.coordsToBigInt(hoveredArrow.piece.coords); const pointerId = mouse.getRelevantListener().getMouseId(Mouse.LEFT)!; candidate = { pieceCoords, pieceType, direction: hoveredArrow.direction, pointerId, }; // console.log('Set candidate'); candidateAnimStartTime = performance.now(); break; } } // Active drag management --------------------------------------------------------------- /** * Returns the hovered arrow that matches the current candidate, or undefined if not found. * The arrow may move every frame (panning & zooming), so we have to re-check each frame. */ function findCandidateHoveredArrow(): HoveredArrow | undefined { if (!candidate) return undefined; return arrows.getHoveredArrows().find((h) => { if (h.piece.floating) return false; const hCoords = bdcoords.coordsToBigInt(h.piece.coords); return ( coordutil.areCoordsEqual(hCoords, candidate!.pieceCoords) && coordutil.areCoordsEqual(h.direction, candidate!.direction) ); }); } /** * Handles the per-frame logic when the drag is active and the mouse is past threshold. * Determines if the mouse is in the slide zone and updates drag position accordingly. */ function manageActiveDrag(mouseWorld: DoubleCoords): void { // Slide zone depth in world space units const slideZoneDepth = 2.0 * arrowscalculator.getArrowIndicatorHalfWidth() * SLIDE_ZONE_WIDTH; // Always use the 2D screen box for slide zone boundaries, even in perspective mode. const screenBox = camera.getScreenBoundingBox(false); const dir = candidate!.direction; const topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar()); const bottomBarDepth = space.convertPixelsToWorldSpace_Virtual( guigameinfo.getHeightOfGameInfoBar(), ); const inRight = dir[0] > 0n && mouseWorld[0] > screenBox.right - slideZoneDepth; const inLeft = dir[0] < 0n && mouseWorld[0] < screenBox.left + slideZoneDepth; const inTop = dir[1] > 0n && mouseWorld[1] > screenBox.top - slideZoneDepth - topBarDepth; const inBottom = dir[1] < 0n && mouseWorld[1] < screenBox.bottom + slideZoneDepth + bottomBarDepth; currentlyInSlideZone = inRight || inLeft || inTop || inBottom; if (currentlyInSlideZone) { updateSlideZoneDrag(mouseWorld); } else { updateOnScreenDrag(); } } /** Mouse is in the slide zone — compute intersection and keep piece off-screen. */ function updateSlideZoneDrag(mouseWorld: DoubleCoords): void { draganimation.setForceRankFileOutline(true); // droparrows has already snapped the drag position and queued a moveArrow shift for the // captured piece's location — don't overwrite it with an animateArrow shift. if (droparrows.getCaptureCoords() !== undefined) return; const mouseBDCoords: BDCoords = space.convertWorldSpaceToCoords(mouseWorld); const pieceBDCoords: BDCoords = bdcoords.FromCoords(candidate!.pieceCoords); const arrowDir = candidate!.direction; const perpDir = vectors.getPerpendicularVector(arrowDir); // Line 1: through mouse in arrow direction. const line1 = vectors.getLineGeneralFormFromCoordsAndVecBD(mouseBDCoords, arrowDir); // Line 2: through piece, perpendicular to arrow direction. const line2 = vectors.getLineGeneralFormFromCoordsAndVecBD(pieceBDCoords, perpDir); // Intersection gives the dragged piece's board position. const intersectionBD: BDCoords | undefined = geometry.calcIntersectionPointOfLinesBD( ...line1, ...line2, ); if (!intersectionBD) return; // Lines are parallel (shouldn't happen with perpendicular lines). const intersectionWorld: DoubleCoords = space.convertCoordToWorldSpace(intersectionBD); const hoveredCoords: Coords = space.roundCoords(intersectionBD); draganimation.setDragLocationAndHoverSquare(intersectionWorld, hoveredCoords); // Queue arrow shifts — animateArrow handles deletion of the original arrow and places // animated arrows (for each applicable slide direction) at the intersection. arrowshifts.animateArrow(candidate!.pieceCoords, intersectionBD, candidate!.pieceType); } /** Mouse is outside the slide zone — piece follows mouse normally, original arrow removed. */ function updateOnScreenDrag(): void { draganimation.setForceRankFileOutline(false); // droparrows has already queued a moveArrow shift — don't overwrite it with a deleteArrow. if (droparrows.getCaptureCoords() !== undefined) return; // Delete the original arrow. Normal drag rendering takes over. arrowshifts.deleteArrow(candidate!.pieceCoords); } // Cleanup ----------------------------------------------------------------------------- /** Resets all drag arrow state. Called when the drag naturally completes or is force-cleared. */ function reset(): void { // console.error('Resetting state'); candidate = undefined; isDragActive = false; currentlyInSlideZone = false; candidateAnimStartTime = 0; draganimation.setForceRankFileOutline(false); } // Rendering --------------------------------------------------------------------------- /** Renders all dragarrows visuals: the slide zone gradient and the slide move highlights. */ function render(): void { if (!arrows.areArrowsActiveThisFrame()) return; renderCandidateArrows(); renderSlideZone(); renderSlideMoveHighlights(); } /** * Renders two animated arrowhead triangles on either side of the candidate arrow indicator, * perpendicular to the arrow direction, while awaiting drag activation. */ function renderCandidateArrows(): void { if (!candidate || isDragActive) return; const worldLocation = findCandidateHoveredArrow()?.worldLocation; if (!worldLocation) return; const halfWidth = arrowscalculator.getArrowIndicatorHalfWidth(); const size = halfWidth * ARROW_SIZE_RATIO; // Determine the perpendicular axis from the indicator's screen position by measuring // the raw world-space distance to each edge pair. The indicator sits on whichever edge is closer. const screenBox = camera.getScreenBoundingBox(false); const cx = worldLocation[0]; const cy = worldLocation[1]; const topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar()); const bottomBarDepth = space.convertPixelsToWorldSpace_Virtual( guigameinfo.getHeightOfGameInfoBar(), ); const distToHorizontalEdge = screenBox.right - Math.abs(cx); const distToVerticalEdge = Math.min( screenBox.top - topBarDepth - cy, cy - screenBox.bottom - bottomBarDepth, ); // px/py is the unit vector along which the extra arrows oscillate let px: number, py: number; if (distToHorizontalEdge < distToVerticalEdge) { // Indicator is on the left or right edge → extra arrows go above/below px = 0; py = 1; } else { // Indicator is on the top or bottom edge → extra arrows go left/right px = 1; py = 0; } // Sine-wave oscillation with a configurable initial phase offset. const elapsed = performance.now() - candidateAnimStartTime; const phase = 2 * Math.PI * (elapsed / CANDIDATE_ANIM.PERIOD_MS + CANDIDATE_ANIM.PHASE_INITIAL); const sineOffset = halfWidth * CANDIDATE_ANIM.AMPLITUDE * 0.5 * (1 - Math.cos(phase)); const data: number[] = []; const [r, g, b, a] = CANDIDATE_ANIM.COLOR; // Render an arrowhead triangle in each perpendicular direction (+/-) for (const sign of [1, -1] as const) { const spx = sign * px; const spy = sign * py; // Center of the base of this arrowhead triangle const bx = cx + spx * (halfWidth + sineOffset); const by = cy + spy * (halfWidth + sineOffset); // Perpendicular-of-perpendicular, for the width of the triangle base const qx = -spy; const qy = spx; // Triangle: two base corners + tip // prettier-ignore data.push( bx + qx * size, by + qy * size, r, g, b, a, bx - qx * size, by - qy * size, r, g, b, a, bx + spx * size, by + spy * size, r, g, b, a, ); } createRenderable(data, 2, 'TRIANGLES', 'color', true).render(); } /** Renders a radial gradient over the slide zone when active. */ function renderSlideZone(): void { if (!isDragActive || !candidate) return; const screenBox = camera.getScreenBoundingBox(false); // Slide zone depth in world space units const depth = 2.0 * arrowscalculator.getArrowIndicatorHalfWidth() * SLIDE_ZONE_WIDTH; const dir = candidate.direction; // Build mask geometry — color values are irrelevant, only the geometry is used for stenciling. const maskData: number[] = []; const dummyColor: Color = [0, 0, 0, 1]; const topBarDepth = space.convertPixelsToWorldSpace_Virtual(guinavigation.getHeightOfNavBar()); const bottomBarDepth = space.convertPixelsToWorldSpace_Virtual( guigameinfo.getHeightOfGameInfoBar(), ); // prettier-ignore if (dir[0] > 0n) maskData.push(...primitives.Quad_Color(screenBox.right - depth, screenBox.bottom, screenBox.right, screenBox.top, dummyColor)); // prettier-ignore if (dir[0] < 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.bottom, screenBox.left + depth, screenBox.top, dummyColor)); // prettier-ignore if (dir[1] > 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.top - depth - topBarDepth, screenBox.right, screenBox.top, dummyColor)); // prettier-ignore if (dir[1] < 0n) maskData.push(...primitives.Quad_Color(screenBox.left, screenBox.bottom, screenBox.right, screenBox.bottom + depth + bottomBarDepth, dummyColor)); if (maskData.length === 0) return; const maskRenderable = createRenderable(maskData, 2, 'TRIANGLES', 'color', true); maskedDraw.execute( () => maskRenderable.render(), undefined, () => renderRadialGradient( SLIDE_ZONE_GRADIENT.COLORS, SLIDE_ZONE_GRADIENT.SPACING, slideZonePhase, ), 'and', ); } /** * Renders a full-screen radial gradient emanating from the screen center. * Colors repeat outward with the given spacing (world units) and phase offset. */ function renderRadialGradient(colors: Color[], spacing: number, phase: number): void { const screenBox = camera.getScreenBoundingBox(false); const maxX = Math.max(Math.abs(screenBox.left), Math.abs(screenBox.right)); const maxY = Math.max(Math.abs(screenBox.top), Math.abs(screenBox.bottom)); const radius = Math.sqrt(maxX * maxX + maxY * maxY); const data = primitives.RadialGradient(0, 0, radius, colors, spacing, phase, 360); if (data.length > 0) createRenderable(data, 2, 'TRIANGLES', 'color', true).render(); } /** * When dragging an arrow indicator and the mouse is inside the slide zone, * renders white box outlines along the piece's sliding direction, * showing you what squares you can reach next by sliding the piece there. */ function renderSlideMoveHighlights(): void { if (!candidate || !currentlyInSlideZone) return; const hoveredCoords = draganimation.getHoveredCoords(); if (!hoveredCoords) return; const gamefile = gameslot.getGamefile()!; const pieceType = candidate.pieceType; // Get the piece's moveset const moveset = legalmoves.getPieceMoveset(gamefile.boardsim, pieceType); // Find the canonical moveset sliding key (x-component is never negative in moveset keys) const normalizedVec: Vec2 = vectors.absVector(candidate.direction); const lineKey: Vec2Key = vectors.getKeyFromVec2(normalizedVec); // If the slide direction is orthogonal, skip. The entire orthogonal lines are already outlined in draganimation.ts if (normalizedVec[0] === 0n || normalizedVec[1] === 0n) return; // Only proceed if the piece actually slides in this direction if (!moveset.sliding?.[lineKey]) return; // For pieces that skip squares (e.g. knightriders), the hovered square may not be // a valid landing spot for the piece from its actual position. Skip in that case. const draggedPiece = boardutil.getPieceFromCoords( gamefile.boardsim.pieces, candidate.pieceCoords, )!; const legalMoves: LegalMoves = legalmoves.getEmptyLegalMoves(moveset); legalmoves.appendPotentialMoves(draggedPiece, moveset, legalMoves); // Appending potential is enough if (!legalmoves.doSlideRangesContainSquare(legalMoves, candidate.pieceCoords, hoveredCoords)) return; // Create a virtual piece at the hovered coords for move calculation const piece: Piece = { type: pieceType, coords: hoveredCoords, index: -1 }; // Build premove-style LegalMoves containing ONLY the arrow's sliding direction. // Premoves ignore friendly/enemy blocking (only voids and world border restrict). const moves: LegalMoves = legalmoves.getEmptyLegalMoves(moveset); moves.sliding[lineKey] = moveset.sliding[lineKey]!; legalmoves.removeObstructedMoves( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piece, moveset, moves, true, // premove = true: only voids and world border restrict movement ); // Render white box outlines for all reachable squares using the shared transform const model = legalmovemodel.generateModelForSlideHighlightOutlines(hoveredCoords, moves); const { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset()); model.render(position, scale); } // Exports ------------------------------------------------------------------------------ export default { update, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/dragging/droparrows.ts ================================================ // src/client/scripts/esm/game/rendering/dragging/droparrows.ts /** * This script handles dropping the dragged piece onto * arrow indicators to capture the piece the arrow * is pointing to. */ import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { Coords } from '../../../../../../shared/chess/util/coordutil.js'; import typeutil from '../../../../../../shared/chess/util/typeutil.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import legalmoves from '../../../../../../shared/chess/logic/legalmoves.js'; import space from '../../misc/space.js'; import arrows from '../arrows/arrows.js'; import gameslot from '../../chess/gameslot.js'; import selection from '../../chess/selection.js'; import arrowshifts from '../arrows/arrowshifts.js'; import frametracker from '../frametracker.js'; import draganimation from './draganimation.js'; // Constants ------------------------------------------------------------------------------- /** Settings for the opacity pulsation on legally capturable arrow indicators. */ const LEGAL_CAPTURE_PULSATE = { /** Period of the oscillation, in milliseconds. */ PERIOD_MS: 700, /** Lower bound of the opacity oscillation. */ MIN_OPACITY: 0.4, } as const; // State ----------------------------------------------------------------------------------- let capturedPieceThisFrame: Piece | undefined; /** Timestamp when the current drag started, used to anchor the pulsation phase to 0. */ let dragStartTime: number | undefined; // Functions ------------------------------------------------------------------------------- /** * Update the piece that would be captured if we were to let * go of the dragged piece right now and return those coordinates if so. * * CALL BEFORE shiftArrows() */ function updateCapturedPiece(): void { if (!draganimation.areDraggingPiece()) throw Error('Should not be updating droparrows when not dragging a piece!'); capturedPieceThisFrame = undefined; const selectedPiece = selection.getPieceSelected()!; const selectedPieceLegalMoves = selection.getLegalMovesOfSelectedPiece()!; const selectedPieceColor = typeutil.getColorFromType(selectedPiece.type); // Test if the mouse is hovering over any arrow let hoveredArrows = arrows.getHoveredArrows(); // Filter out the selected piece, and floating point arrows (animated ones) hoveredArrows = hoveredArrows.filter((arrow) => { if (arrow.piece.floating) return false; // Filter animated arrows const integerCoords = bdcoords.coordsToBigInt(arrow.piece.coords); return !coordutil.areCoordsEqual(integerCoords, selectedPiece.coords); }); // For each of the hovered arrows, test if capturing is legal const legalCaptureHoveredArrows = hoveredArrows.filter((arrow) => { return legalmoves.checkIfMoveLegal( gameslot.getGamefile()!, selectedPieceLegalMoves, selectedPiece.coords, bdcoords.coordsToBigInt(arrow.piece.coords), selectedPieceColor, ); }); if (legalCaptureHoveredArrows.length === 0) return; // No arrow being hovered over is legal to capture by the dragged piece const legalCapturePiece = legalCaptureHoveredArrows[0]!.piece; // console.log(JSON.stringify(legalCaptureHoveredArrows)); capturedPieceThisFrame = { type: legalCapturePiece.type, coords: bdcoords.coordsToBigInt(legalCapturePiece.coords), index: legalCapturePiece.index, }; } function getCaptureCoords(): Coords | undefined { return capturedPieceThisFrame?.coords; } /** * Shifts an arrow indicator if we are hovering the dragged piece over a capturable arrow. * * DO AFTER selection.update(). Because making a move changes the board. */ function shiftArrows(): void { if (!draganimation.areDraggingPiece()) return; const selectedPiece = selection.getPieceSelected()!; // Modify the arrow indicators to reflect the potentialcapture let newLocationOfSelectedPiece: Coords | undefined; if (capturedPieceThisFrame !== undefined) { // Reflect the dragged piece's new location in draganimation.ts const worldCoords = space.convertCoordToWorldSpace( bdcoords.FromCoords(capturedPieceThisFrame.coords), ); draganimation.setDragLocationAndHoverSquare(worldCoords, capturedPieceThisFrame.coords); // Delete the captured piece arrow arrowshifts.deleteArrow(capturedPieceThisFrame.coords); // Place the selected piece's arrow location on it newLocationOfSelectedPiece = capturedPieceThisFrame.coords; } // Shift the arrow of the selected piece if (newLocationOfSelectedPiece) arrowshifts.moveArrow(selectedPiece.coords, newLocationOfSelectedPiece); // Or just delete if there's no new integer destination else arrowshifts.deleteArrow(selectedPiece.coords); } /** * Every frame while dragging, iterates all visible arrow indicators and pulsates * the opacity of any that the dragged piece could legally capture. */ function updateLegalCaptureArrows(): void { if (!draganimation.areDraggingPiece()) return; const gamefile = gameslot.getGamefile()!; const selectedPiece = selection.getPieceSelected()!; const selectedPieceLegalMoves = selection.getLegalMovesOfSelectedPiece()!; const selectedPieceColor = typeutil.getColorFromType(selectedPiece.type); if (dragStartTime === undefined) dragStartTime = performance.now(); const phase = (2 * Math.PI * (performance.now() - dragStartTime)) / LEGAL_CAPTURE_PULSATE.PERIOD_MS; const alpha = LEGAL_CAPTURE_PULSATE.MIN_OPACITY + ((1 - LEGAL_CAPTURE_PULSATE.MIN_OPACITY) * (Math.cos(phase) + 1)) / 2; let hasCapturable = false; for (const arrow of arrows.getAllArrows()) { if (arrow.piece.floating) continue; const intCoords = bdcoords.coordsToBigInt(arrow.piece.coords); if (coordutil.areCoordsEqual(intCoords, selectedPiece.coords)) continue; // When a capture is pending, the arrows at the capture destination have been replaced // by the dragged piece's own arrows — skip them so they don't pulsate. if ( capturedPieceThisFrame !== undefined && coordutil.areCoordsEqual(intCoords, capturedPieceThisFrame.coords) ) continue; if ( legalmoves.checkIfMoveLegal( gamefile, selectedPieceLegalMoves, selectedPiece.coords, intCoords, selectedPieceColor, ) ) { arrow.opacity = alpha; hasCapturable = true; } } if (hasCapturable) frametracker.onVisualChange(); } function onDragTermination(): void { capturedPieceThisFrame = undefined; dragStartTime = undefined; } export default { updateCapturedPiece, getCaptureCoords, shiftArrows, updateLegalCaptureArrows, onDragTermination, }; ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/EffectZoneManager.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/EffectZoneManager.ts import boardtiles from '../boardtiles'; import ImageLoader from '../../../util/ImageLoader'; import preferences from '../../../components/header/preferences'; import frametracker from '../frametracker'; import TextureLoader from '../../../webgl/TextureLoader'; import { OceanZone } from './zones/OceanZone'; import { StaticZone } from './zones/StaticZone'; import { EchoRiftZone } from './zones/EchoRiftZone'; import { ProgramManager } from '../../../webgl/ProgramManager'; import { EmberVergeZone } from './zones/EmberVergeZone'; import { DustyWastesZone } from './zones/DustyWastesZone'; import { PostProcessPass } from '../../../webgl/post_processing/PostProcessingPipeline'; import { IridescenceZone } from './zones/IridescenceZone'; import { AshfallVocsZone } from './zones/AshfallVocsZone'; import { TheBeginningZone } from './zones/TheBeginningZone'; import { UndercurrentZone } from './zones/UndercurrentZone'; import { SpectralEdgeZone } from './zones/SpectralEdgeZone'; import { ContortionFieldZone } from './zones/ContortionFieldZone'; /** * Defines a zone in space that applies a specific visual effect to the board. */ interface EffectZone { /** A unique name for the zone, for debugging. */ readonly name: string; /** The closest tile that this zone effect starts at. */ readonly start: bigint; /** * Whether this zone uses advanced visual effects. If true, then * the Advanced Effects settings toggle may disable the zone. */ readonly advancedEffect?: boolean; } /** Union of all Zone names. */ type ZoneName = (typeof EffectZoneManager.ZONES)[number]['name']; /** * A constructed Zone, with methods for updating, obtaining * relevant uniforms, and obtaining post-process passes. */ export interface Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number; /** Dynamically updates the zone effect. */ readonly update: () => void; /** Returns the uniforms needed to send to the gpu. */ readonly getUniforms: () => Record; /** Returns the current post processing pass effects for this zone. */ readonly getPasses: () => PostProcessPass[]; /** Fades in the ambience. */ readonly fadeInAmbience: (_transitionDurationMillis: number) => void; /** Fades out the ambience, then stops the track playing. */ readonly fadeOutAmbience: (_transitionDurationMillis: number) => void; } /** * Manages which visual effect is applied to the board based on distance from the origin, * and handles smooth, timed transitions between effect zones. */ export class EffectZoneManager { // prettier-ignore static readonly ZONES = [ // Define zones in ascending order of their start distance. { name: 'The Beginning', start: 0n, advancedEffect: false }, // 0 // [PRODUCTION] Default distances: { name: 'Undercurrent', start: 10n ** 3n, advancedEffect: false }, // 1 { name: 'Contortion Field', start: 10n ** 40n, advancedEffect: true }, // 3 { name: 'Ocean', start: 10n ** 80n, advancedEffect: true }, // 10 { name: 'Spectral Edge', start: 10n ** 120n, advancedEffect: true }, // 4 { name: 'Iridescence', start: 10n ** 180n, advancedEffect: true }, // 5 { name: 'Ember Verge', start: 10n ** 330n, advancedEffect: true }, // 11 { name: 'Ashfall Vocs', start: 10n ** 390n, advancedEffect: true }, // 9 { name: 'Dusty Wastes', start: 10n ** 540n, advancedEffect: true }, // 6 { name: 'Static', start: 10n ** 690n, advancedEffect: true }, // 7 { name: 'Echo Rift', start: 10n ** 940n, advancedEffect: true }, // 8 // [TESTING] Much shorter distances: // { name: 'Undercurrent', start: 20n, advancedEffect: false }, // 1 // { name: 'Contortion Field', start: 40n, advancedEffect: true }, // 3 // { name: 'Ocean', start: 60n, advancedEffect: true }, // 10 // { name: 'Spectral Edge', start: 80n, advancedEffect: true }, // 4 // { name: 'Iridescence', start: 100n, advancedEffect: true }, // 5 // { name: 'Ember Verge', start: 120n, advancedEffect: true }, // 11 // { name: 'Ashfall Vocs', start: 140n, advancedEffect: true }, // 9 // { name: 'Dusty Wastes', start: 160n, advancedEffect: true }, // 6 // { name: 'Static', start: 180n, advancedEffect: true }, // 7 // { name: 'Echo Rift', start: 200n, advancedEffect: true }, // 8 ] as const satisfies Readonly[]; /** A reference to the WebGL rendering context. */ private gl: WebGL2RenderingContext; /** The constructed Zones. */ private zones: Record; /** The perlin noise texture used for cloudy effects. */ private perlinNoiseTexture: WebGLTexture | undefined; /** The white noise texture used for static effects. */ private whiteNoiseTexture: WebGLTexture | undefined; // --- Transition State --- /** How long a transition between zones should take, in milliseconds. */ private transitionDuration: number = 1500; /** The timestamp when the current transition started, or null if no transition is happening. */ private transitionStartTime: number | null = null; /** The current zone we are in, or transitioning out of. */ private currentZone: Zone; /** The zone we are transitioning into, or null if no transition is happening. */ private transitionTargetZone: Zone | null = null; /** 0.0 = fully currentZone, 1.0 = fully targetZone */ private transitionProgress: number = 0.0; constructor(gl: WebGL2RenderingContext, programManager: ProgramManager) { this.gl = gl; // Load perlin noise texture const noiseTexture: Promise = ImageLoader.loadImage( 'img/noise_texture/perlin_noise.webp', ).then((image) => { const texture = TextureLoader.loadTexture(gl, image); this.perlinNoiseTexture = texture; return texture; }); // Load white noise texture ImageLoader.loadImage('img/noise_texture/white_noise.webp').then((image) => { // Ensure texture filtering is set to NEAREST for a sharp, pixelated look const texture = TextureLoader.loadTexture(gl, image, { mipmaps: false }); this.whiteNoiseTexture = texture; }); // Construct Zones // prettier-ignore this.zones = { 'The Beginning': new TheBeginningZone(), 'Undercurrent': new UndercurrentZone(), 'Contortion Field': new ContortionFieldZone(programManager), 'Ocean': new OceanZone(programManager), 'Spectral Edge': new SpectralEdgeZone(), 'Iridescence': new IridescenceZone(), 'Ember Verge': new EmberVergeZone(), 'Ashfall Vocs': new AshfallVocsZone(programManager, noiseTexture), 'Dusty Wastes': new DustyWastesZone(programManager), 'Static': new StaticZone(programManager), 'Echo Rift': new EchoRiftZone(programManager), }; this.currentZone = this.zones['The Beginning']; // Set up a listener for the ambience-enabled preference changing. document.addEventListener('ambience-toggle', (event) => { // Turn on/off the ambience of the current zone (and transition target zone, if applicable). const enabled: boolean = event.detail; if (!enabled) { // Fade out any currently playing ambience. this.currentZone.fadeOutAmbience(this.transitionDuration); this.transitionTargetZone?.fadeOutAmbience(this.transitionDuration); } else { // If we're mid-transition, fade in the target zone's ambience. if (this.transitionTargetZone) this.transitionTargetZone.fadeInAmbience(this.transitionDuration); // Otherwise, fade in the current zone's ambience. else this.currentZone.fadeInAmbience(this.transitionDuration); } }); } /** * Finds the active zone for a given distance from the origin. */ private findZoneForDistance(distance: bigint): Zone { const advancedEnabled = preferences.getAdvancedEffectsMode(); let furthestZone: Zone | undefined; // Iterate through all proceeding zones in reverse to find // the furthest one that starts before our current distance. for (let i = EffectZoneManager.ZONES.length - 1; i >= 0; i--) { const zone = EffectZoneManager.ZONES[i]!; if (!advancedEnabled && zone.advancedEffect) continue; // Skip zones requiring advanced effects if they're disabled if (distance >= zone.start) { furthestZone = this.zones[zone.name]; break; } } if (!furthestZone) throw new Error(`No effect zones for distance ${distance}`); return furthestZone; } /** * Detects if we should transition to a new zone, * updates transitionProgress, and updates zone states. */ public update(distanceFromOrigin: bigint): void { // --- 1. UPDATE TRANSITION STATE --- if (this.transitionStartTime !== null && this.transitionTargetZone) { const elapsedTime = Date.now() - this.transitionStartTime; if (elapsedTime >= this.transitionDuration) { this.currentZone = this.transitionTargetZone; this.transitionTargetZone = null; this.transitionStartTime = null; } } // --- 2. DETECT NEW ZONE CROSSINGS --- const targetZoneForDistance = this.findZoneForDistance(distanceFromOrigin); if ( this.transitionStartTime === null && // Only start a NEW transition if one isn't already active targetZoneForDistance !== this.currentZone ) { // A new transition needs to start. // console.log('Starting transition to new zone.'); this.transitionTargetZone = targetZoneForDistance; this.transitionStartTime = Date.now(); // Fade out the current zone's ambience and fade in the transitionTargetZone's if (preferences.getAmbienceEnabled()) { this.currentZone.fadeOutAmbience(this.transitionDuration); this.transitionTargetZone.fadeInAmbience(this.transitionDuration); } } else if ( this.transitionTargetZone && // A transition is active targetZoneForDistance === this.currentZone && // And we've moved back into the 'from' zone's area this.transitionTargetZone !== this.currentZone // And we're not already reversing ) { // The user has changed their mind and is moving back. We need to reverse the transition. // console.log(`Reversing transition. Now going from ${this.transitionTargetZone.name} to ${this.currentZone.name}`); // 1. The 'from' and 'to' zones are swapped. const oldTarget = this.transitionTargetZone; this.transitionTargetZone = this.currentZone; this.currentZone = oldTarget; // 2. The timer is reversed. const elapsedTime = Date.now() - this.transitionStartTime!; const remainingTime = this.transitionDuration - elapsedTime; this.transitionStartTime = Date.now() - remainingTime; // Fade out the current zone's ambience and fade in the transitionTargetZone's if (preferences.getAmbienceEnabled()) { this.currentZone.fadeOutAmbience(elapsedTime); this.transitionTargetZone.fadeInAmbience(elapsedTime); } } // --- 3. UPDATE TRANSITION PROGRESS OF ACTIVE EFFECTS --- // Recalculate alpha for this frame's render pass. this.transitionProgress = this.transitionStartTime && this.transitionTargetZone ? Math.min((Date.now() - this.transitionStartTime) / this.transitionDuration, 1.0) : 0.0; // Debugging // console.log( // `Current: ${fromZone.name}, `, // `Target: ${toZone.name}, `, // `Alpha: ${transitionAlpha.toFixed(2)}` // ); // Update individual zone states this.currentZone.update(); if (this.transitionTargetZone) this.transitionTargetZone.update(); // Only all for an animation frame if the current zone isn't the origin, or if we're mid-transition. // This ensures cpu usage isn't spiked from Zone Effects when near origin. if ( (this.currentZone !== this.zones['The Beginning'] && this.currentZone !== this.zones['Undercurrent']) || this.transitionTargetZone ) frametracker.onVisualChange(); } /** * Renders the board tiles with all active Zones effects applied. */ public renderBoard(): void { const fromZone = this.currentZone; const toZone = this.transitionTargetZone || this.currentZone; // Construct the uniform object for the Uber-Shader const uniforms: Record = { // Global uniforms u_transitionProgress: this.transitionProgress, u_resolution: [this.gl.canvas.width, this.gl.canvas.height], u_pixelDensity: window.devicePixelRatio, // Zone uniforms u_effectTypeA: fromZone.effectType, u_effectTypeB: toZone.effectType, ...fromZone.getUniforms(), ...toZone.getUniforms(), }; // Render board tiles boardtiles.render( { perlinNoise: this.perlinNoiseTexture, whiteNoise: this.whiteNoiseTexture, }, uniforms, ); } /** * Returns an array of all post-process effects that should be active * this frame, according to the distance we are from the origin, * with their masterStrength properties set appropriately. */ public getActivePostProcessPasses(): PostProcessPass[] { const activePasses: PostProcessPass[] = []; const fromZonePasses = this.currentZone.getPasses(); fromZonePasses.forEach((pass) => (pass.masterStrength = 1.0 - this.transitionProgress)); activePasses.push(...fromZonePasses); if (this.transitionTargetZone) { const toZonePasses = this.transitionTargetZone.getPasses(); toZonePasses.forEach((pass) => (pass.masterStrength = this.transitionProgress)); activePasses.push(...toZonePasses); } return activePasses; } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/soundscapes/IridescenceSoundscape.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/soundscapes/IridescenceSoundscape.ts import type { LayerConfig } from '../../../../audio/SoundLayer'; /** The first two layers of the Iridescence soundscape (lower pitch). */ const layers12: LayerConfig[] = [ { volume: { base: 0.015, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 418, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, { type: 'lowpass', frequency: { base: 418, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, ], }, { volume: { base: 0.12, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 631, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, { type: 'bandpass', frequency: { base: 631, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, ], }, ]; /** The third and fourth layers of the Iridescence soundscape (higher pitch). */ const layers34: LayerConfig[] = [ { volume: { base: 0.2, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 851, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, { type: 'bandpass', frequency: { base: 850, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, ], }, { volume: { base: 0.02, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 1714, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, { type: 'bandpass', frequency: { base: 1715, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, ], }, ]; export default { layers12, layers34, }; ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/soundscapes/UndercurrentSoundscape.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/soundscapes/UndercurrentSoundscape.ts import { LayerConfig } from '../../../../audio/SoundLayer'; import { SoundscapeConfig } from '../../../../audio/SoundscapePlayer'; /** The source of the Undercurrent soundscape layer is white noise. */ const source: LayerConfig['source'] = { type: 'noise', }; /** The filters of the Undercurrent soundscape layer. */ const filters: LayerConfig['filters'] = [ { type: 'lowpass', frequency: { base: 136, }, Q: { base: 1, }, gain: { base: 0, }, }, { type: 'lowpass', frequency: { base: 138, }, Q: { base: 1, }, gain: { base: 0, }, }, ]; /** The complete configuration for the Undercurrent soundscape. */ const config: SoundscapeConfig = { masterVolume: 0.36, layers: [ { volume: { base: 1, }, source, filters, }, ], }; export default { source, filters, config, }; ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/AshfallVocsZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/AshfallVocsZone.ts import type { Zone } from '../EffectZoneManager'; import { HeatWavePass } from '../../../../webgl/post_processing/passes/HeatWavePass'; import { VignettePass } from '../../../../webgl/post_processing/passes/VignettePass'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class AshfallVocsZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 9; private colorGradePass: ColorGradePass; private vignettePass: VignettePass; private heatWavePass: HeatWavePass | undefined = undefined; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; /** The speed of the moving heat waves. */ private heatWaveSpeed: number = 2.0; constructor(programManager: ProgramManager, noise: Promise) { noise.then((texture) => (this.heatWavePass = new HeatWavePass(programManager, texture))); this.colorGradePass = new ColorGradePass(programManager); this.colorGradePass.saturation = 2; this.colorGradePass.contrast = 1.4; this.colorGradePass.brightness = -0.35; this.colorGradePass.tint = [1.0, 0.4, 0.4]; this.vignettePass = new VignettePass(programManager); this.vignettePass.radius = 0.3; this.vignettePass.softness = 0.5; this.vignettePass.intensity = 0.7; // Load the ambience... const noiseConfig: SoundscapeConfig = { masterVolume: 0.36, layers: [ ...UndercurrentSoundscape.config.layers, { // High pitched sizzling volume: { base: 0.005, lfo: { wave: 'perlin', rate: 0.22, depth: 0.002, }, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 10000, }, Q: { base: 0.9601, }, gain: { base: 0, }, }, ], }, ], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(noiseConfig); } public update(): void { if (this.heatWavePass) this.heatWavePass.time = (performance.now() / 1000) * this.heatWaveSpeed; } public getUniforms(): Record { return {}; } public getPasses(): PostProcessPass[] { const passes: PostProcessPass[] = [this.colorGradePass, this.vignettePass]; if (this.heatWavePass) passes.push(this.heatWavePass); return passes; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/ContortionFieldZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/ContortionFieldZone.ts import type { Zone } from '../EffectZoneManager'; import loadbalancer from '../../../misc/loadbalancer'; import { SineWavePass } from '../../../../webgl/post_processing/passes/SineWavePass'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; export class ContortionFieldZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 3; /** Post Processing Effect creating heat waves. */ private sineWavePass: SineWavePass; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; /** How fast the sine waves oscillate. */ private oscillationSpeed: number = 1.0; /** How fast the sine waves rotates, in degrees per second. */ private rotationSpeed: number = 3.0; constructor(programManager: ProgramManager) { this.sineWavePass = new SineWavePass(programManager); // Load the ambience... // Initialize the player with the config. this.ambience = new SoundscapePlayer(UndercurrentSoundscape.config); } public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // Seconds this.sineWavePass.time = (performance.now() / 1000) * this.oscillationSpeed; this.sineWavePass.angle += this.rotationSpeed * deltaTime; } public getUniforms(): Record { return {}; } public getPasses(): PostProcessPass[] { return [this.sineWavePass]; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/DustyWastesZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/DustyWastesZone.ts import type { Zone } from '../EffectZoneManager'; import loadbalancer from '../../../misc/loadbalancer'; import { GlitchPass } from '../../../../webgl/post_processing/passes/GlitchPass'; import { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class DustyWastesZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 6; private colorGradePass: ColorGradePass; private glitchPass: GlitchPass; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; // --- Wind Effect Properties --- /** The opacity of the wind effect. */ private windOpacity: number = 0.4; // Default: 0.35 /** How many times the noise texture should tile the screen. */ private noiseTiling: number = 1.25; /** The average wind speed. */ private windSpeed: number = 0.7; /** How much faster one scroll speed is greater than the other. */ private windSpeedsOffset: number = 1.2; /** The vector offset in radians each scroll vector is from each other. */ private windDirectionsOffset: number = 0.6; /** The direction the wind is rotating. Clockwise or counter-clockwise. */ private windRotationParity: -1 | 1 = Math.random() < 0.5 ? -1 : 1; /** The speed at which the wind direction rotates, in radians per second. */ private windRotationSpeed: number = 0.0025; // --- Glitch Effect Properties --- /** A multiplier for the chromatic aberration strength. */ private aberrationStrengthMultiplier: number = 1.3; /** Minimum amount of trauma to add per glitch burst. */ private minTraumaToAdd: number = 0.5; /** Maximum amount of trauma to add per glitch burst. */ private maxTraumaToAdd: number = 1.5; /** Intensity decreases by this amount per second. */ private decayRate: number = 2.0; /** Minimum seconds between glitch bursts. */ private minInterval: number = 1.0; /** Maximum seconds between glitch bursts. */ private maxInterval: number = 7.0; /** Maximum allowed glitch intensity ("trauma") before clamping. */ private maxTrauma: number = 2.0; // ============ State ============ // --- Wind Animation State --- /** The wind direction in radians. 0 is to the right. */ private windDirection: number = Math.random() * Math.PI * 2; /** The accumulated UV offset for the first noise layer. Wrapped to [0,1]. */ private uvOffset1: [number, number] = [0, 0]; /** The accumulated UV offset for the second noise layer. Wrapped to [0,1]. */ private uvOffset2: [number, number] = [0, 0]; // --- Glitch "Trauma" Animation State --- /** Current "trauma" level, from 0.0 to 1.0+. */ private glitchIntensity: number = 0.0; /** Countdown timer in seconds until the next glitch burst. */ private timeUntilNextGlitch: number = 0.0; constructor(programManager: ProgramManager) { this.colorGradePass = new ColorGradePass(programManager); this.colorGradePass.brightness = -0.2; // Default: 0.7 this.colorGradePass.tint = [1.0, 0.75, 0.7]; // Slight red tint this.glitchPass = new GlitchPass(programManager); // Load the ambience... const noiseConfig: SoundscapeConfig = { masterVolume: 0.14, layers: [ { volume: { base: 1, lfo: { wave: 'perlin', rate: 0.76, depth: 0.12, }, }, source: { type: 'noise', }, filters: [ { type: 'lowpass', frequency: { base: 271, }, Q: { base: 1.0001, }, gain: { base: 0, }, }, ], }, { volume: { base: 0.5, }, source: { type: 'noise', }, filters: [ { type: 'bandpass', frequency: { base: 909, lfo: { wave: 'perlin', rate: 0.47, depth: 203, }, }, Q: { base: 29.9901, }, gain: { base: 0, }, }, { type: 'bandpass', frequency: { base: 909, lfo: { wave: 'perlin', rate: 0.35, depth: 201, }, }, Q: { base: 10.7801, }, gain: { base: 0, }, }, ], }, ], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(noiseConfig); } /** Responsible for calculating the exact UV offsets of the noise texture layers each frame. */ public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // --- Wind update logic --- // Optional animation of other properties // this.windSpeed = math.getSineWaveVariation(Date.now() / 1000, 0, 0.9); // this.windDirectionsOffset = math.getSineWaveVariation(Date.now() / 1000, 0, 2.5); // this.windSpeedsOffset = math.getSineWaveVariation(Date.now() / 1000, 1, 2.0); // Animate the wind direction. this.windDirection += this.windRotationSpeed * this.windRotationParity * deltaTime; if (this.windDirection > Math.PI * 2) this.windDirection -= Math.PI * 2; else if (this.windDirection < 0) this.windDirection += Math.PI * 2; // Calculate the instantaneous velocity vectors for this frame. const angle1 = this.windDirection - this.windDirectionsOffset / 2; const angle2 = this.windDirection + this.windDirectionsOffset / 2; const velocity1 = [Math.cos(angle1) * this.windSpeed, Math.sin(angle1) * this.windSpeed]; const velocity2 = [ Math.cos(angle2) * this.windSpeed * this.windSpeedsOffset, Math.sin(angle2) * this.windSpeed * this.windSpeedsOffset, ]; // 3. Integrate: Add the displacement for this frame (velocity * deltaTime) to the total offset. this.uvOffset1[0] += (velocity1[0]! * deltaTime) % 1; this.uvOffset1[1] += (velocity1[1]! * deltaTime) % 1; this.uvOffset2[0] += (velocity2[0]! * deltaTime) % 1; this.uvOffset2[1] += (velocity2[1]! * deltaTime) % 1; // --- Glitch update logic --- // 1. Always decay the current glitch intensity this.glitchIntensity = Math.max(0, this.glitchIntensity - this.decayRate * deltaTime); // 2. Check if it's time to trigger a new glitch burst this.timeUntilNextGlitch -= deltaTime; if (this.timeUntilNextGlitch <= 0) { // Add a random amount of "trauma" const traumaToAdd = this.minTraumaToAdd + Math.random() * (this.maxTraumaToAdd - this.minTraumaToAdd); this.glitchIntensity = Math.min(this.glitchIntensity + traumaToAdd, this.maxTrauma); // Clamp at maxTrauma this.randomizeNextGlitchTimer(); // Reset the timer for the next burst } // 3. Apply the current intensity to the shader pass properties // Use powers to make the visual effect more "bursty" and less linear const intensity = this.glitchIntensity * this.glitchIntensity; this.glitchPass.tearStrength = intensity; this.glitchPass.aberrationStrength = this.glitchIntensity * this.aberrationStrengthMultiplier; // 4. Keep the shader's internal time moving for tear pattern animation this.glitchPass.time += deltaTime; } // Copied from GlitchZone for combined effect private randomizeNextGlitchTimer(): void { this.timeUntilNextGlitch = this.minInterval + Math.random() * (this.maxInterval - this.minInterval); } public getUniforms(): Record { // Pass the final accumulated offsets directly to the shader. return { u6_strength: this.windOpacity, u6_noiseTiling: this.noiseTiling, u6_uvOffset1: this.uvOffset1, u6_uvOffset2: this.uvOffset2, }; } public getPasses(): PostProcessPass[] { return [this.colorGradePass, this.glitchPass]; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/EchoRiftZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/EchoRiftZone.ts import type { Zone } from '../EffectZoneManager'; import gamesound from '../../../misc/gamesound'; import PerlinNoise from '../../../../util/PerlinNoise'; import preferences from '../../../../components/header/preferences'; import AudioManager from '../../../../audio/AudioManager'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { VoronoiDistortionPass } from '../../../../webgl/post_processing/passes/VoronoiDistortionPass'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class EchoRiftZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 8; private colorGradePass: ColorGradePass; /** Post Processing Effect bending light through a crystalline voronoi distortion pattern structure. */ private voronoiDistortionPass: VoronoiDistortionPass; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; /** A 1D Perlin noise generator for randomizing color grade properties. */ private noiseGenerator: (_t: number) => number; /** How "zoomed in" the Perlin noise is. Higher values = smoother/slower noise. */ private noiseZoom: number = 3000; /** The base brightness level around which the brightness will vary. */ private baseBrightness: number = -0.39; /** How much the brightness will vary above and below the base brightness. */ private brightnessVariation: number = 0.07; // ============ State ============ /** The next timestamp the voronoi distortion pass will update the time value, revealing a different pattern. */ private nextCrackTime: number = Date.now(); private baseMillisBetweenCracks: number = 400; private maxMillisBetweenCracks: number = 4000; constructor(programManager: ProgramManager) { this.voronoiDistortionPass = new VoronoiDistortionPass(programManager); this.colorGradePass = new ColorGradePass(programManager); this.colorGradePass.saturation = 0; this.colorGradePass.contrast = 0.3; this.noiseGenerator = PerlinNoise.create1DNoiseGenerator(30); // Load the ambience... const soundConfig: SoundscapeConfig = { masterVolume: 1.0, layers: [ { volume: { base: 0.7, lfo: { wave: 'perlin', rate: 1.13, depth: 0.5, }, }, source: { type: 'noise', }, filters: [ { type: 'lowpass', frequency: { base: 136, }, Q: { base: 1.0001, }, gain: { base: 0, }, }, { type: 'lowpass', frequency: { base: 139, }, Q: { base: 1.0001, }, gain: { base: 0, }, }, ], }, ], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(soundConfig); } public update(): void { // Update cracking of the voronoi distortion effect. // voronoiDistortionPass.time = 632663; // voronoiDistortionPass.time = Date.now() / 1000; // voronoiDistortionPass.time = Math.floor(performance.now() / 400) * 10; if (Date.now() > this.nextCrackTime) { this.voronoiDistortionPass.time = performance.now() / 10; this.nextCrackTime = Date.now() + this.baseMillisBetweenCracks + Math.random() * this.maxMillisBetweenCracks; if (preferences.getAmbienceEnabled()) gamesound.playGlassCrack(); } // Randomize the brightness const noiseValue = this.noiseGenerator(performance.now() / this.noiseZoom); this.colorGradePass.brightness = this.baseBrightness + noiseValue * this.brightnessVariation; } public getUniforms(): Record { return {}; } public getPasses(): PostProcessPass[] { return [this.voronoiDistortionPass, this.colorGradePass]; // return [this.colorGradePass]; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); AudioManager.fadeInDownsampler(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); AudioManager.fadeOutDownsampler(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/EmberVergeZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/EmberVergeZone.ts import type { Zone } from '../EffectZoneManager'; import loadbalancer from '../../../misc/loadbalancer'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; export class EmberVergeZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 11; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; // --- Configurable Properties --- // prettier-ignore private readonly colors: [number, number, number][] = [ [0.92, 0.82, 0.62], // Faded Gold [0.6, 0.8, 0.6], // Muted Green [0.5, 0.7, 0.9], // Muted Blue [0.8, 0.5, 0.8], // Muted Purple [0.88, 0.22, 0.15], // Molten Orange-Red [0.78, 0.05, 0.05], // Ashfall Core Red ]; /** Determines how strongly the gradient colors are blended with the original board tile colors. */ private strength: number = 0.5; /** The base speed at which the gradient texture scrolls across the screen. */ private flowSpeed: number = 0.07; /** The speed at which the flow direction changes over time, in radians per second. */ private flowRotationSpeed: number = 0.0025; /** How many times the full gradient repeats across the screen along the direction of flow. */ private gradientRepeat: number = 0.7; /** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */ private maskOffset: number = 0.07; // --- State Properties --- /** The current direction of the flow, in radians. */ private flowDirection: number = Math.random() * Math.PI * 2; constructor() { this.ambience = new SoundscapePlayer(UndercurrentSoundscape.config); } public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // In seconds // Rotate the flow direction over time. this.flowDirection += this.flowRotationSpeed * deltaTime; if (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2; else if (this.flowDirection < 0) this.flowDirection += Math.PI * 2; } public getUniforms(): Record { // Pre-calculate the direction vector const flowDirectionVec: [number, number] = [ Math.cos(this.flowDirection), Math.sin(this.flowDirection), ]; const flowDistance = (performance.now() / 1000) * this.flowSpeed; const uniforms: Record = { u11_flowDistance: flowDistance, u11_flowDirectionVec: flowDirectionVec, u11_gradientRepeat: this.gradientRepeat, u11_maskOffset: this.maskOffset, u11_strength: this.strength, }; // Add each color as a separate uniform. for (let i = 0; i < this.colors.length; i++) { // Use the color if it exists, otherwise pad with black. const color = this.colors[i] || [0, 0, 0]; uniforms[`u11_color${i + 1}`] = color; } return uniforms; } public getPasses(): PostProcessPass[] { return []; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/IridescenceZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/IridescenceZone.ts import type { Zone } from '../EffectZoneManager'; import loadbalancer from '../../../misc/loadbalancer'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import IridescenceSoundscape from '../soundscapes/IridescenceSoundscape'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class IridescenceZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 5; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; // --- Configurable Properties --- /** The array of RGB colors that defines the gradient. Passed to the shader. */ private readonly colors: [number, number, number][] = [ [1.0, 0.5, 0.5], // Soft Red [1.0, 1.0, 0.5], // Soft Yellow [0.5, 1.0, 0.5], // Soft Green [0.5, 1.0, 1.0], // Soft Cyan [0.5, 0.5, 1.0], // Soft Blue [1.0, 0.5, 1.0], // Soft Magenta ]; /** Determines how strongly the gradient colors are blended with the original board tile colors. */ private strength: number = 1; /** The base speed at which the gradient texture scrolls across the screen. */ private flowSpeed: number = 0.07; // Default: 0.07 /** The speed at which the flow direction changes over time, in radians per second. */ private flowRotationSpeed: number = 0.0025; // Default: 0.0025 /** How many times the full gradient repeats across the screen along the direction of flow. */ private gradientRepeat: number = 0.7; // Default: 1.2 /** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */ private maskOffset: number = 0.06; // Default: 0.06 // --- State Properties --- /** The current direction of the flow, in radians. */ private flowDirection: number = Math.random() * Math.PI * 2; constructor() { // Load the ambience... const noiseConfig: SoundscapeConfig = { masterVolume: 0.33, layers: [...IridescenceSoundscape.layers12, ...IridescenceSoundscape.layers34], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(noiseConfig); } public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // In seconds // Rotate the flow direction over time. this.flowDirection += this.flowRotationSpeed * deltaTime; if (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2; else if (this.flowDirection < 0) this.flowDirection += Math.PI * 2; } public getUniforms(): Record { // Pre-calculate the direction vector. const flowDirectionVec: [number, number] = [ Math.cos(this.flowDirection), Math.sin(this.flowDirection), ]; const flowDistance = (performance.now() / 1000) * this.flowSpeed; const uniforms: Record = { u5_flowDistance: flowDistance, u5_flowDirectionVec: flowDirectionVec, u5_gradientRepeat: this.gradientRepeat, u5_maskOffset: this.maskOffset, u5_strength: this.strength, }; // Add each color as a separate uniform. for (let i = 0; i < this.colors.length; i++) { // Use the color if it exists, otherwise pad with black. const color = this.colors[i] || [0, 0, 0]; uniforms[`u5_color${i + 1}`] = color; } return uniforms; } public getPasses(): PostProcessPass[] { return []; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/OceanZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/OceanZone.ts import type { Zone } from '../EffectZoneManager'; import camera from '../../camera'; import loadbalancer from '../../../misc/loadbalancer'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; import { RippleSource, WaterPass } from '../../../../webgl/post_processing/passes/WaterPass'; export class OceanZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 10; private colorGradePass: ColorGradePass; /** The post-processing pass that renders the water ripple effect from continuous sources. */ private waterPass: WaterPass; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; /** The distance from the center of the screen (in world units) to place the ripples. */ private readonly RIPPLE_DISTANCE: number = 100; /** The speed at which the circle of ripples rotates, in radians per second. */ private readonly ROTATION_SPEED: number = 0.02; // State --------------------------------------------------- /** The state of the three persistent ripple sources. */ private readonly sources: RippleSource[]; /** The direction the circle rotates. Set randomly on initialization. */ private readonly rotationDirection: 1 | -1; /** The current rotation of the entire ripple circle, in radians. */ private circleRotationAngle: number = 0; constructor(programManager: ProgramManager) { this.colorGradePass = new ColorGradePass(programManager); this.colorGradePass.saturation = 0.6; this.colorGradePass.tint = [0.9, 0.95, 1.0]; // Slight blue // Initialize the WaterPass with the current canvas dimensions. this.waterPass = new WaterPass(programManager, camera.canvas.width, camera.canvas.height); // Initialize the three permanent ripple sources. Their location will be updated each frame. this.sources = [{ center: [0, 0] }, { center: [0, 0] }, { center: [0, 0] }]; // Determine the rotation direction randomly. this.rotationDirection = Math.random() < 0.5 ? 1 : -1; this.ambience = new SoundscapePlayer(UndercurrentSoundscape.config); // Create event listener for screen resize to update water pass resolution. document.addEventListener('canvas_resize', (event) => { const { width, height } = event.detail; this.waterPass.setResolution(width, height); }); } public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // Time in seconds since last frame. // --- 1. Animate the rotation of the ripple circle --- this.circleRotationAngle += this.ROTATION_SPEED * this.rotationDirection * deltaTime; // --- 2. Define the base ripple locations on the circle --- // prettier-ignore const baseAngles = [ 0, // 0 degrees 40 * (Math.PI / 180), // 40 degrees in radians (40 + 80) * (Math.PI / 180), // 120 degrees in radians ]; // Calculate the final world positions by applying the current circle rotation. const worldPositions = baseAngles.map((angle) => ({ x: Math.cos(angle + this.circleRotationAngle) * this.RIPPLE_DISTANCE, y: Math.sin(angle + this.circleRotationAngle) * this.RIPPLE_DISTANCE, })); // --- 3. Convert world space coordinates to screen UVs [0-1] --- const screenBox = camera.getScreenBoundingBox(false); const screenWidthWorld = screenBox.right - screenBox.left; const screenHeightWorld = screenBox.top - screenBox.bottom; // Calculate the final UV for each ripple and update its source's center. for (let i = 0; i < worldPositions.length; i++) { const pos = worldPositions[i]!; const source = this.sources[i]!; const u = (pos.x - screenBox.left) / screenWidthWorld; const v = (pos.y - screenBox.bottom) / screenHeightWorld; source.center = [u, v]; } // --- 4. Feed the updated ripple source locations to the pass --- this.waterPass.updateSources(this.sources); this.waterPass.time = performance.now(); } public getUniforms(): Record { // This zone's visual effect is purely from a post-processing pass, // so it does not need to send any uniforms to the main board shader. return {}; } public getPasses(): PostProcessPass[] { // Return the water pass to be rendered by the pipeline. return [this.colorGradePass, this.waterPass]; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/SpectralEdgeZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/SpectralEdgeZone.ts import type { Zone } from '../EffectZoneManager'; import loadbalancer from '../../../misc/loadbalancer'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import IridescenceSoundscape from '../soundscapes/IridescenceSoundscape'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class SpectralEdgeZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 4; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; // --- Configurable Properties --- /** The array of RGB colors that defines the gradient. Passed to the shader. */ private readonly colors: [number, number, number][] = [ [1.0, 0.5, 0.5], // Soft Red [1.0, 1.0, 0.5], // Soft Yellow [0.5, 1.0, 0.5], // Soft Green [0.5, 1.0, 1.0], // Soft Cyan [0.5, 0.5, 1.0], // Soft Blue [1.0, 0.5, 1.0], // Soft Magenta ]; /** Determines how strongly the gradient colors are blended with the original board tile colors. */ private strength: number = 0.3; /** The base speed at which the gradient texture scrolls across the screen. */ private flowSpeed: number = 0.07; // Default: 0.07 /** The speed at which the flow direction changes over time, in radians per second. */ private flowRotationSpeed: number = 0.0025; // Default: 0.0025 /** How many times the full gradient repeats across the screen along the direction of flow. */ private gradientRepeat: number = 0.7; // Default: 1.2 /** The phase shift applied to the light tiles' gradient, as a percentage of the gradient's total length. */ private maskOffset: number = 0.07; // Default: 0.06 // --- State Properties --- /** The current direction of the flow, in radians. */ private flowDirection: number = Math.random() * Math.PI * 2; constructor() { // Load the ambience... const noiseConfig: SoundscapeConfig = { masterVolume: 0.25, layers: [ // Undercurrent layer { // Custom volume volume: { base: 0.8, }, source: UndercurrentSoundscape.source, filters: UndercurrentSoundscape.filters, }, // Partial of Iridescence layers ...IridescenceSoundscape.layers12, ], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(noiseConfig); } public update(): void { const deltaTime = loadbalancer.getDeltaTime(); // In seconds // Rotate the flow direction over time. this.flowDirection += this.flowRotationSpeed * deltaTime; if (this.flowDirection > Math.PI * 2) this.flowDirection -= Math.PI * 2; else if (this.flowDirection < 0) this.flowDirection += Math.PI * 2; } public getUniforms(): Record { // Pre-calculate the direction vector const flowDirectionVec: [number, number] = [ Math.cos(this.flowDirection), Math.sin(this.flowDirection), ]; const flowDistance = (performance.now() / 1000) * this.flowSpeed; const uniforms: Record = { u4_flowDistance: flowDistance, u4_flowDirectionVec: flowDirectionVec, u4_gradientRepeat: this.gradientRepeat, u4_maskOffset: this.maskOffset, u4_strength: this.strength, }; // Add each color as a separate uniform. for (let i = 0; i < this.colors.length; i++) { // Use the color if it exists, otherwise pad with black. const color = this.colors[i] || [0, 0, 0]; uniforms[`u4_color${i + 1}`] = color; } return uniforms; } public getPasses(): PostProcessPass[] { return []; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/StaticZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/StaticZone.ts import type { Zone } from '../EffectZoneManager'; import AudioManager from '../../../../audio/AudioManager'; import { ProgramManager } from '../../../../webgl/ProgramManager'; import { ColorGradePass } from '../../../../webgl/post_processing/passes/ColorGradePass'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapeConfig, SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; export class StaticZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 7; private colorGradePass: ColorGradePass; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; /** How many pixels wide the white noise texture is. */ private readonly TEXTURE_WIDTH = 256; /** The strength of the effect. */ private strength: number = 0.05; /** How large each "pixel" of the static should be, in screen pixels. */ private readonly PIXEL_SIZE = 6; /** How often the static pattern should change, in milliseconds. */ private readonly UPDATE_INTERVAL = 60; // private readonly UPDATE_INTERVAL = 1000; // For testing // --- STATE --- /** The last timestamp the pixels were randomized. */ private lastUpdateTime: number = 0; /** The current UV offset. */ private uvOffset: [number, number] = [0, 0]; constructor(programManager: ProgramManager) { this.colorGradePass = new ColorGradePass(programManager); this.colorGradePass.saturation = 0.35; // Default: 0.5 this.colorGradePass.brightness = -0.2; // Default: -0.15 // Load the ambience... const noiseConfig: SoundscapeConfig = { masterVolume: 0.016, layers: [ { volume: { base: 1, }, source: { type: 'noise', }, filters: [ { type: 'highpass', frequency: { base: 900, }, Q: { base: 1, }, gain: { base: 0, }, }, ], }, ], }; // Initialize the player with the config. this.ambience = new SoundscapePlayer(noiseConfig); } public update(): void { // Randomize the pixels every little bit. const now = Date.now(); if (now - this.lastUpdateTime > this.UPDATE_INTERVAL) { this.lastUpdateTime = now; // Generate a random offset, but snap it to the pixel grid. this.uvOffset = [ Math.floor(Math.random() * this.TEXTURE_WIDTH) / this.TEXTURE_WIDTH, Math.floor(Math.random() * this.TEXTURE_WIDTH) / this.TEXTURE_WIDTH, ]; } } public getUniforms(): Record { return { u7_strength: this.strength, u7_uvOffset: this.uvOffset, u7_pixelWidth: this.TEXTURE_WIDTH, u7_pixelSize: this.PIXEL_SIZE, }; } public getPasses(): PostProcessPass[] { return [this.colorGradePass]; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); AudioManager.fadeInDownsampler(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); AudioManager.fadeOutDownsampler(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/TheBeginningZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/TheBeginningZone.ts import { Zone } from '../EffectZoneManager'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; export class TheBeginningZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 0; public update(): void { // No dynamic state to update for a pass-through zone. } public getUniforms(): Record { return {}; } public getPasses(): PostProcessPass[] { return []; } public fadeInAmbience(_transitionDurationMillis: number): void {} public fadeOutAmbience(_transitionDurationMillis: number): void {} } ================================================ FILE: src/client/scripts/esm/game/rendering/effect_zone/zones/UndercurrentZone.ts ================================================ // src/client/scripts/esm/game/rendering/effect_zone/zones/UndercurrentZone.ts /** * This is the 1st zone you encounter moving away from the origin. * * It has NO visual effect, but it does introduce the first ambience. */ import type { Zone } from '../EffectZoneManager'; import { PostProcessPass } from '../../../../webgl/post_processing/PostProcessingPipeline'; import { SoundscapePlayer } from '../../../../audio/SoundscapePlayer'; import UndercurrentSoundscape from '../soundscapes/UndercurrentSoundscape'; export class UndercurrentZone implements Zone { /** The unique integer id this effect zone gets. */ readonly effectType: number = 1; /** The soundscape player for this zone. */ private ambience: SoundscapePlayer; constructor() { // Load the ambience... // Initialize the player with the config. this.ambience = new SoundscapePlayer(UndercurrentSoundscape.config); } public update(): void { // No dynamic state to update for a pass-through zone. } public getUniforms(): Record { return {}; } public getPasses(): PostProcessPass[] { return []; } public fadeInAmbience(transitionDurationMillis: number): void { this.ambience.fadeIn(transitionDurationMillis); } public fadeOutAmbience(transitionDurationMillis: number): void { this.ambience.fadeOut(transitionDurationMillis); } } ================================================ FILE: src/client/scripts/esm/game/rendering/frameratelimiter.ts ================================================ // src/client/scripts/esm/game/rendering/frameratelimiter.ts /** * This module manages the framerate of the game. * * When on the title screen, the framerate (frequency of requestAnimationFrame calls) * is limited to 30fps to save GPU resources. */ import gameloader from '../chess/gameloader.js'; // Variables ------------------------------------------------- /** * Target framerate when not in a game. * * I cannot actually tell a difference between 30fps and 240fps there. */ const TARGET_FPS_TITLE_SCREEN = 30; // State ----------------------------------------------------- /** Timestamp of the last frame that was actually rendered */ let lastFrameTime = 0; /** * Set to true when we hear the canvas_resize event. We should bypass fps throttling and render the next frame immediately. * * Patches bug where resizing the window on the title screen (where fps is throttled) causes * rapid black flickering when the canvas is black, but we're waiting to render the next frame. */ let canvasResized: boolean = false; document.addEventListener('canvas_resize', () => (canvasResized = true)); // Functions ------------------------------------------------- /** * Request an animation frame, with throttling applied when on the title screen. * This is a wrapper for calls to requestAnimationFrame(). * @param callback - The callback function to execute on the next frame */ function requestFrame(callback: FrameRequestCallback): void { // Not in a game (title screen), throttle. const throttledCallback = (timestamp: number): void => { // If we're in a game, or canvas was resized, run at full speed. if (gameloader.areInAGame() || canvasResized) { canvasResized = false; lastFrameTime = timestamp; callback(timestamp); return; } // On the very first frame, or after a long pause (e.g. tab was inactive), // reset the timer to the current time. if (lastFrameTime === 0 || timestamp - lastFrameTime > 200) { lastFrameTime = timestamp; } // Calculate time elapsed since the last scheduled frame const elapsed = timestamp - lastFrameTime; // If enough time has passed, execute the callback const millisPerFrame = 1000 / TARGET_FPS_TITLE_SCREEN; if (elapsed >= millisPerFrame) { // Instead of resetting lastFrameTime to the current 'timestamp', // we advance it by a fixed interval. This creates a steady "tick" // that is not affected by the monitor's specific refresh rate, fixing frame-skipping. lastFrameTime += millisPerFrame; callback(timestamp); } else { // Not enough time has passed - schedule another check directly with requestAnimationFrame requestAnimationFrame(throttledCallback); } }; requestAnimationFrame(throttledCallback); } // Exports -------------------------------------------------- export default { requestFrame, }; ================================================ FILE: src/client/scripts/esm/game/rendering/frametracker.ts ================================================ // src/client/scripts/esm/game/rendering/frametracker.ts /** * This script stores an internal variable that keeps track of whether * anything visual has changed on-screen in the game this frame. * If nothing has, we can save compute by skipping rendering. * * ZERO dependancies. */ /** Whether there has been a visual change on-screen the past frame. */ let hasBeenVisualChange: boolean = true; /** The next frame will be rendered. Compute can be saved if nothing has visibly changed on-screen. */ function onVisualChange(): void { // console.error("onVisualChange()"); hasBeenVisualChange = true; } /** true if there has been a visual change on-screen since last frame. */ function doWeRenderNextFrame(): boolean { return hasBeenVisualChange; } /** * Resets {@link hasBeenVisualChange} to false, to prepare for next frame. * Call right after we finish a render frame. */ function onFrameRender(): void { hasBeenVisualChange = false; } export default { onVisualChange, doWeRenderNextFrame, onFrameRender, }; ================================================ FILE: src/client/scripts/esm/game/rendering/gl-matrix.js ================================================ // src/client/scripts/esm/game/rendering/gl-matrix.js /* eslint-disable */ /*! @fileoverview gl-matrix - High performance matrix and vector operations @author Brandon Jones @author Colin MacKenzie IV @version 3.4.0 Copyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // This file has been trimmed for use for Infinite Chess, full file can be found here: // https://www.lcg.ufrj.br/WebGL/hws.edu-examples/doc-bump/gl-matrix.js.html 'use strict'; /** * Common utilities * @module glMatrix */ // Configuration Constants var EPSILON = 0.000001; var ARRAY_TYPE = typeof Float32Array !== 'undefined' ? Float32Array : Array; /** * 4x4 Matrix
Format: column-major, when typed out it looks like row-major
The matrices are being post multiplied. * @module mat4 */ /** * Creates a new identity mat4 * * @returns {mat4} a new 4x4 matrix */ function create$5() { var out = new ARRAY_TYPE(16); if (ARRAY_TYPE != Float32Array) { out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; } out[0] = 1; out[5] = 1; out[10] = 1; out[15] = 1; return out; } /** * Creates a new mat4 initialized with values from an existing matrix * * @param {ReadonlyMat4} a matrix to clone * @returns {mat4} a new 4x4 matrix */ function clone$5(a) { var out = new ARRAY_TYPE(16); out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Copy the values from one mat4 to another * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function copy$5(out, a) { out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Create a new mat4 with the given values * * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m03 Component in column 0, row 3 position (index 3) * @param {Number} m10 Component in column 1, row 0 position (index 4) * @param {Number} m11 Component in column 1, row 1 position (index 5) * @param {Number} m12 Component in column 1, row 2 position (index 6) * @param {Number} m13 Component in column 1, row 3 position (index 7) * @param {Number} m20 Component in column 2, row 0 position (index 8) * @param {Number} m21 Component in column 2, row 1 position (index 9) * @param {Number} m22 Component in column 2, row 2 position (index 10) * @param {Number} m23 Component in column 2, row 3 position (index 11) * @param {Number} m30 Component in column 3, row 0 position (index 12) * @param {Number} m31 Component in column 3, row 1 position (index 13) * @param {Number} m32 Component in column 3, row 2 position (index 14) * @param {Number} m33 Component in column 3, row 3 position (index 15) * @returns {mat4} A new mat4 */ function fromValues$5( m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33, ) { var out = new ARRAY_TYPE(16); out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m03; out[4] = m10; out[5] = m11; out[6] = m12; out[7] = m13; out[8] = m20; out[9] = m21; out[10] = m22; out[11] = m23; out[12] = m30; out[13] = m31; out[14] = m32; out[15] = m33; return out; } /** * Set the components of a mat4 to the given values * * @param {mat4} out the receiving matrix * @param {Number} m00 Component in column 0, row 0 position (index 0) * @param {Number} m01 Component in column 0, row 1 position (index 1) * @param {Number} m02 Component in column 0, row 2 position (index 2) * @param {Number} m03 Component in column 0, row 3 position (index 3) * @param {Number} m10 Component in column 1, row 0 position (index 4) * @param {Number} m11 Component in column 1, row 1 position (index 5) * @param {Number} m12 Component in column 1, row 2 position (index 6) * @param {Number} m13 Component in column 1, row 3 position (index 7) * @param {Number} m20 Component in column 2, row 0 position (index 8) * @param {Number} m21 Component in column 2, row 1 position (index 9) * @param {Number} m22 Component in column 2, row 2 position (index 10) * @param {Number} m23 Component in column 2, row 3 position (index 11) * @param {Number} m30 Component in column 3, row 0 position (index 12) * @param {Number} m31 Component in column 3, row 1 position (index 13) * @param {Number} m32 Component in column 3, row 2 position (index 14) * @param {Number} m33 Component in column 3, row 3 position (index 15) * @returns {mat4} out */ function set$5( out, m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33, ) { out[0] = m00; out[1] = m01; out[2] = m02; out[3] = m03; out[4] = m10; out[5] = m11; out[6] = m12; out[7] = m13; out[8] = m20; out[9] = m21; out[10] = m22; out[11] = m23; out[12] = m30; out[13] = m31; out[14] = m32; out[15] = m33; return out; } /** * Set a mat4 to the identity matrix * * @param {mat4} out the receiving matrix * @returns {mat4} out */ function identity$2(out) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Transpose the values of a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function transpose(out, a) { // If we are transposing ourselves we can skip a few steps but have to cache some values if (out === a) { var a01 = a[1], a02 = a[2], a03 = a[3]; var a12 = a[6], a13 = a[7]; var a23 = a[11]; out[1] = a[4]; out[2] = a[8]; out[3] = a[12]; out[4] = a01; out[6] = a[9]; out[7] = a[13]; out[8] = a02; out[9] = a12; out[11] = a[14]; out[12] = a03; out[13] = a13; out[14] = a23; } else { out[0] = a[0]; out[1] = a[4]; out[2] = a[8]; out[3] = a[12]; out[4] = a[1]; out[5] = a[5]; out[6] = a[9]; out[7] = a[13]; out[8] = a[2]; out[9] = a[6]; out[10] = a[10]; out[11] = a[14]; out[12] = a[3]; out[13] = a[7]; out[14] = a[11]; out[15] = a[15]; } return out; } /** * Inverts a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function invert$2(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b00 = a00 * a11 - a01 * a10; var b01 = a00 * a12 - a02 * a10; var b02 = a00 * a13 - a03 * a10; var b03 = a01 * a12 - a02 * a11; var b04 = a01 * a13 - a03 * a11; var b05 = a02 * a13 - a03 * a12; var b06 = a20 * a31 - a21 * a30; var b07 = a20 * a32 - a22 * a30; var b08 = a20 * a33 - a23 * a30; var b09 = a21 * a32 - a22 * a31; var b10 = a21 * a33 - a23 * a31; var b11 = a22 * a33 - a23 * a32; // Calculate the determinant var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; if (!det) { return null; } det = 1.0 / det; out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det; out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det; out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det; out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det; return out; } /** * Calculates the adjugate of a mat4 * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the source matrix * @returns {mat4} out */ function adjoint(out, a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b00 = a00 * a11 - a01 * a10; var b01 = a00 * a12 - a02 * a10; var b02 = a00 * a13 - a03 * a10; var b03 = a01 * a12 - a02 * a11; var b04 = a01 * a13 - a03 * a11; var b05 = a02 * a13 - a03 * a12; var b06 = a20 * a31 - a21 * a30; var b07 = a20 * a32 - a22 * a30; var b08 = a20 * a33 - a23 * a30; var b09 = a21 * a32 - a22 * a31; var b10 = a21 * a33 - a23 * a31; var b11 = a22 * a33 - a23 * a32; out[0] = a11 * b11 - a12 * b10 + a13 * b09; out[1] = a02 * b10 - a01 * b11 - a03 * b09; out[2] = a31 * b05 - a32 * b04 + a33 * b03; out[3] = a22 * b04 - a21 * b05 - a23 * b03; out[4] = a12 * b08 - a10 * b11 - a13 * b07; out[5] = a00 * b11 - a02 * b08 + a03 * b07; out[6] = a32 * b02 - a30 * b05 - a33 * b01; out[7] = a20 * b05 - a22 * b02 + a23 * b01; out[8] = a10 * b10 - a11 * b08 + a13 * b06; out[9] = a01 * b08 - a00 * b10 - a03 * b06; out[10] = a30 * b04 - a31 * b02 + a33 * b00; out[11] = a21 * b02 - a20 * b04 - a23 * b00; out[12] = a11 * b07 - a10 * b09 - a12 * b06; out[13] = a00 * b09 - a01 * b07 + a02 * b06; out[14] = a31 * b01 - a30 * b03 - a32 * b00; out[15] = a20 * b03 - a21 * b01 + a22 * b00; return out; } /** * Calculates the determinant of a mat4 * * @param {ReadonlyMat4} a the source matrix * @returns {Number} determinant of a */ function determinant(a) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; var b0 = a00 * a11 - a01 * a10; var b1 = a00 * a12 - a02 * a10; var b2 = a01 * a12 - a02 * a11; var b3 = a20 * a31 - a21 * a30; var b4 = a20 * a32 - a22 * a30; var b5 = a21 * a32 - a22 * a31; var b6 = a00 * b5 - a01 * b4 + a02 * b3; var b7 = a10 * b5 - a11 * b4 + a12 * b3; var b8 = a20 * b2 - a21 * b1 + a22 * b0; var b9 = a30 * b2 - a31 * b1 + a32 * b0; // Calculate the determinant return a13 * b6 - a03 * b7 + a33 * b8 - a23 * b9; } /** * Multiplies two mat4s * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function multiply$5(out, a, b) { var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; // Cache only the current line of the second matrix var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7]; out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11]; out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15]; out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; return out; } /** * Translate a mat4 by the given vector * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to translate * @param {ReadonlyVec3} v vector to translate by * @returns {mat4} out */ function translate$1(out, a, v) { var x = v[0], y = v[1], z = v[2]; var a00, a01, a02, a03; var a10, a11, a12, a13; var a20, a21, a22, a23; if (a === out) { out[12] = a[0] * x + a[4] * y + a[8] * z + a[12]; out[13] = a[1] * x + a[5] * y + a[9] * z + a[13]; out[14] = a[2] * x + a[6] * y + a[10] * z + a[14]; out[15] = a[3] * x + a[7] * y + a[11] * z + a[15]; } else { a00 = a[0]; a01 = a[1]; a02 = a[2]; a03 = a[3]; a10 = a[4]; a11 = a[5]; a12 = a[6]; a13 = a[7]; a20 = a[8]; a21 = a[9]; a22 = a[10]; a23 = a[11]; out[0] = a00; out[1] = a01; out[2] = a02; out[3] = a03; out[4] = a10; out[5] = a11; out[6] = a12; out[7] = a13; out[8] = a20; out[9] = a21; out[10] = a22; out[11] = a23; out[12] = a00 * x + a10 * y + a20 * z + a[12]; out[13] = a01 * x + a11 * y + a21 * z + a[13]; out[14] = a02 * x + a12 * y + a22 * z + a[14]; out[15] = a03 * x + a13 * y + a23 * z + a[15]; } return out; } /** * Scales the mat4 by the dimensions in the given vec3 not using vectorization * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to scale * @param {ReadonlyVec3} v the vec3 to scale the matrix by * @returns {mat4} out **/ function scale$5(out, a, v) { var x = v[0], y = v[1], z = v[2]; out[0] = a[0] * x; out[1] = a[1] * x; out[2] = a[2] * x; out[3] = a[3] * x; out[4] = a[4] * y; out[5] = a[5] * y; out[6] = a[6] * y; out[7] = a[7] * y; out[8] = a[8] * z; out[9] = a[9] * z; out[10] = a[10] * z; out[11] = a[11] * z; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; return out; } /** * Rotates a mat4 by the given angle around the given axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @param {ReadonlyVec3} axis the axis to rotate around * @returns {mat4} out */ function rotate$1(out, a, rad, axis) { var x = axis[0], y = axis[1], z = axis[2]; var len = Math.hypot(x, y, z); var s, c, t; var a00, a01, a02, a03; var a10, a11, a12, a13; var a20, a21, a22, a23; var b00, b01, b02; var b10, b11, b12; var b20, b21, b22; if (len < EPSILON) { return null; } len = 1 / len; x *= len; y *= len; z *= len; s = Math.sin(rad); c = Math.cos(rad); t = 1 - c; a00 = a[0]; a01 = a[1]; a02 = a[2]; a03 = a[3]; a10 = a[4]; a11 = a[5]; a12 = a[6]; a13 = a[7]; a20 = a[8]; a21 = a[9]; a22 = a[10]; a23 = a[11]; // Construct the elements of the rotation matrix b00 = x * x * t + c; b01 = y * x * t + z * s; b02 = z * x * t - y * s; b10 = x * y * t - z * s; b11 = y * y * t + c; b12 = z * y * t + x * s; b20 = x * z * t + y * s; b21 = y * z * t - x * s; b22 = z * z * t + c; // Perform rotation-specific matrix multiplication out[0] = a00 * b00 + a10 * b01 + a20 * b02; out[1] = a01 * b00 + a11 * b01 + a21 * b02; out[2] = a02 * b00 + a12 * b01 + a22 * b02; out[3] = a03 * b00 + a13 * b01 + a23 * b02; out[4] = a00 * b10 + a10 * b11 + a20 * b12; out[5] = a01 * b10 + a11 * b11 + a21 * b12; out[6] = a02 * b10 + a12 * b11 + a22 * b12; out[7] = a03 * b10 + a13 * b11 + a23 * b12; out[8] = a00 * b20 + a10 * b21 + a20 * b22; out[9] = a01 * b20 + a11 * b21 + a21 * b22; out[10] = a02 * b20 + a12 * b21 + a22 * b22; out[11] = a03 * b20 + a13 * b21 + a23 * b22; if (a !== out) { // If the source and destination differ, copy the unchanged last row out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } return out; } /** * Rotates a matrix by the given angle around the X axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateX$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a10 = a[4]; var a11 = a[5]; var a12 = a[6]; var a13 = a[7]; var a20 = a[8]; var a21 = a[9]; var a22 = a[10]; var a23 = a[11]; if (a !== out) { // If the source and destination differ, copy the unchanged rows out[0] = a[0]; out[1] = a[1]; out[2] = a[2]; out[3] = a[3]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[4] = a10 * c + a20 * s; out[5] = a11 * c + a21 * s; out[6] = a12 * c + a22 * s; out[7] = a13 * c + a23 * s; out[8] = a20 * c - a10 * s; out[9] = a21 * c - a11 * s; out[10] = a22 * c - a12 * s; out[11] = a23 * c - a13 * s; return out; } /** * Rotates a matrix by the given angle around the Y axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateY$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a00 = a[0]; var a01 = a[1]; var a02 = a[2]; var a03 = a[3]; var a20 = a[8]; var a21 = a[9]; var a22 = a[10]; var a23 = a[11]; if (a !== out) { // If the source and destination differ, copy the unchanged rows out[4] = a[4]; out[5] = a[5]; out[6] = a[6]; out[7] = a[7]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[0] = a00 * c - a20 * s; out[1] = a01 * c - a21 * s; out[2] = a02 * c - a22 * s; out[3] = a03 * c - a23 * s; out[8] = a00 * s + a20 * c; out[9] = a01 * s + a21 * c; out[10] = a02 * s + a22 * c; out[11] = a03 * s + a23 * c; return out; } /** * Rotates a matrix by the given angle around the Z axis * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to rotate * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function rotateZ$3(out, a, rad) { var s = Math.sin(rad); var c = Math.cos(rad); var a00 = a[0]; var a01 = a[1]; var a02 = a[2]; var a03 = a[3]; var a10 = a[4]; var a11 = a[5]; var a12 = a[6]; var a13 = a[7]; if (a !== out) { // If the source and destination differ, copy the unchanged last row out[8] = a[8]; out[9] = a[9]; out[10] = a[10]; out[11] = a[11]; out[12] = a[12]; out[13] = a[13]; out[14] = a[14]; out[15] = a[15]; } // Perform axis-specific matrix multiplication out[0] = a00 * c + a10 * s; out[1] = a01 * c + a11 * s; out[2] = a02 * c + a12 * s; out[3] = a03 * c + a13 * s; out[4] = a10 * c - a00 * s; out[5] = a11 * c - a01 * s; out[6] = a12 * c - a02 * s; out[7] = a13 * c - a03 * s; return out; } /** * Creates a matrix from a vector translation * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, dest, vec); * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyVec3} v Translation vector * @returns {mat4} out */ function fromTranslation$1(out, v) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a matrix from a vector scaling * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.scale(dest, dest, vec); * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyVec3} v Scaling vector * @returns {mat4} out */ function fromScaling(out, v) { out[0] = v[0]; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = v[1]; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = v[2]; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from a given angle around a given axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotate(dest, dest, rad, axis); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @param {ReadonlyVec3} axis the axis to rotate around * @returns {mat4} out */ function fromRotation$1(out, rad, axis) { var x = axis[0], y = axis[1], z = axis[2]; var len = Math.hypot(x, y, z); var s, c, t; if (len < EPSILON) { return null; } len = 1 / len; x *= len; y *= len; z *= len; s = Math.sin(rad); c = Math.cos(rad); t = 1 - c; // Perform rotation-specific matrix multiplication out[0] = x * x * t + c; out[1] = y * x * t + z * s; out[2] = z * x * t - y * s; out[3] = 0; out[4] = x * y * t - z * s; out[5] = y * y * t + c; out[6] = z * y * t + x * s; out[7] = 0; out[8] = x * z * t + y * s; out[9] = y * z * t - x * s; out[10] = z * z * t + c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the X axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateX(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromXRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = c; out[6] = s; out[7] = 0; out[8] = 0; out[9] = -s; out[10] = c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the Y axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateY(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromYRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = c; out[1] = 0; out[2] = -s; out[3] = 0; out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0; out[8] = s; out[9] = 0; out[10] = c; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from the given angle around the Z axis * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.rotateZ(dest, dest, rad); * * @param {mat4} out mat4 receiving operation result * @param {Number} rad the angle to rotate the matrix by * @returns {mat4} out */ function fromZRotation(out, rad) { var s = Math.sin(rad); var c = Math.cos(rad); // Perform axis-specific matrix multiplication out[0] = c; out[1] = s; out[2] = 0; out[3] = 0; out[4] = -s; out[5] = c; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Creates a matrix from a quaternion rotation and vector translation * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @returns {mat4} out */ function fromRotationTranslation$1(out, q, v) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; out[0] = 1 - (yy + zz); out[1] = xy + wz; out[2] = xz - wy; out[3] = 0; out[4] = xy - wz; out[5] = 1 - (xx + zz); out[6] = yz + wx; out[7] = 0; out[8] = xz + wy; out[9] = yz - wx; out[10] = 1 - (xx + yy); out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a new mat4 from a dual quat. * * @param {mat4} out Matrix * @param {ReadonlyQuat2} a Dual Quaternion * @returns {mat4} mat4 receiving operation result */ function fromQuat2(out, a) { var translation = new ARRAY_TYPE(3); var bx = -a[0], by = -a[1], bz = -a[2], bw = a[3], ax = a[4], ay = a[5], az = a[6], aw = a[7]; var magnitude = bx * bx + by * by + bz * bz + bw * bw; //Only scale if it makes sense if (magnitude > 0) { translation[0] = ((ax * bw + aw * bx + ay * bz - az * by) * 2) / magnitude; translation[1] = ((ay * bw + aw * by + az * bx - ax * bz) * 2) / magnitude; translation[2] = ((az * bw + aw * bz + ax * by - ay * bx) * 2) / magnitude; } else { translation[0] = (ax * bw + aw * bx + ay * bz - az * by) * 2; translation[1] = (ay * bw + aw * by + az * bx - ax * bz) * 2; translation[2] = (az * bw + aw * bz + ax * by - ay * bx) * 2; } fromRotationTranslation$1(out, a, translation); return out; } /** * Returns the translation vector component of a transformation * matrix. If a matrix is built with fromRotationTranslation, * the returned vector will be the same as the translation vector * originally supplied. * @param {vec3} out Vector to receive translation component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {vec3} out */ function getTranslation$1(out, mat) { out[0] = mat[12]; out[1] = mat[13]; out[2] = mat[14]; return out; } /** * Returns the scaling factor component of a transformation * matrix. If a matrix is built with fromRotationTranslationScale * with a normalized Quaternion paramter, the returned vector will be * the same as the scaling vector * originally supplied. * @param {vec3} out Vector to receive scaling factor component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {vec3} out */ function getScaling(out, mat) { var m11 = mat[0]; var m12 = mat[1]; var m13 = mat[2]; var m21 = mat[4]; var m22 = mat[5]; var m23 = mat[6]; var m31 = mat[8]; var m32 = mat[9]; var m33 = mat[10]; out[0] = Math.hypot(m11, m12, m13); out[1] = Math.hypot(m21, m22, m23); out[2] = Math.hypot(m31, m32, m33); return out; } /** * Returns a quaternion representing the rotational component * of a transformation matrix. If a matrix is built with * fromRotationTranslation, the returned quaternion will be the * same as the quaternion originally supplied. * @param {quat} out Quaternion to receive the rotation component * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @return {quat} out */ function getRotation(out, mat) { var scaling = new ARRAY_TYPE(3); getScaling(scaling, mat); var is1 = 1 / scaling[0]; var is2 = 1 / scaling[1]; var is3 = 1 / scaling[2]; var sm11 = mat[0] * is1; var sm12 = mat[1] * is2; var sm13 = mat[2] * is3; var sm21 = mat[4] * is1; var sm22 = mat[5] * is2; var sm23 = mat[6] * is3; var sm31 = mat[8] * is1; var sm32 = mat[9] * is2; var sm33 = mat[10] * is3; var trace = sm11 + sm22 + sm33; var S = 0; if (trace > 0) { S = Math.sqrt(trace + 1.0) * 2; out[3] = 0.25 * S; out[0] = (sm23 - sm32) / S; out[1] = (sm31 - sm13) / S; out[2] = (sm12 - sm21) / S; } else if (sm11 > sm22 && sm11 > sm33) { S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2; out[3] = (sm23 - sm32) / S; out[0] = 0.25 * S; out[1] = (sm12 + sm21) / S; out[2] = (sm31 + sm13) / S; } else if (sm22 > sm33) { S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2; out[3] = (sm31 - sm13) / S; out[0] = (sm12 + sm21) / S; out[1] = 0.25 * S; out[2] = (sm23 + sm32) / S; } else { S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2; out[3] = (sm12 - sm21) / S; out[0] = (sm31 + sm13) / S; out[1] = (sm23 + sm32) / S; out[2] = 0.25 * S; } return out; } /** * Decomposes a transformation matrix into its rotation, translation * and scale components. Returns only the rotation component * @param {quat} out_r Quaternion to receive the rotation component * @param {vec3} out_t Vector to receive the translation vector * @param {vec3} out_s Vector to receive the scaling factor * @param {ReadonlyMat4} mat Matrix to be decomposed (input) * @returns {quat} out_r */ function decompose(out_r, out_t, out_s, mat) { out_t[0] = mat[12]; out_t[1] = mat[13]; out_t[2] = mat[14]; var m11 = mat[0]; var m12 = mat[1]; var m13 = mat[2]; var m21 = mat[4]; var m22 = mat[5]; var m23 = mat[6]; var m31 = mat[8]; var m32 = mat[9]; var m33 = mat[10]; out_s[0] = Math.hypot(m11, m12, m13); out_s[1] = Math.hypot(m21, m22, m23); out_s[2] = Math.hypot(m31, m32, m33); var is1 = 1 / out_s[0]; var is2 = 1 / out_s[1]; var is3 = 1 / out_s[2]; var sm11 = m11 * is1; var sm12 = m12 * is2; var sm13 = m13 * is3; var sm21 = m21 * is1; var sm22 = m22 * is2; var sm23 = m23 * is3; var sm31 = m31 * is1; var sm32 = m32 * is2; var sm33 = m33 * is3; var trace = sm11 + sm22 + sm33; var S = 0; if (trace > 0) { S = Math.sqrt(trace + 1.0) * 2; out_r[3] = 0.25 * S; out_r[0] = (sm23 - sm32) / S; out_r[1] = (sm31 - sm13) / S; out_r[2] = (sm12 - sm21) / S; } else if (sm11 > sm22 && sm11 > sm33) { S = Math.sqrt(1.0 + sm11 - sm22 - sm33) * 2; out_r[3] = (sm23 - sm32) / S; out_r[0] = 0.25 * S; out_r[1] = (sm12 + sm21) / S; out_r[2] = (sm31 + sm13) / S; } else if (sm22 > sm33) { S = Math.sqrt(1.0 + sm22 - sm11 - sm33) * 2; out_r[3] = (sm31 - sm13) / S; out_r[0] = (sm12 + sm21) / S; out_r[1] = 0.25 * S; out_r[2] = (sm23 + sm32) / S; } else { S = Math.sqrt(1.0 + sm33 - sm11 - sm22) * 2; out_r[3] = (sm12 - sm21) / S; out_r[0] = (sm31 + sm13) / S; out_r[1] = (sm23 + sm32) / S; out_r[2] = 0.25 * S; } return out_r; } /** * Creates a matrix from a quaternion rotation, vector translation and vector scale * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * mat4.scale(dest, scale) * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @param {ReadonlyVec3} s Scaling vector * @returns {mat4} out */ function fromRotationTranslationScale(out, q, v, s) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; var sx = s[0]; var sy = s[1]; var sz = s[2]; out[0] = (1 - (yy + zz)) * sx; out[1] = (xy + wz) * sx; out[2] = (xz - wy) * sx; out[3] = 0; out[4] = (xy - wz) * sy; out[5] = (1 - (xx + zz)) * sy; out[6] = (yz + wx) * sy; out[7] = 0; out[8] = (xz + wy) * sz; out[9] = (yz - wx) * sz; out[10] = (1 - (xx + yy)) * sz; out[11] = 0; out[12] = v[0]; out[13] = v[1]; out[14] = v[2]; out[15] = 1; return out; } /** * Creates a matrix from a quaternion rotation, vector translation and vector scale, rotating and scaling around the given origin * This is equivalent to (but much faster than): * * mat4.identity(dest); * mat4.translate(dest, vec); * mat4.translate(dest, origin); * let quatMat = mat4.create(); * quat4.toMat4(quat, quatMat); * mat4.multiply(dest, quatMat); * mat4.scale(dest, scale) * mat4.translate(dest, negativeOrigin); * * @param {mat4} out mat4 receiving operation result * @param {quat4} q Rotation quaternion * @param {ReadonlyVec3} v Translation vector * @param {ReadonlyVec3} s Scaling vector * @param {ReadonlyVec3} o The origin vector around which to scale and rotate * @returns {mat4} out */ function fromRotationTranslationScaleOrigin(out, q, v, s, o) { // Quaternion math var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var xy = x * y2; var xz = x * z2; var yy = y * y2; var yz = y * z2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; var sx = s[0]; var sy = s[1]; var sz = s[2]; var ox = o[0]; var oy = o[1]; var oz = o[2]; var out0 = (1 - (yy + zz)) * sx; var out1 = (xy + wz) * sx; var out2 = (xz - wy) * sx; var out4 = (xy - wz) * sy; var out5 = (1 - (xx + zz)) * sy; var out6 = (yz + wx) * sy; var out8 = (xz + wy) * sz; var out9 = (yz - wx) * sz; var out10 = (1 - (xx + yy)) * sz; out[0] = out0; out[1] = out1; out[2] = out2; out[3] = 0; out[4] = out4; out[5] = out5; out[6] = out6; out[7] = 0; out[8] = out8; out[9] = out9; out[10] = out10; out[11] = 0; out[12] = v[0] + ox - (out0 * ox + out4 * oy + out8 * oz); out[13] = v[1] + oy - (out1 * ox + out5 * oy + out9 * oz); out[14] = v[2] + oz - (out2 * ox + out6 * oy + out10 * oz); out[15] = 1; return out; } /** * Calculates a 4x4 matrix from the given quaternion * * @param {mat4} out mat4 receiving operation result * @param {ReadonlyQuat} q Quaternion to create matrix from * * @returns {mat4} out */ function fromQuat(out, q) { var x = q[0], y = q[1], z = q[2], w = q[3]; var x2 = x + x; var y2 = y + y; var z2 = z + z; var xx = x * x2; var yx = y * x2; var yy = y * y2; var zx = z * x2; var zy = z * y2; var zz = z * z2; var wx = w * x2; var wy = w * y2; var wz = w * z2; out[0] = 1 - yy - zz; out[1] = yx + wz; out[2] = zx - wy; out[3] = 0; out[4] = yx - wz; out[5] = 1 - xx - zz; out[6] = zy + wx; out[7] = 0; out[8] = zx + wy; out[9] = zy - wx; out[10] = 1 - xx - yy; out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; } /** * Generates a frustum matrix with the given bounds * * @param {mat4} out mat4 frustum matrix will be written into * @param {Number} left Left bound of the frustum * @param {Number} right Right bound of the frustum * @param {Number} bottom Bottom bound of the frustum * @param {Number} top Top bound of the frustum * @param {Number} near Near bound of the frustum * @param {Number} far Far bound of the frustum * @returns {mat4} out */ function frustum(out, left, right, bottom, top, near, far) { var rl = 1 / (right - left); var tb = 1 / (top - bottom); var nf = 1 / (near - far); out[0] = near * 2 * rl; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = near * 2 * tb; out[6] = 0; out[7] = 0; out[8] = (right + left) * rl; out[9] = (top + bottom) * tb; out[10] = (far + near) * nf; out[11] = -1; out[12] = 0; out[13] = 0; out[14] = far * near * 2 * nf; out[15] = 0; return out; } /** * Generates a perspective projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1], * which matches WebGL/OpenGL's clip volume. * Passing null/undefined/no value for far will generate infinite projection matrix. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} fovy Vertical field of view in radians * @param {number} aspect Aspect ratio. typically viewport width/height * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum, can be null or Infinity * @returns {mat4} out */ function perspectiveNO(out, fovy, aspect, near, far) { var f = 1.0 / Math.tan(fovy / 2); out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = -1; out[12] = 0; out[13] = 0; out[15] = 0; if (far != null && far !== Infinity) { var nf = 1 / (near - far); out[10] = (far + near) * nf; out[14] = 2 * far * near * nf; } else { out[10] = -1; out[14] = -2 * near; } return out; } /** * Alias for {@link mat4.perspectiveNO} * @function */ var perspective = perspectiveNO; /** * Generates a perspective projection matrix suitable for WebGPU with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1], * which matches WebGPU/Vulkan/DirectX/Metal's clip volume. * Passing null/undefined/no value for far will generate infinite projection matrix. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} fovy Vertical field of view in radians * @param {number} aspect Aspect ratio. typically viewport width/height * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum, can be null or Infinity * @returns {mat4} out */ function perspectiveZO(out, fovy, aspect, near, far) { var f = 1.0 / Math.tan(fovy / 2); out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[11] = -1; out[12] = 0; out[13] = 0; out[15] = 0; if (far != null && far !== Infinity) { var nf = 1 / (near - far); out[10] = far * nf; out[14] = far * near * nf; } else { out[10] = -1; out[14] = -near; } return out; } /** * Generates a perspective projection matrix with the given field of view. * This is primarily useful for generating projection matrices to be used * with the still experiemental WebVR API. * * @param {mat4} out mat4 frustum matrix will be written into * @param {Object} fov Object containing the following values: upDegrees, downDegrees, leftDegrees, rightDegrees * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function perspectiveFromFieldOfView(out, fov, near, far) { var upTan = Math.tan((fov.upDegrees * Math.PI) / 180.0); var downTan = Math.tan((fov.downDegrees * Math.PI) / 180.0); var leftTan = Math.tan((fov.leftDegrees * Math.PI) / 180.0); var rightTan = Math.tan((fov.rightDegrees * Math.PI) / 180.0); var xScale = 2.0 / (leftTan + rightTan); var yScale = 2.0 / (upTan + downTan); out[0] = xScale; out[1] = 0.0; out[2] = 0.0; out[3] = 0.0; out[4] = 0.0; out[5] = yScale; out[6] = 0.0; out[7] = 0.0; out[8] = -((leftTan - rightTan) * xScale * 0.5); out[9] = (upTan - downTan) * yScale * 0.5; out[10] = far / (near - far); out[11] = -1.0; out[12] = 0.0; out[13] = 0.0; out[14] = (far * near) / (near - far); out[15] = 0.0; return out; } /** * Generates a orthogonal projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [-1, 1], * which matches WebGL/OpenGL's clip volume. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} left Left bound of the frustum * @param {number} right Right bound of the frustum * @param {number} bottom Bottom bound of the frustum * @param {number} top Top bound of the frustum * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function orthoNO(out, left, right, bottom, top, near, far) { var lr = 1 / (left - right); var bt = 1 / (bottom - top); var nf = 1 / (near - far); out[0] = -2 * lr; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = -2 * bt; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = 2 * nf; out[11] = 0; out[12] = (left + right) * lr; out[13] = (top + bottom) * bt; out[14] = (far + near) * nf; out[15] = 1; return out; } /** * Alias for {@link mat4.orthoNO} * @function */ var ortho = orthoNO; /** * Generates a orthogonal projection matrix with the given bounds. * The near/far clip planes correspond to a normalized device coordinate Z range of [0, 1], * which matches WebGPU/Vulkan/DirectX/Metal's clip volume. * * @param {mat4} out mat4 frustum matrix will be written into * @param {number} left Left bound of the frustum * @param {number} right Right bound of the frustum * @param {number} bottom Bottom bound of the frustum * @param {number} top Top bound of the frustum * @param {number} near Near bound of the frustum * @param {number} far Far bound of the frustum * @returns {mat4} out */ function orthoZO(out, left, right, bottom, top, near, far) { var lr = 1 / (left - right); var bt = 1 / (bottom - top); var nf = 1 / (near - far); out[0] = -2 * lr; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = -2 * bt; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = nf; out[11] = 0; out[12] = (left + right) * lr; out[13] = (top + bottom) * bt; out[14] = near * nf; out[15] = 1; return out; } /** * Generates a look-at matrix with the given eye position, focal point, and up axis. * If you want a matrix that actually makes an object look at another object, you should use targetTo instead. * * @param {mat4} out mat4 frustum matrix will be written into * @param {ReadonlyVec3} eye Position of the viewer * @param {ReadonlyVec3} center Point the viewer is looking at * @param {ReadonlyVec3} up vec3 pointing up * @returns {mat4} out */ function lookAt(out, eye, center, up) { var x0, x1, x2, y0, y1, y2, z0, z1, z2, len; var eyex = eye[0]; var eyey = eye[1]; var eyez = eye[2]; var upx = up[0]; var upy = up[1]; var upz = up[2]; var centerx = center[0]; var centery = center[1]; var centerz = center[2]; if ( Math.abs(eyex - centerx) < EPSILON && Math.abs(eyey - centery) < EPSILON && Math.abs(eyez - centerz) < EPSILON ) { return identity$2(out); } z0 = eyex - centerx; z1 = eyey - centery; z2 = eyez - centerz; len = 1 / Math.hypot(z0, z1, z2); z0 *= len; z1 *= len; z2 *= len; x0 = upy * z2 - upz * z1; x1 = upz * z0 - upx * z2; x2 = upx * z1 - upy * z0; len = Math.hypot(x0, x1, x2); if (!len) { x0 = 0; x1 = 0; x2 = 0; } else { len = 1 / len; x0 *= len; x1 *= len; x2 *= len; } y0 = z1 * x2 - z2 * x1; y1 = z2 * x0 - z0 * x2; y2 = z0 * x1 - z1 * x0; len = Math.hypot(y0, y1, y2); if (!len) { y0 = 0; y1 = 0; y2 = 0; } else { len = 1 / len; y0 *= len; y1 *= len; y2 *= len; } out[0] = x0; out[1] = y0; out[2] = z0; out[3] = 0; out[4] = x1; out[5] = y1; out[6] = z1; out[7] = 0; out[8] = x2; out[9] = y2; out[10] = z2; out[11] = 0; out[12] = -(x0 * eyex + x1 * eyey + x2 * eyez); out[13] = -(y0 * eyex + y1 * eyey + y2 * eyez); out[14] = -(z0 * eyex + z1 * eyey + z2 * eyez); out[15] = 1; return out; } /** * Generates a matrix that makes something look at something else. * * @param {mat4} out mat4 frustum matrix will be written into * @param {ReadonlyVec3} eye Position of the viewer * @param {ReadonlyVec3} center Point the viewer is looking at * @param {ReadonlyVec3} up vec3 pointing up * @returns {mat4} out */ function targetTo(out, eye, target, up) { var eyex = eye[0], eyey = eye[1], eyez = eye[2], upx = up[0], upy = up[1], upz = up[2]; var z0 = eyex - target[0], z1 = eyey - target[1], z2 = eyez - target[2]; var len = z0 * z0 + z1 * z1 + z2 * z2; if (len > 0) { len = 1 / Math.sqrt(len); z0 *= len; z1 *= len; z2 *= len; } var x0 = upy * z2 - upz * z1, x1 = upz * z0 - upx * z2, x2 = upx * z1 - upy * z0; len = x0 * x0 + x1 * x1 + x2 * x2; if (len > 0) { len = 1 / Math.sqrt(len); x0 *= len; x1 *= len; x2 *= len; } out[0] = x0; out[1] = x1; out[2] = x2; out[3] = 0; out[4] = z1 * x2 - z2 * x1; out[5] = z2 * x0 - z0 * x2; out[6] = z0 * x1 - z1 * x0; out[7] = 0; out[8] = z0; out[9] = z1; out[10] = z2; out[11] = 0; out[12] = eyex; out[13] = eyey; out[14] = eyez; out[15] = 1; return out; } /** * Returns a string representation of a mat4 * * @param {ReadonlyMat4} a matrix to represent as a string * @returns {String} string representation of the matrix */ function str$5(a) { // prettier-ignore return "mat4(" + a[0] + ", " + a[1] + ", " + a[2] + ", " + a[3] + ", " + a[4] + ", " + a[5] + ", " + a[6] + ", " + a[7] + ", " + a[8] + ", " + a[9] + ", " + a[10] + ", " + a[11] + ", " + a[12] + ", " + a[13] + ", " + a[14] + ", " + a[15] + ")"; } /** * Returns Frobenius norm of a mat4 * * @param {ReadonlyMat4} a the matrix to calculate Frobenius norm of * @returns {Number} Frobenius norm */ function frob(a) { // prettier-ignore return Math.hypot(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]); } /** * Adds two mat4's * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function add$5(out, a, b) { out[0] = a[0] + b[0]; out[1] = a[1] + b[1]; out[2] = a[2] + b[2]; out[3] = a[3] + b[3]; out[4] = a[4] + b[4]; out[5] = a[5] + b[5]; out[6] = a[6] + b[6]; out[7] = a[7] + b[7]; out[8] = a[8] + b[8]; out[9] = a[9] + b[9]; out[10] = a[10] + b[10]; out[11] = a[11] + b[11]; out[12] = a[12] + b[12]; out[13] = a[13] + b[13]; out[14] = a[14] + b[14]; out[15] = a[15] + b[15]; return out; } /** * Subtracts matrix b from matrix a * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @returns {mat4} out */ function subtract$3(out, a, b) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; out[3] = a[3] - b[3]; out[4] = a[4] - b[4]; out[5] = a[5] - b[5]; out[6] = a[6] - b[6]; out[7] = a[7] - b[7]; out[8] = a[8] - b[8]; out[9] = a[9] - b[9]; out[10] = a[10] - b[10]; out[11] = a[11] - b[11]; out[12] = a[12] - b[12]; out[13] = a[13] - b[13]; out[14] = a[14] - b[14]; out[15] = a[15] - b[15]; return out; } /** * Multiply each element of the matrix by a scalar. * * @param {mat4} out the receiving matrix * @param {ReadonlyMat4} a the matrix to scale * @param {Number} b amount to scale the matrix's elements by * @returns {mat4} out */ function multiplyScalar(out, a, b) { out[0] = a[0] * b; out[1] = a[1] * b; out[2] = a[2] * b; out[3] = a[3] * b; out[4] = a[4] * b; out[5] = a[5] * b; out[6] = a[6] * b; out[7] = a[7] * b; out[8] = a[8] * b; out[9] = a[9] * b; out[10] = a[10] * b; out[11] = a[11] * b; out[12] = a[12] * b; out[13] = a[13] * b; out[14] = a[14] * b; out[15] = a[15] * b; return out; } /** * Adds two mat4's after multiplying each element of the second operand by a scalar value. * * @param {mat4} out the receiving vector * @param {ReadonlyMat4} a the first operand * @param {ReadonlyMat4} b the second operand * @param {Number} scale the amount to scale b's elements by before adding * @returns {mat4} out */ function multiplyScalarAndAdd(out, a, b, scale) { out[0] = a[0] + b[0] * scale; out[1] = a[1] + b[1] * scale; out[2] = a[2] + b[2] * scale; out[3] = a[3] + b[3] * scale; out[4] = a[4] + b[4] * scale; out[5] = a[5] + b[5] * scale; out[6] = a[6] + b[6] * scale; out[7] = a[7] + b[7] * scale; out[8] = a[8] + b[8] * scale; out[9] = a[9] + b[9] * scale; out[10] = a[10] + b[10] * scale; out[11] = a[11] + b[11] * scale; out[12] = a[12] + b[12] * scale; out[13] = a[13] + b[13] * scale; out[14] = a[14] + b[14] * scale; out[15] = a[15] + b[15] * scale; return out; } /** * Returns whether the matrices have exactly the same elements in the same position (when compared with ===) * * @param {ReadonlyMat4} a The first matrix. * @param {ReadonlyMat4} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function exactEquals$5(a, b) { // prettier-ignore return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5] && a[6] === b[6] && a[7] === b[7] && a[8] === b[8] && a[9] === b[9] && a[10] === b[10] && a[11] === b[11] && a[12] === b[12] && a[13] === b[13] && a[14] === b[14] && a[15] === b[15]; } /** * Returns whether the matrices have approximately the same elements in the same position. * * @param {ReadonlyMat4} a The first matrix. * @param {ReadonlyMat4} b The second matrix. * @returns {Boolean} True if the matrices are equal, false otherwise. */ function equals$5(a, b) { var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3]; var a4 = a[4], a5 = a[5], a6 = a[6], a7 = a[7]; var a8 = a[8], a9 = a[9], a10 = a[10], a11 = a[11]; var a12 = a[12], a13 = a[13], a14 = a[14], a15 = a[15]; var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; var b4 = b[4], b5 = b[5], b6 = b[6], b7 = b[7]; var b8 = b[8], b9 = b[9], b10 = b[10], b11 = b[11]; var b12 = b[12], b13 = b[13], b14 = b[14], b15 = b[15]; return ( Math.abs(a0 - b0) <= EPSILON * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && Math.abs(a1 - b1) <= EPSILON * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && Math.abs(a2 - b2) <= EPSILON * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && Math.abs(a3 - b3) <= EPSILON * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && Math.abs(a4 - b4) <= EPSILON * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && Math.abs(a5 - b5) <= EPSILON * Math.max(1.0, Math.abs(a5), Math.abs(b5)) && Math.abs(a6 - b6) <= EPSILON * Math.max(1.0, Math.abs(a6), Math.abs(b6)) && Math.abs(a7 - b7) <= EPSILON * Math.max(1.0, Math.abs(a7), Math.abs(b7)) && Math.abs(a8 - b8) <= EPSILON * Math.max(1.0, Math.abs(a8), Math.abs(b8)) && Math.abs(a9 - b9) <= EPSILON * Math.max(1.0, Math.abs(a9), Math.abs(b9)) && Math.abs(a10 - b10) <= EPSILON * Math.max(1.0, Math.abs(a10), Math.abs(b10)) && Math.abs(a11 - b11) <= EPSILON * Math.max(1.0, Math.abs(a11), Math.abs(b11)) && Math.abs(a12 - b12) <= EPSILON * Math.max(1.0, Math.abs(a12), Math.abs(b12)) && Math.abs(a13 - b13) <= EPSILON * Math.max(1.0, Math.abs(a13), Math.abs(b13)) && Math.abs(a14 - b14) <= EPSILON * Math.max(1.0, Math.abs(a14), Math.abs(b14)) && Math.abs(a15 - b15) <= EPSILON * Math.max(1.0, Math.abs(a15), Math.abs(b15)) ); } /** * Alias for {@link mat4.multiply} * @function */ var mul$5 = multiply$5; /** * Alias for {@link mat4.subtract} * @function */ var sub$3 = subtract$3; export default { __proto__: null, create: create$5, clone: clone$5, copy: copy$5, fromValues: fromValues$5, set: set$5, identity: identity$2, transpose: transpose, invert: invert$2, adjoint: adjoint, determinant: determinant, multiply: multiply$5, translate: translate$1, scale: scale$5, rotate: rotate$1, rotateX: rotateX$3, rotateY: rotateY$3, rotateZ: rotateZ$3, fromTranslation: fromTranslation$1, fromScaling: fromScaling, fromRotation: fromRotation$1, fromXRotation: fromXRotation, fromYRotation: fromYRotation, fromZRotation: fromZRotation, fromRotationTranslation: fromRotationTranslation$1, fromQuat2: fromQuat2, getTranslation: getTranslation$1, getScaling: getScaling, getRotation: getRotation, decompose: decompose, fromRotationTranslationScale: fromRotationTranslationScale, fromRotationTranslationScaleOrigin: fromRotationTranslationScaleOrigin, fromQuat: fromQuat, frustum: frustum, perspectiveNO: perspectiveNO, perspective: perspective, perspectiveZO: perspectiveZO, perspectiveFromFieldOfView: perspectiveFromFieldOfView, orthoNO: orthoNO, ortho: ortho, orthoZO: orthoZO, lookAt: lookAt, targetTo: targetTo, str: str$5, frob: frob, add: add$5, subtract: subtract$3, multiplyScalar: multiplyScalar, multiplyScalarAndAdd: multiplyScalarAndAdd, exactEquals: exactEquals$5, equals: equals$5, mul: mul$5, sub: sub$3, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/annotations/annotations.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/annotations/annotations.ts /** * This script manages all annotations * * Squares * * Arrows * * Rays */ import type { Ray } from '../../../../../../../shared/util/math/vectors.js'; import type { BDCoords, Coords } from '../../../../../../../shared/chess/util/coordutil.js'; import jsutil from '../../../../../../../shared/util/jsutil.js'; import bdcoords from '../../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../../shared/chess/util/coordutil.js'; import gameslot from '../../../chess/gameslot.js'; import drawrays from './drawrays.js'; import keybinds from '../../../misc/keybinds.js'; import { Mouse } from '../../../input.js'; import drawarrows from './drawarrows.js'; import gameloader from '../../../chess/gameloader.js'; import drawsquares from './drawsquares.js'; import preferences from '../../../../components/header/preferences.js'; import { GameBus } from '../../../GameBus.js'; // Types ----------------------------------------------------------------------- /** An object storing all visible annotations for a specific ply. */ interface Annotes { /** First type of annotation: A square highlight. */ Squares: Coords[]; /** Second type of annoation: An arrow draw from one square to another. */ Arrows: Arrow[]; /** * Third type of annotation: A ray of infinite square highlights, * starting from a square and going to infinity. */ Rays: Ray[]; } type Square = Coords; /** Second type of annoation: An arrow draw from one square to another. */ interface Arrow { start: Coords; end: Coords; /** The bigint vector pointing from the start coords to the end coords. NOT normalized. */ vector: Coords; /** The precalculated difference going from start to the end. Same as the vector, but as a BigDecimal. */ difference: BDCoords; /** The precalculated ratio of the x difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */ xRatio: number; /** The precalculated ratio of the y difference to the distance (hypotenuse, total length). Doesn't need extreme precision. */ yRatio: number; } // Variables ------------------------------------------------------------------- /** The annotations tied to specific move plies, when lingering annotations is OFF. */ const annotes_plies: Annotes[] = []; /** The main list of annotations, when lingering annotations is ON. */ let annotes_linger: Annotes = getEmptyAnnotes(); // Events --------------------------------------------------------------------- GameBus.addEventListener('piece-selected', () => { // Erase all the annotations of the current ply, if lingering annotations is OFF. if (preferences.getLingeringAnnotationsMode()) return; // Don't clear annotations on piece selection in this mode // Clear the annotations of the current ply const annotes = getRelevantAnnotes(); clearAnnotes(annotes); }); GameBus.addEventListener('game-unloaded', () => { // Clear all user-drawn highlights resetState(); }); // Getters --------------------------------------------------------------------- /** Returns the list of all Square highlights currently visible. */ function getSquares(): Coords[] { return getRelevantAnnotes().Squares; } /** Returns the list of all Arrow highlights currently visible. */ function getArrows(): Arrow[] { return getRelevantAnnotes().Arrows; } /** Returns the list of all Ray highlights currently visible. */ function getRays(): Ray[] { return getRelevantAnnotes().Rays; } // Helpers --------------------------------------------------------------------- /** * Returns the visible annotations according to the current Lingering Annotations mode: * 1. OFF => Returns current ply's annotes * 2. ON => Returns main annotes */ function getRelevantAnnotes(): Annotes { const enabled = preferences.getLingeringAnnotationsMode(); if (enabled) return annotes_linger; else { const index = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1; // Change -1 based to 0 based index // Ensure its initialized if (!annotes_plies[index]) annotes_plies[index] = getEmptyAnnotes(); return annotes_plies[index]; } } /** Event listener for when we change the Lingering Annotations mode */ document.addEventListener('lingering-annotations-toggle', (e) => { if (!gameloader.areInAGame()) return; const enabled: boolean = e.detail; const ply = gameslot.getGamefile()!.boardsim.state.local.moveIndex + 1; // Change -1 based to 0 based index if (enabled) { /** Transfer annotes from the ply to {@link annotes_linger} */ annotes_linger = jsutil.deepCopyObject(annotes_plies[ply]!); } else { /** Transfer annotes from {@link annotes_linger} to the current ply */ annotes_plies[ply] = jsutil.deepCopyObject(annotes_linger); // Clear these clearAnnotes(annotes_linger); } }); /** Returns an empty Annotes object. */ function getEmptyAnnotes(): Annotes { return { Squares: [], Arrows: [], Rays: [] }; } /** Erases all the annotes of the provided annotations. */ function clearAnnotes(annotes: Annotes): void { annotes.Squares.length = 0; annotes.Arrows.length = 0; annotes.Rays.length = 0; } // Functions ------------------------------------------------------------------- /** Main Adds/deletes annotations */ function update(): void { const mouseKeybind = keybinds.getAnnotationMouseButton(); if (mouseKeybind === undefined) return; // No button is assigned to drawing annotations currently // When this throws, we need to go into drawarrows, drawsquares, and drawrays update methods // and make it so the mouse button is accepted as an argument. if (mouseKeybind !== Mouse.RIGHT) throw Error('Annote drawing only supports right mouse button.'); const annotes = getRelevantAnnotes(); // Arrows first since it reads if there was a click, but Squares will claim the click. drawarrows.update(annotes.Arrows); drawsquares.update(annotes.Squares); drawrays.update(annotes.Rays); } /** * Collapses all annotations. The behavior is: * A. Atleast 1 ray => Erase all rays and add more Squares at all their intersections. * B. Else => Erase all annotes. */ function Collapse(): void { const annotes = getRelevantAnnotes(); if (annotes.Rays.length > 0) { // Collapse rays instead of erasing all annotations. // Can map to integer Coords since the argument we pass in ensures we only get back integer intersections. const additionalSquares = drawrays .collapseRays(annotes.Rays, true) .map((i) => bdcoords.coordsToBigInt(i)); for (const newSquare of additionalSquares) { // Avoid adding duplicates if (annotes.Squares.every((s) => !coordutil.areCoordsEqual(s, newSquare))) annotes.Squares.push(newSquare); } annotes.Rays.length = 0; // Erase all rays drawrays.dispatchRayCountEvent(annotes.Rays); } else clearAnnotes(annotes); } function resetState(): void { annotes_plies.length = 0; clearAnnotes(annotes_linger); drawarrows.stopDrawing(); drawrays.stopDrawing(); drawsquares.clearPresetOverrides(); drawrays.clearPresetOverrides(); } // Rendering ---------------------------------------------------------- /** Renders the annotations that should be rendered below the pieces */ function render_belowPieces(): void { const annotes = getRelevantAnnotes(); drawsquares.render(annotes.Squares); drawrays.render(annotes.Rays); } function render_abovePieces(): void { const annotes = getRelevantAnnotes(); drawarrows.render(annotes.Arrows); } // Exports ---------------------------------------------------------- export default { getSquares, getArrows, getRays, update, Collapse, resetState, render_belowPieces, render_abovePieces, }; export type { Square, Arrow, Ray }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/annotations/drawarrows.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/annotations/drawarrows.ts /** * This script allows the user to draw arrows on the board. * * Helpful for analysis, and requested by many. */ import type { Arrow } from './annotations.js'; import type { Color } from '../../../../../../../shared/util/math/math.js'; import type { BoundingBoxBD, DoubleBoundingBox, } from '../../../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import vectors from '../../../../../../../shared/util/math/vectors.js'; import geometry from '../../../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../../../shared/chess/util/bdcoords.js'; import coordutil, { BDCoords, Coords, DoubleCoords, } from '../../../../../../../shared/chess/util/coordutil.js'; import space from '../../../misc/space.js'; import mouse from '../../../../util/mouse.js'; import camera from '../../camera.js'; import snapping from '../snapping.js'; import boardpos from '../../boardpos.js'; import { Mouse } from '../../../input.js'; import preferences from '../../../../components/header/preferences.js'; import { createRenderable } from '../../../../webgl/Renderable.js'; // Constants ----------------------------------------------------------------- /** Properties for the drawn arrows.*/ const ARROW = { /** Width of the arrow's rectangular body, where 1.0 spans a full square. */ BODY_WIDTH: 0.24, // Default: 0.24 /** Width of the base of the arrowhead (perpendicular to arrow direction), where 1.0 spans a full square. */ TIP_WIDTH: 0.55, // Default: 0.55 /** Length of the arrowhead (along arrow direction), where 1.0 spans a full square. */ TIP_LENGTH: 0.37, // Default: 0.37 /** * The minimum desired length of the arrow's body, as a proportion of the total arrow length. * E.g., 0.5 means the body should try to be at least 50% of the total arrow length. * If the arrow is too short for both this proportional body and the ARROW.TIP_LENGTH, * both body and tip lengths will be adjusted. * Valid range: [0.0, 1.0]. 0.0 means no minimum proportional body length is enforced beyond * what's left after the tip takes ARROW.TIP_LENGTH. 1.0 means the arrow tries to be all body. */ MIN_BODY_PROPORTION: 0.4, // Default: 0.4 Example: Body should be at least 30% of total arrow length /** Offset of the arrow's base from the starting coordinate, in percentage of 1 tile width. */ BASE_OFFSET: 0.35, }; const ZERO = bd.fromBigInt(0n); const ONE = bd.fromBigInt(1n); /** This will be defined if we are CURRENTLY drawing an arrow. */ let drag_start: Coords | undefined; /** The ID of the pointer that is drawing the arrow. */ let pointerId: string | undefined; /** The last known position of the pointer drawing an arrow. */ let pointerWorld: DoubleCoords | undefined; // Updating ----------------------------------------------------------------- /** * Tests if the user has started/finished drawing new arrows, * or deleting any existing ones. * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this! * @param arrows - All arrow annotations currently on the board. */ function update(arrows: Arrow[]): void { const respectiveListener = mouse.getRelevantListener(); if (!drag_start) { // Test if right mouse down (start drawing) if (mouse.isMouseDown(Mouse.RIGHT) && !mouse.isMouseDoubleClickDragged(Mouse.RIGHT)) { mouse.claimMouseDown(Mouse.RIGHT); // Claim to prevent the same pointer dragging the board pointerId = respectiveListener.getMouseId(Mouse.RIGHT)!; pointerWorld = mouse.getPointerWorld(pointerId!); if (!pointerWorld) return stopDrawing(); // Maybe we're looking into sky? const closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld); const snapCoords = snapping.getWorldSnapCoords(pointerWorld); if (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) { if (closestEntityToWorld) { // Snap to nearest hovered entity drag_start = coordutil.copyCoords(closestEntityToWorld.coords); } else { // Snap to the current snap drag_start = [...snapCoords!]; } } else { // No snap drag_start = space.convertWorldSpaceToCoords_Rounded(pointerWorld); } } } else { // Currently drawing an arrow // Test if pointer released (finalize arrow) if (respectiveListener.pointerExists(pointerId!)) pointerWorld = mouse.getPointerWorld(pointerId!); // Update its last known position if (!respectiveListener.isPointerHeld(pointerId!)) { // Prevents accidentally drawing tiny arrows while zoomed out if we intend to draw square if (!mouse.isMouseClicked(Mouse.RIGHT)) addDrawnArrow(arrows); // else We drew a square highlight instead of an arrow stopDrawing(); } } } function stopDrawing(): void { drag_start = undefined; pointerId = undefined; pointerWorld = undefined; } /** If the given pointer is currently being used to draw an arrow, this stops using it. */ function stealPointer(pointerIdToSteal: string): void { if (pointerId !== pointerIdToSteal) return; // Not the pointer drawing the arrow, don't stop using it. stopDrawing(); } /** * Adds the currently drawn arrow to the list. * If a matching arrow already exists, that will be removed instead. * @param arrows - All arrows currently visible on the board. * @returns An object containing the results, such as whether a change was made, and what arrow was deleted if any. */ function addDrawnArrow(arrows: Arrow[]): { changed: boolean; deletedArrow?: Arrow } { if (!pointerWorld) return { changed: false }; // Probably stopped drawing while looking into sky? // console.log("Adding drawn arrow"); let drag_end: Coords; const closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld); const snapCoords = snapping.getWorldSnapCoords(pointerWorld); if (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) { if (closestEntityToWorld) { // Snap to nearest hovered entity drag_end = coordutil.copyCoords(closestEntityToWorld.coords); } else { // Snap to the current snap drag_end = [...snapCoords!]; } } else { // No snap drag_end = space.convertWorldSpaceToCoords_Rounded(pointerWorld); } // Skip if end equals start (no arrow drawn) if (coordutil.areCoordsEqual(drag_start!, drag_end)) return { changed: false }; // If a matching arrow already exists, remove that instead. for (let i = 0; i < arrows.length; i++) { const arrow = arrows[i]!; if ( coordutil.areCoordsEqual(arrow.start, drag_start!) && coordutil.areCoordsEqual(arrow.end, drag_end) ) { arrows.splice(i, 1); // Remove the existing arrow return { changed: true, deletedArrow: arrow }; // No new arrow added } } // Precalculate other arrow properties const vector: Coords = coordutil.subtractCoords(drag_end, drag_start!); const difference: BDCoords = bdcoords.FromCoords(vector); // Since the difference can be arbitrarily large, we need to normalize it // NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers. const normalizedVector: DoubleCoords = vectors.normalizeVectorBD(difference); const normalizedVectorHypot: number = Math.hypot(...normalizedVector); // Add the arrow arrows.push({ start: drag_start!, end: drag_end, vector, difference, xRatio: normalizedVector[0] / normalizedVectorHypot, yRatio: normalizedVector[1] / normalizedVectorHypot, }); return { changed: true }; } // Rendering ----------------------------------------------------------------- function render(arrows: Arrow[]): void { // Add the arrow currently being drawn const drawingCurrentlyDrawn = drag_start ? addDrawnArrow(arrows) : { changed: false }; // Early exit if no arrows to draw if (arrows.length > 0) { // Construct the data const color = preferences.getAnnoteArrowColor(); const data: number[] = arrows.flatMap((arrow) => getDataArrow(arrow, color)); // Render createRenderable(data, 2, 'TRIANGLES', 'color', true).render(); // No transform needed } // Remove the arrow currently being drawn if (drawingCurrentlyDrawn.changed) { if (drawingCurrentlyDrawn.deletedArrow) arrows.push(drawingCurrentlyDrawn.deletedArrow); // Restore the deleted arrow if any else arrows.pop(); } } /** * Generates vertex data for a single arrow. * @param startWorld - The starting coordinates [x, y] of the arrow's base (world space). * @param endWorld - The ending coordinates [x, y] of the arrow's tip (world space). * @param color - The color [r, g, b, a] of the arrow. * @returns The vertex data for the arrow (x,y, r,g,b,a). */ function getDataArrow(arrow: Arrow, color: Color): number[] { // First we need to shift the arrow's base a little away from the center of the starting square. // The distance in squares between the start and end coordinates. const totalLengthSquares: BigDecimal = vectors.euclideanDistance(arrow.start, arrow.end); const entityWidthWorld: number = snapping.getEntityWidthWorld(); // How many squares wide highlights are at this zoom distance. const entityWidthSquares: BigDecimal = boardpos.areZoomedOut() ? space.convertWorldSpaceToGrid(entityWidthWorld) : ONE; // The size of entities at this zoom level. const size = boardpos.areZoomedOut() ? entityWidthWorld : boardpos.getBoardScaleAsNumber(); // How much the arrow base is offset from the start coordinate. const arrowBaseOffsetWorld: number = ARROW.BASE_OFFSET * size; const arrowBaseOffsetSquares: BigDecimal = bd.multiplyFloating( entityWidthSquares, bd.fromNumber(ARROW.BASE_OFFSET), ); // If the arrow length <= base offset, don't draw it (it would have negative length). if (bd.compare(totalLengthSquares, arrowBaseOffsetSquares) <= 0) return []; // Calculate the base and tip world space coordinates let startWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(arrow.start)); let endWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(arrow.end)); // Apply the base offset to the start world coordinates // so the arrow base doesn't start exactly at the center of the square. startWorld[0] += arrow.xRatio * arrowBaseOffsetWorld; startWorld[1] += arrow.yRatio * arrowBaseOffsetWorld; // ----------------------------------------------------------------------------------------- // Make sure the start and end world points don't overflow to Infinity. // To resolve this, we are going to cap the start and end world points to the view distance. const viewBox: DoubleBoundingBox = camera.getPerspectiveScreenBox(); // World space view box // Convert to squares const boardPos: BDCoords = boardpos.getBoardPos(); const boardScale: BigDecimal = boardpos.getBoardScale(); const viewBoxTiles: BoundingBoxBD = { left: space.convertWorldSpaceToCoords_Axis(viewBox.left, boardScale, boardPos[0]), right: space.convertWorldSpaceToCoords_Axis(viewBox.right, boardScale, boardPos[0]), bottom: space.convertWorldSpaceToCoords_Axis(viewBox.bottom, boardScale, boardPos[1]), top: space.convertWorldSpaceToCoords_Axis(viewBox.top, boardScale, boardPos[1]), }; // Now take the arrow's vector, and calculate its intersections with this box. const intersections = geometry.findLineBoxIntersectionsBD( bdcoords.FromCoords(arrow.start), arrow.vector, viewBoxTiles, ); if (intersections.length < 2) return []; // Arrow not visible on screen // Make sure the arrow body passes through the screen. if (!intersections[1]!.positiveDotProduct) return []; // start point lies beyond screen // Also check if the first intersection dot product of the vector pointing from the END coords is positive. const dotProductEndToFirstIntersection = vectors.dotProductBD( coordutil.subtractBDCoords(intersections[0]!.coords!, bdcoords.FromCoords(arrow.end)), vectors.negateBDVector(arrow.difference), ); if (bd.compare(dotProductEndToFirstIntersection, ZERO) < 0) return []; // end point lies before screen // startWorld: Make sure it doesn't come before the first intersection. // If it does, set it to the first intersection. // To do this, we're going to have to compare dot products. const firstIntersectionWorld = space.convertCoordToWorldSpace(intersections[0]!.coords!); const startToFirstIntersection: DoubleCoords = coordutil.subtractDoubleCoords( firstIntersectionWorld, startWorld, ); const startToEnd: DoubleCoords = coordutil.subtractDoubleCoords(endWorld, startWorld); const dotProductStart = vectors.dotProductDoubles(startToFirstIntersection, startToEnd); if (dotProductStart > 0) startWorld = firstIntersectionWorld; // startWorld lies before the first intersection, clamp it to the first intersection. // endWorld: Make sure it doesn't go past the last intersection. // If it does, set it to the last intersection. const lastIntersectionWorld = space.convertCoordToWorldSpace(intersections[1]!.coords!); const endToLastIntersection: DoubleCoords = coordutil.subtractDoubleCoords( lastIntersectionWorld, endWorld, ); const endToStart: DoubleCoords = vectors.negateDoubleVector(startToEnd); const dotProductEnd = vectors.dotProductDoubles(endToLastIntersection, endToStart); if (dotProductEnd > 0) endWorld = lastIntersectionWorld; // endWorld lies past the last intersection, clamp it to the last intersection. // ----------------------------------------------------------------------------------------- // Great! Arrow is visible on screen, and start/end world coords are clamped properly. // Now we can generate the arrow vertex data. const [r, g, b, a] = color; const vertices: number[] = []; const bodyWidthArg = ARROW.BODY_WIDTH * size; const tipWidthArg = ARROW.TIP_WIDTH * size; const desiredTipLength = ARROW.TIP_LENGTH * size; const sx = startWorld[0]; const sy = startWorld[1]; const ex = endWorld[0]; const ey = endWorld[1]; const dx = ex - sx; const dy = ey - sy; const length = vectors.euclideanDistanceDoubles(startWorld, endWorld); // World space length from base to tip // Helpers // prettier-ignore const addQuad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void => { vertices.push(x1, y1, r, g, b, a, x2, y2, r, g, b, a, x3, y3, r, g, b, a); vertices.push(x3, y3, r, g, b, a, x4, y4, r, g, b, a, x1, y1, r, g, b, a); }; // prettier-ignore const addTriangle = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number): void => { vertices.push(x1, y1, r, g, b, a, x2, y2, r, g, b, a, x3, y3, r, g, b, a); }; const ndx = dx / length; // Normalized direction vector x const ndy = dy / length; // Normalized direction vector y const pdx = -ndy; // Perpendicular vector x const pdy = ndx; // Perpendicular vector y let actualBodyWidth: number; let actualTipLength: number; let actualTipWidth: number; // --- Calculate actual body and tip lengths based on total length and desired proportions --- // Minimum body length based on its desired proportion of the total length. const proportionallyMinBodyLength = length * ARROW.MIN_BODY_PROPORTION; // Length remaining for the body if the tip takes its full desiredTipLength. const bodyLengthIfFullTip = length - desiredTipLength; if (bodyLengthIfFullTip >= proportionallyMinBodyLength) { // Case 1: Enough space for the full desiredTipLength, AND // the remaining body (length - desiredTipLength) meets or exceeds the proportionallyMinBodyLength. // This is the "ideal" scenario where the tip gets its desired length. actualTipLength = desiredTipLength; actualTipWidth = tipWidthArg; // Tip length is as desired, so tip width is as desired. actualBodyWidth = bodyWidthArg; } else { // Case 2: Not enough space for both full desiredTipLength AND proportionallyMinBodyLength. // This is the "constrained" scenario. // Body gets its proportionallyMinBodyLength. const actualBodyLength = proportionallyMinBodyLength; // Tip gets the rest of the total length. actualTipLength = length - actualBodyLength; // Scale body width and tip width based on how their actual length compares to their desired length. // desiredTipLength is guaranteed > ARROW_DRAW_THRESHOLD here. const ratio = actualTipLength / desiredTipLength; actualBodyWidth = bodyWidthArg * ratio; actualTipWidth = tipWidthArg * ratio; } // Draw Both Body and Tip const halfActualTipWidth = actualTipWidth / 2; const halfActualBodyWidth = actualBodyWidth / 2; // Junction point (where body meets tip base) is 'actualTipLength' back from the end point 'ex, ey'. const tipBaseCenterX = ex - ndx * actualTipLength; const tipBaseCenterY = ey - ndy * actualTipLength; // Tip vertices const tipPointX = ex; const tipPointY = ey; // Tip apex is at the arrow's end point const tipWing1X = tipBaseCenterX + pdx * halfActualTipWidth; const tipWing1Y = tipBaseCenterY + pdy * halfActualTipWidth; const tipWing2X = tipBaseCenterX - pdx * halfActualTipWidth; const tipWing2Y = tipBaseCenterY - pdy * halfActualTipWidth; addTriangle(tipPointX, tipPointY, tipWing1X, tipWing1Y, tipWing2X, tipWing2Y); // Body vertices (rectangle from startCoords to tipBaseCenter) const bodyStartLeftX = sx + pdx * halfActualBodyWidth; const bodyStartLeftY = sy + pdy * halfActualBodyWidth; const bodyStartRightX = sx - pdx * halfActualBodyWidth; const bodyStartRightY = sy - pdy * halfActualBodyWidth; const bodyEndLeftX = tipBaseCenterX + pdx * halfActualBodyWidth; const bodyEndLeftY = tipBaseCenterY + pdy * halfActualBodyWidth; const bodyEndRightX = tipBaseCenterX - pdx * halfActualBodyWidth; const bodyEndRightY = tipBaseCenterY - pdy * halfActualBodyWidth; // prettier-ignore addQuad(bodyStartLeftX, bodyStartLeftY, bodyEndLeftX, bodyEndLeftY, bodyEndRightX, bodyEndRightY, bodyStartRightX, bodyStartRightY); return vertices; } // Exports ------------------------------------------------------------------- export default { update, stopDrawing, stealPointer, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/annotations/drawrays.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/annotations/drawrays.ts /** * This script allows the user to draw rays on the board. * * Helpful for analysis. */ import type { Color } from '../../../../../../../shared/util/math/math.js'; import variant from '../../../../../../../shared/chess/variants/variant.js'; import bdcoords from '../../../../../../../shared/chess/util/bdcoords.js'; import vectors, { Ray } from '../../../../../../../shared/util/math/vectors.js'; import geometry, { BaseRay } from '../../../../../../../shared/util/math/geometry.js'; import coordutil, { BDCoords, Coords, DoubleCoords, } from '../../../../../../../shared/chess/util/coordutil.js'; import space from '../../../misc/space.js'; import mouse from '../../../../util/mouse.js'; import meshes from '../../meshes.js'; import snapping from '../snapping.js'; import gameslot from '../../../chess/gameslot.js'; import boardpos from '../../boardpos.js'; import { Mouse } from '../../../input.js'; import preferences from '../../../../components/header/preferences.js'; import annotations from './annotations.js'; import legalmovemodel from '../legalmovemodel.js'; import highlightline, { Line } from '../highlightline.js'; import selectedpiecehighlightline from '../selectedpiecehighlightline.js'; // Variables ----------------------------------------------------------------- /** The color of preset rays for the variant. */ const PRESET_RAY_COLOR: Color = [1, 0.2, 0, 0.24]; // Default: 0.18 Transparent orange (makes preset rays less noticeable/distracting) /** * The preset ray overrides if provided from the ICN. * These override the variant's preset rays. */ let preset_rays: BaseRay[] | undefined; /** This will be defined if we are CURRENTLY drawing a ray. */ let drag_start: Coords | undefined; /** The ID of the pointer that is drawing the ray. */ let pointerId: string | undefined; /** The last known position of the pointer drawing a ray. */ let pointerWorld: DoubleCoords | undefined; // Getters ------------------------------------------------------------------- /** Whether a ray is currently being drawn. */ function areDrawing(): boolean { return drag_start !== undefined; } /** Returns all the preset rays in the current variant. */ function getPresetRays(): Ray[] { const baseRays = preset_rays ?? variant.getRayPresets(gameslot.getGamefile()!.boardsim.variant); // Maps a list of plain rays to a new Ray list that contains their line coefficient info. return baseRays.map((r) => { return { start: r.start, vector: r.vector, line: vectors.getLineGeneralFormFromCoordsAndVec(r.start, r.vector), }; }); } // Updating ----------------------------------------------------------------- /** * Tests if the user has started/finished drawing new rays, * or deleting any existing ones. * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this! * @param rays - All ray annotations currently on the board. */ function update(rays: Ray[]): void { const respectiveListener = mouse.getRelevantListener(); if (!drag_start) { // Not currently drawing a ray if (mouse.isMouseDoubleClickDragged(Mouse.RIGHT)) { // Double click drag this frame mouse.claimMouseDown(Mouse.RIGHT); // Claim to prevent the same pointer dragging the board pointerId = respectiveListener.getMouseId(Mouse.RIGHT)!; pointerWorld = mouse.getPointerWorld(pointerId!); if (!pointerWorld) return stopDrawing(); // Could have double click dragged while looking into sky? const closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld); const snapCoords = snapping.getWorldSnapCoords(pointerWorld); if ((boardpos.areZoomedOut() && closestEntityToWorld) || snapCoords) { if (snapCoords) drag_start = coordutil.copyCoords(snapCoords); else if (closestEntityToWorld) { // Snap to nearest hovered entity drag_start = coordutil.copyCoords(closestEntityToWorld.coords); } else throw Error('How did we get here?'); } else { // No snap drag_start = space.convertWorldSpaceToCoords_Rounded(pointerWorld); } // console.log("Ray drag start:", drag_start); } } else { // Currently drawing a ray // Test if pointer released (finalize ray) // If not released, delete any Square present on the Ray start if (respectiveListener.pointerExists(pointerId!)) pointerWorld = mouse.getPointerWorld(pointerId!); // Update its last known position if (respectiveListener.isPointerHeld(pointerId!)) { // Pointer is still holding if (!pointerWorld) return; // Maybe we're looking into sky? const pointerCoords = space.convertWorldSpaceToCoords_Rounded(pointerWorld); // If the mouse coords is different from the drag start, now delete any Squares off of the start coords of the ray. // This prevents the start coord from being highlighted too opaque. if (!coordutil.areCoordsEqual(pointerCoords, drag_start!)) { const squares = annotations.getSquares(); const index = squares.findIndex((coords) => coordutil.areCoordsEqual(coords, drag_start!), ); if (index !== -1) { squares.splice(index, 1); // Remove the square highlight // console.log("Removed square highlight."); } } } else { // The pointer is no longer being held // Prevents accidentally ray drawing if we intend to draw square if (!mouse.isMouseClicked(Mouse.RIGHT)) { addDrawnRay(rays); // Finalize the ray dispatchRayCountEvent(rays); } stopDrawing(); } } } function getPointerId(): string { if (!pointerId) throw Error( "Pointer ID is undefined. Don't call drawrays.getPointerId() if not drawing a ray.", ); return pointerId; } function stopDrawing(): void { drag_start = undefined; pointerId = undefined; pointerWorld = undefined; } /** If the given pointer is currently being used to draw a ray, this stops using it. */ function stealPointer(pointerIdToSteal: string): void { if (pointerId !== pointerIdToSteal) return; // Not the pointer drawing the ray, don't stop using it. stopDrawing(); } /** Returns all the Rays converted to Lines, which are rendered easily. */ function getLines(rays: Ray[], color: Color): Line[] { const boundingBox = highlightline.getRenderRange(); const lines: Line[] = []; for (const ray of rays) { const rayStartBD = bdcoords.FromCoords(ray.start); // Find the points it intersects the screen const intersectionPoints = geometry.findLineBoxIntersectionsBD( rayStartBD, ray.vector, boundingBox, ); if (intersectionPoints.length < 2) continue; // Ray has no intersections with screen, not visible, don't render. if ( !intersectionPoints[0]!.positiveDotProduct && !intersectionPoints[1]!.positiveDotProduct ) continue; // Ray STARTS off screen and goes in the opposite direction. Not visible. const start = intersectionPoints[0]!.positiveDotProduct ? intersectionPoints[0]!.coords : rayStartBD; lines.push({ start, end: intersectionPoints[1]!.coords, coefficients: ray.line, color, }); } return lines; } /** * Adds the currently drawn ray to the list. * If a matching ray already exists, that will be removed instead. * Any coincident rays are removed. * @param rays - All rays currently visible on the board. * @returns An object containing the results, such as whether the ray was added, and what rays were deleted if any. */ function addDrawnRay(rays: Ray[]): { added: boolean; deletedRays?: Ray[] } { if (!pointerWorld) return { added: false }; // Probably stopped drawing while looking into sky? const drag_end = space.convertWorldSpaceToCoords_Rounded(pointerWorld); // Skip if end equals start (no ray drawn) if (coordutil.areCoordsEqual(drag_start!, drag_end)) return { added: false }; // const vector_unnormalized = coordutil.subtractCoords(drag_end, drag_start!); const mouseTileCoords = space.convertWorldSpaceToCoords(pointerWorld); const vector_unnormalized = coordutil.subtractBDCoords( mouseTileCoords, bdcoords.FromCoords(drag_start!), ); const vector = findClosestPredefinedVector( vector_unnormalized, gameslot.getGamefile()!.boardsim.pieces.hippogonalsPresent, ); const line = vectors.getLineGeneralFormFromCoordsAndVec(drag_start!, vector); const deletedRays: Ray[] = []; // If any existing rays are coincident, remove those. for (let i = rays.length - 1; i >= 0; i--) { // Iterate backwards since we're modifying the list as we go const ray = rays[i]!; if (!coordutil.areCoordsEqual(ray.vector, vector)) continue; // Not parallel (assumes vectors are normalized) if (coordutil.areCoordsEqual(ray.start, drag_start!)) { // Identical, erase the existing one instead. rays.splice(i, 1); // Remove the existing ray deletedRays.push(ray); // console.log("Erasing ray."); return { added: false, deletedRays }; } const line2 = ray.line; if (vectors.areLinesInGeneralFormEqual(line, line2)) { // Coincident // Calculate the dot product the ray's vectors. // If it's positive, they point in the same direction, otherwise opposite. const dotProd = vectors.dotProduct(vector, ray.vector); if (dotProd > 0) { // Positive, they point in same direction // Which one is contained in the other? const vecToComparingRayStart = coordutil.subtractCoords(ray.start, drag_start!); const dotProd2 = vectors.dotProduct(vector, vecToComparingRayStart); if (dotProd2 > 0) { // Positive = comparing ray is contained within the new ray // Remove this comparing ray in favor of the new one rays.splice(i, 1); deletedRays.push(ray); // console.log("Removed ray in favor of new."); } else { // Skip adding the new one (it already exists contained in this comparing one) // console.log("Ray is already contained in another."); if (deletedRays.length > 0) throw Error( 'Should not be any rays deleted if ray to be added is contained within another!', ); return { added: false }; } } else { // Negative, they point in opposite directions // Keep both console.log('Rays point in opposite directions.'); } } } // Add the ray const ray = { start: drag_start!, vector, line }; rays.push(ray); // console.log("Added ray:", ray); return { added: true, deletedRays }; } /** * Finds the VECTOR whose angle most closely matches the angle of the given targetVector. * This helps us snap the ray's direction to a slide direction in the game. */ function findClosestPredefinedVector(targetVector: BDCoords, searchHippogonals: boolean): Coords { // Since the targetVector can be arbitrarily large, we need to normalize it // NEAR the range 0-1 (don't matter if it's not exact) so that we can use javascript numbers. const normalizedVector = vectors.normalizeVectorBD(targetVector); // Now we can use small numbers const targetAngle = Math.atan2(normalizedVector[1], normalizedVector[0]); // Y value first // prettier-ignore const searchVectors: Coords[] = searchHippogonals ? [ ...vectors.VECTORS_ORTHOGONAL, ...vectors.VECTORS_DIAGONAL, ...vectors.VECTORS_HIPPOGONAL ] : [ ...vectors.VECTORS_ORTHOGONAL, ...vectors.VECTORS_DIAGONAL ]; // Add the negation of all vectors for (let i = searchVectors.length - 1; i >= 0; i--) { searchVectors.push(vectors.negateVector(searchVectors[i]!)); } let minAbsoluteAngleDifference = Infinity; // Initialize with the first vector let closestVector: Coords = searchVectors[0]!; for (const predefinedVector of searchVectors) { const predifinedVectorDouble: DoubleCoords = vectors.convertVectorToDoubles(predefinedVector); const angle = Math.atan2(predifinedVectorDouble[1], predifinedVectorDouble[0]); // Calculate the difference in angles let angleDifferenceRad = targetAngle - angle; // Normalize angleDifferenceRad to the shortest signed angle in the range [-PI, PI]. // This ensures that angles like -179 deg and 179 deg are considered close (2 deg diff), not far (358 deg diff). // Example: diff = 350 deg (almost 2PI). Normalized: -10 deg. // diff = -350 deg. Normalized: 10 deg. angleDifferenceRad = angleDifferenceRad - 2 * Math.PI * Math.round(angleDifferenceRad / (2 * Math.PI)); const currentAbsoluteAngleDifference = Math.abs(angleDifferenceRad); if (currentAbsoluteAngleDifference < minAbsoluteAngleDifference) { minAbsoluteAngleDifference = currentAbsoluteAngleDifference; closestVector = predefinedVector; } } return closestVector; } /** * Collapses all existing rays into a list of intersection coords points. * * This includes all drawn ray starts, all intersections between drawn & all rays, * and all intersections between drawn rays and the selected piece's legal move rays/segments. */ function collapseRays(rays_drawn: Ray[], trimDecimals: boolean): BDCoords[] { const intersections: BDCoords[] = []; const rays_preset = getPresetRays(); const rays_all: Ray[] = [...rays_drawn, ...rays_preset]; if (rays_all.length === 0) return intersections; // First add the start coords of all rays to the list of intersections for (const ray of rays_drawn) addSquare_NoDuplicates(bdcoords.FromCoords(ray.start)); // Then add all the intersection points of the rays (drawn against drawn + preset, SKIP preset against preset) for (let a = 0; a < rays_drawn.length; a++) { const ray1 = rays_drawn[a]!; // Gauranteed drawn ray for (let b = a + 1; b < rays_all.length; b++) { const ray2 = rays_all[b]!; // Could be drawn or preset ray // Calculate where they intersect const intsect = geometry.intersectRays(ray1, ray2); if (intsect === undefined) continue; // No intersection, skip. // Verify the intersection point is an integer if (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse. // OPTIONAL: Floor() the coords and add it anyway, even if not integer. // intsect = space.roundCoords(intsect); // Push it to the collapsed coord intersections if there isn't a duplicate already addSquare_NoDuplicates(intsect); } } // Add all the intersection points of the drawn rays with all // the components of the selected piece's legal move lines. const { rays: selectedPieceRays, segments: selectedPieceSegments } = selectedpiecehighlightline.getLineComponents(); for (const ray of rays_all) { // Selected piece legal move RAYS for (const legalRay of selectedPieceRays) { const intsect = geometry.intersectRays(ray, legalRay); if (intsect === undefined) continue; // No intersection, skip. // Verify the intersection point is an integer if (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse. // Push it to the collapsed coord intersections if there isn't a duplicate already addSquare_NoDuplicates(intsect); } // Selected piece legal move SEGMENTS for (const segment of selectedPieceSegments) { const intsect = geometry.intersectRayAndSegment(ray, segment.start, segment.end); if (intsect === undefined) continue; // No intersection, skip. // Verify the intersection point is an integer if (trimDecimals && !bdcoords.areCoordsIntegers(intsect)) continue; // Not an integer, don't collapse. // Push it to the collapsed coord intersections if there isn't a duplicate already addSquare_NoDuplicates(intsect); } } function addSquare_NoDuplicates(coords: BDCoords): void { if (intersections.every((coords2) => !coordutil.areBDCoordsEqual(coords, coords2))) intersections.push(coords); } return intersections; } function dispatchRayCountEvent(rays: Ray[]): void { document.dispatchEvent(new CustomEvent('ray-count-change', { detail: rays.length })); } /** * Sets the preset rays, if they were specified in the ICN. * These override the variant's preset rays. */ function setPresetOverrides(prs: BaseRay[]): void { if (preset_rays) throw Error('Preset rays already initialized. Did you forget to clearPresetOverrides()?'); preset_rays = prs; } /** Returns the preset ray overrides from the ICN. */ function getPresetOverrides(): BaseRay[] | undefined { return preset_rays; } /** Clears the preset ray overrides from the ICN. */ function clearPresetOverrides(): void { preset_rays = undefined; } // Rendering ----------------------------------------------------------------- /** Renders all existing rays, including preset rays. */ function render(rays: Ray[]): void { // Add the ray currently being drawn const drawingCurrentlyDrawn = drag_start ? addDrawnRay(rays) : { added: false }; const presetRays = getPresetRays(); const drawnRaysColor = preferences.getAnnoteSquareColor(); const presetRaysColor: Color = [...PRESET_RAY_COLOR]; genAndRenderRays(rays, drawnRaysColor); genAndRenderRays(presetRays, presetRaysColor); // Remove the ray currently being drawn if (drawingCurrentlyDrawn.added) rays.pop(); // Restore the deleted rays if any if (drawingCurrentlyDrawn.deletedRays) rays.push(...drawingCurrentlyDrawn.deletedRays); } /** Generates and renders a model for the given rays and color. */ function genAndRenderRays(rays: Ray[], color: Color): void { if (rays.length === 0) return; // Nothing to render if (boardpos.areZoomedOut()) { // Zoomed out, render rays as highlight lines color[3] = 1; // Highlightlines are fully opaque const lines = getLines(rays, color); highlightline.genLinesModel(lines).render(); } else { // Zoomed in, render rays as infinite legal move highlights const { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset()); legalmovemodel.genModelForRays(rays, color).render(position, scale); } } // Exports ------------------------------------------------------------------- export default { PRESET_RAY_COLOR, areDrawing, getPresetRays, update, getPointerId, stealPointer, stopDrawing, getLines, findClosestPredefinedVector, collapseRays, dispatchRayCountEvent, setPresetOverrides, getPresetOverrides, clearPresetOverrides, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/annotations/drawsquares.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/annotations/drawsquares.ts /** * This script allows the user to highlight squares on the board. * * Helpful for analysis, and requested by many. */ import type { Color } from '../../../../../../../shared/util/math/math.js'; import type { Square } from './annotations.js'; import type { Coords, DoubleCoords } from '../../../../../../../shared/chess/util/coordutil.js'; import vectors from '../../../../../../../shared/util/math/vectors.js'; import variant from '../../../../../../../shared/chess/variants/variant.js'; import bdcoords from '../../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../../shared/chess/util/coordutil.js'; import space from '../../../misc/space.js'; import mouse from '../../../../util/mouse.js'; import snapping from '../snapping.js'; import boardpos from '../../boardpos.js'; import gameslot from '../../../chess/gameslot.js'; import guipause from '../../../gui/guipause.js'; import { Mouse } from '../../../input.js'; import preferences from '../../../../components/header/preferences.js'; import squarerendering from '../squarerendering.js'; // Constants ----------------------------------------------------------------- /** The color of preset squares for the variant. */ const PRESET_SQUARE_COLOR: Color = [1, 0.2, 0, 0.24]; // Default: 0.19 Transparent orange (makes preset squares less noticeable/distracting) /** * To make single Square highlight more visible than rays (which * include a LOT of squares), lone squares get an opacity offset. */ const OPACITY_OFFSET = 0.08; /** ADDITONAL (not overriding) opacity when hovering over highlights. */ const HOVER_OPACITY = 0.5; // Variables ----------------------------------------------------------------- /** * The preset square overrides if provided from the ICN. * These override the variant's preset squares. */ let preset_squares: Square[] | undefined; // Updating ----------------------------------------------------------------- /** Returns a list of all square highlights being hovered over by any pointer. */ function getAllSquaresHovered(highlights: Square[]): Coords[] { const allHovered: Square[] = []; for (const pointerWorld of mouse.getAllPointerWorlds()) { const hovered = getSquaresBelowWorld(highlights, pointerWorld, false).squares; hovered.forEach((coords) => { // Prevent duplicates if (!allHovered.some((c) => coordutil.areCoordsEqual(c, coords))) allHovered.push(coords); }); } return allHovered; } /** Returns a list of Square highlight coordinates that are all being hovered over by the provided world coords. */ function getSquaresBelowWorld( highlights: Square[], world: DoubleCoords, trackDists: boolean, ): { squares: Coords[]; dists?: number[] } { const squares: Square[] = []; const dists: number[] = []; const entityHalfWidthWorld = snapping.getEntityWidthWorld() / 2; // Iterate through each highlight to see if the mouse world is within ENTITY_WIDTH_VPIXELS of it highlights.forEach((coords) => { const coordsWorld = space.convertCoordToWorldSpace(bdcoords.FromCoords(coords)); const dist_cheby = vectors.chebyshevDistanceDoubles(coordsWorld, world); if (dist_cheby < entityHalfWidthWorld) { squares.push(coords); // Upgrade the distance to euclidean if (trackDists) dists.push(vectors.euclideanDistanceDoubles(coordsWorld, world)); } }); if (trackDists) return { squares, dists }; else return { squares }; } /** * Tests if the user has added any new square highlights, * or deleted any existing ones. * REQUIRES THE HOVERED HIGHLIGHTS to be updated prior to calling this! * @param highlights - All square highlights currently on the board. */ function update(highlights: Square[]): void { // If the pointer simulated a right click, add a highlight! if (mouse.isMouseClicked(Mouse.RIGHT)) { mouse.claimMouseClick(Mouse.RIGHT); // Claim the click so other scripts don't also use it const pointerWorld = mouse.getMouseWorld(Mouse.RIGHT); if (!pointerWorld) return; // Maybe we're looking into sky? const pointerSquare: Coords = space.convertWorldSpaceToCoords_Rounded(pointerWorld); const closestEntityToWorld = snapping.getClosestEntityToWorld(pointerWorld); const snapCoords = snapping.getWorldSnapCoords(pointerWorld); if (boardpos.areZoomedOut() && (closestEntityToWorld || snapCoords)) { // Zoomed out & snapping one thing => Snapping behavior if (closestEntityToWorld) { // Now that we have the closest hovered entity, toggle the highlight on its coords. const index = highlights.findIndex((coords) => coordutil.areCoordsEqual(coords, closestEntityToWorld.coords), ); if (index !== -1) highlights.splice(index, 1); // Already highlighted, Remove else highlights.push(closestEntityToWorld.coords); // Add } else if (snapCoords) { // Toggle the highlight on its coords. const index = highlights.findIndex((coords) => coordutil.areCoordsEqual(coords, snapCoords), ); if (index !== -1) throw Error( 'Snap is present, but the highlight already exists. If it exists than it should have been snapped to.', ); highlights.push(snapCoords); // Add } else throw Error('Snapping behavior but no snapCoords or hovered entity found.'); } else { // Zoomed in OR zoomed out with no snap => Normal behavior // Check if the square is already highlighted const index = highlights.findIndex((coords) => coordutil.areCoordsEqual(coords, pointerSquare), ); if (index !== -1) highlights.splice(index, 1); // Remove else highlights.push(pointerSquare); // Add } } } /** * Sets the preset squares, if they were specified in the ICN. * These override the variant's preset squares. */ function setPresetOverrides(pss: Coords[]): void { if (preset_squares) throw Error( 'Preset squares already initialized. Did you forget to clearPresetOverrides()?', ); preset_squares = pss; } /** Returns the preset square overrides from the ICN. */ function getPresetOverrides(): Coords[] | undefined { return preset_squares; } /** Clears the preset ray overrides from the ICN. */ function clearPresetOverrides(): void { preset_squares = undefined; } // Rendering ----------------------------------------------------------------- function render(highlights: Square[]): void { const presetSquares = preset_squares ?? variant.getSquarePresets(gameslot.getGamefile()!.boardsim.variant); // If we're zoomed out, then the size of the highlights is constant. const u_size = boardpos.areZoomedOut() ? snapping.getEntityWidthWorld() : boardpos.getBoardScaleAsNumber(); // Render preset squares (only if zoomed in) if (!boardpos.areZoomedOut() && presetSquares.length > 0) squarerendering .genModel(presetSquares, PRESET_SQUARE_COLOR) .render(undefined, undefined, { u_size }); // Early exit if no drawn-squares to draw if (highlights.length === 0) return; // Render main highlights const color = preferences.getAnnoteSquareColor(); color[3] += OPACITY_OFFSET; // Add opacity offset to make it more visible than rays squarerendering.genModel(highlights, color).render(undefined, undefined, { u_size }); // Render hovered highlights if (!boardpos.areZoomedOut() || guipause.areWePaused()) return; // Don't increase opacity of highlighgts when zoomed in const allHovered = getAllSquaresHovered(highlights); if (allHovered.length > 0) { const hoverColor = preferences.getAnnoteSquareColor(); hoverColor[3] = HOVER_OPACITY; squarerendering.genModel(allHovered, hoverColor).render(undefined, undefined, { u_size }); } } // Exports ------------------------------------------------------------------- export default { PRESET_SQUARE_COLOR, HOVER_OPACITY, update, getAllSquaresHovered, getSquaresBelowWorld, setPresetOverrides, getPresetOverrides, clearPresetOverrides, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/checkhighlight.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/checkhighlight.ts /** * This script renders the red glow surrounding * royal pieces currently in check. */ import type { Board } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Color } from '../../../../../../shared/util/math/math.js'; import type { BDCoords, Coords } from '../../../../../../shared/chess/util/coordutil.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import space from '../../misc/space.js'; import boardpos from '../boardpos.js'; import primitives from '../primitives.js'; import preferences from '../../../components/header/preferences.js'; import { Renderable, createRenderable } from '../../../webgl/Renderable.js'; // Functions ----------------------------------------------------------------------- /** * Renders the red glow around all pieces in check on the currently-viewed move. */ function render(boardsim: Board): void { const royalsInCheck = gamefileutility.getCheckCoordsOfCurrentViewedPosition(boardsim); if (royalsInCheck.length === 0) return; // Nothing in check const model = genCheckHighlightModel(royalsInCheck); model.render(); } /** * Generates the buffer model of the red-glow around each royal piece currently in check. */ function genCheckHighlightModel(royalsInCheck: Coords[]): Renderable { const color = preferences.getCheckHighlightColor(); // [r,g,b,a] const colorOfPerimeter: Color = [color[0], color[1], color[2], 0]; // Same color, but zero opacity const outRad = 0.65 * boardpos.getBoardScaleAsNumber(); const inRad = 0.3 * boardpos.getBoardScaleAsNumber(); const resolution = 20; const data: number[] = []; for (let i = 0; i < royalsInCheck.length; i++) { const thisRoyalInCheckCoordsBD: BDCoords = bdcoords.FromCoords(royalsInCheck[i]!); // This currently doesn't work for squareCenters other than 0.5. I will need to add + 0.5 - board.getSquareCenter() // Create a math function for returning the world-space point of the CENTER of the provided coordinate! const worldSpaceCoord = space.convertCoordToWorldSpace(thisRoyalInCheckCoordsBD); const x = worldSpaceCoord[0]; const y = worldSpaceCoord[1]; const dataCircle: number[] = primitives.Circle(x, y, inRad, resolution, color); // prettier-ignore const dataRing: number[] = primitives.Ring(x, y, inRad, outRad, resolution, color, colorOfPerimeter); data.push(...dataCircle); data.push(...dataRing); } return createRenderable(data, 2, 'TRIANGLES', 'color', true); } export default { render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/highlightline.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/highlightline.ts /** * This script renders our single-line legal sliding moves * when we are zoomed out far. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { BDCoords } from '../../../../../../shared/chess/util/coordutil.js'; import type { BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js'; import type { LineCoefficients } from '../../../../../../shared/util/math/vectors.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import space from '../../misc/space.js'; import boardpos from '../boardpos.js'; import boardtiles from '../boardtiles.js'; import perspective from '../perspective.js'; import { Renderable, createRenderable } from '../../../webgl/Renderable.js'; /** * A single highlight line. * * Coords are clamped to screen edge, since * we can't render lines out to infinity. */ interface Line { /** The starting point coords. May have floating point innaccuracy. */ start: BDCoords; /** The ending point coords. May have floating point innaccuracy. */ end: BDCoords; /** The equation of the line in general form. [A,B,C]. PERFECT integers, use this for calculating intersections. */ coefficients: LineCoefficients; /** The color of the line. */ color: Color; /** * The piece type that should be displayed when hovering over the line, if there is one. * Otherwise, a glow dot is rendered when hovering over it. */ piece?: number; } /** * Returns the respective bounding box inside which we should render highlight lines out to, * according to whether we're in perspective mode or not. */ function getRenderRange(): BoundingBoxBD { if (!perspective.getEnabled()) { // 2D mode return boardtiles.gboundingBoxFloat(); } else { // Perspective mode const distToRenderBoardBD: BigDecimal = bd.fromNumber(perspective.distToRenderBoard); const scale: BigDecimal = boardpos.getBoardScale(); const position = boardpos.getBoardPos(); const distToRenderBoard_Tiles: BigDecimal = bd.divideFloating(distToRenderBoardBD, scale); // Shift the box based on our current board position return { left: bd.subtract(position[0], distToRenderBoard_Tiles), right: bd.add(position[0], distToRenderBoard_Tiles), bottom: bd.subtract(position[1], distToRenderBoard_Tiles), top: bd.add(position[1], distToRenderBoard_Tiles), }; } } function genLinesModel(lines: Line[]): Renderable { const data: number[] = lines.flatMap((line) => getLineData(line)); return createRenderable(data, 2, 'LINES', 'color', true); } function getLineData(line: Line): number[] { const startWorld = space.convertCoordToWorldSpace(line.start); const endWorld = space.convertCoordToWorldSpace(line.end); const [r, g, b, a] = line.color; // prettier-ignore return [ // Vertex Color startWorld[0], startWorld[1], r, g, b, a, endWorld[0], endWorld[1], r, g, b, a ]; } export default { getRenderRange, genLinesModel, }; export type { Line }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/highlights.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/highlights.ts /** * This script renders all highlights: * * Last move * Check * Legal moves (of selected piece and hovered arrows) */ import type { Board } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Color } from '../../../../../../shared/util/math/math.js'; import moveutil from '../../../../../../shared/chess/util/moveutil.js'; import boardpos from '../boardpos.js'; import premoves from '../../chess/premoves.js'; import movehints from './movehints.js'; import enginegame from '../../misc/enginegame.js'; import annotations from './annotations/annotations.js'; import preferences from '../../../components/header/preferences.js'; import checkhighlight from './checkhighlight.js'; import squarerendering from './squarerendering.js'; import legalmovehighlights from './legalmovehighlights.js'; import specialrighthighlights from './specialrighthighlights.js'; /** * Renders all highlights, including: * * Last move highlight * Red Check highlight * Legal move highlights * Hovered arrows legal move highlights * Outline of highlights render box */ function render(boardsim: Board): void { if (!boardpos.areZoomedOut()) { // Zoomed in highlightLastMove(boardsim); checkhighlight.render(boardsim); legalmovehighlights.render(); specialrighthighlights.render(); // Should be after legalmovehighlights.render(), since that updates model_Offset } premoves.render(); // Premove highlights // Needs to render EVEN if zoomed out (different mode) annotations.render_belowPieces(); // The square highlights added by the user movehints.render(); // Individual legal move hints when in check enginegame.render(); // Engine games can render a debug of engine generated moves } /** Highlights the start and end squares of the most recently played move. */ function highlightLastMove(boardsim: Board): void { const lastMove = moveutil.getCurrentMove(boardsim); if (!lastMove) return; // Don't render if last move is undefined. const color: Color = preferences.getLastMoveHighlightColor(); const u_size: number = boardpos.getBoardScaleAsNumber(); squarerendering .genModel([lastMove.startCoords, lastMove.endCoords], color) .render(undefined, undefined, { u_size }); } export default { render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/legalmovehighlights.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/legalmovehighlights.ts /** * [ZOOMED IN] This script renders legal moves of: * * * Selected piece * * All hovered arrows */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Piece } from '../../../../../../shared/chess/util/boardutil.js'; import type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js'; import typeutil from '../../../../../../shared/chess/util/typeutil.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import camera from '../camera.js'; import meshes from '../meshes.js'; import selection from '../../chess/selection.js'; import preferences from '../../../components/header/preferences.js'; import piecemodels from '../piecemodels.js'; import { GameBus } from '../../GameBus.js'; import frametracker from '../frametracker.js'; import legalmovemodel from './legalmovemodel.js'; import legalmoveshapes from '../instancedshapes.js'; import arrowlegalmovehighlights from '../arrows/arrowlegalmovehighlights.js'; import { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js'; // Variables ----------------------------------------------------------------------------- /** The current piece selected, if there is one. */ let pieceSelected: Piece | undefined; /** The current selected piece's legal moves, if there is one. */ let selectedPieceLegalMoves: LegalMoves | undefined; /** * A buffer model that contains the single square * highlight immediately underneath the selected piece. */ let model_SelectedPiece: RenderableInstanced | undefined; /** * An model using instanced-rendering for rendering the * non-capturing selected piece's legal move highlights */ let model_NonCapture: RenderableInstanced | undefined; /** * An model using instanced-rendering for rendering the * capturing selected piece's legal move highlights */ let model_Capture: RenderableInstanced | undefined; // Init Listeners -------------------------------------------------------------------------------- // When the legal move shape settings is modified, regenerate the model of the highlights document.addEventListener('legalmove-shape-change', regenerateAll); // Custom Event // When the theme is changed, erase the models so they // will be regenerated next render call. document.addEventListener('theme-change', regenerateAll); // On Events ------------------------------------------------------------------------------------- GameBus.addEventListener('piece-selected', (event) => { const detail = event.detail; pieceSelected = detail.piece; selectedPieceLegalMoves = detail.legalMoves; // Generate the buffer model for the green legal move highlights. regenSelectedPieceLegalMovesHighlightsModel(); }); GameBus.addEventListener('piece-unselected', () => { pieceSelected = undefined; selectedPieceLegalMoves = undefined; // Erase models model_SelectedPiece = undefined; model_NonCapture = undefined; model_Capture = undefined; }); // Rendering -------------------------------------------------------------------------------------- /** * Renders the legal move highlights of the selected piece, all hovered arrows, * and outlines the box containing all of them. */ function render(): void { // Sometimes when we are just panning around, our screen bounding box // exits the box containing our generating legal move highlights mesh. // When that happens, update the box and regenerate the highlights! const changeMade = legalmovemodel.updateRenderRange(); if (changeMade) regenerateAll(); renderSelectedPieceLegalMoves(); arrowlegalmovehighlights.renderEachHoveredPieceLegalMoves(); if (camera.getDebug()) legalmovemodel.renderOutlineOfRenderBox(); } /** * Regenerates both the models of our selected piece's legal move highlights, * and the models of pieces legal moves of which we're currently hovering over their arrow, * and the model of the special rights highlights. * * Basically everything that relies on {@link model_Offset} */ function regenerateAll(): void { regenSelectedPieceLegalMovesHighlightsModel(); arrowlegalmovehighlights.regenModelsOfHoveredPieces(); } // Regenerates the model for all highlighted legal moves. function regenSelectedPieceLegalMovesHighlightsModel(): void { if (!pieceSelected) return; // console.log("Regenerating legal moves model.."); // The model of the selected piece's legal moves const selectedPieceColor = typeutil.getColorFromType(pieceSelected!.type); const color_options = { isOpponentPiece: selection.isOpponentPieceSelected(), isPremove: selection.arePremoving(), }; const color: Color = preferences.getLegalMoveHighlightColor(color_options); const { NonCaptureModel, CaptureModel } = legalmovemodel.generateModelsForPiecesLegalMoveHighlights( pieceSelected!.coords, selectedPieceLegalMoves!, selectedPieceColor, color, ); model_NonCapture = NonCaptureModel; model_Capture = CaptureModel; // The selected piece highlight model const vertexData: number[] = legalmoveshapes.getDataLegalMoveSquare(color); const coords = pieceSelected!.coords; const offsetCoord = coordutil.subtractCoords(coords, legalmovemodel.getOffset()); const instanceData: bigint[] = [...offsetCoord]; model_SelectedPiece = createRenderable_Instanced( vertexData, piecemodels.castBigIntArrayToFloat32(instanceData), 'TRIANGLES', 'colorInstanced', true, ); frametracker.onVisualChange(); } /** * Renders the current selected piece's legal move mesh, * IF a piece is selected. * * The mesh should have been pre-calculated. */ function renderSelectedPieceLegalMoves(): void { if (!pieceSelected) return; // No model to render const { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset()); // Render each of the models using instanced rendering. model_SelectedPiece!.render(position, scale); model_NonCapture!.render(position, scale); model_Capture!.render(position, scale); } // Exports ----------------------------------------------------------------------------------- export default { // Rendering render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/legalmovemodel.ts /** * [ZOOMED IN] This script handles the model * generation of piece's legal move highlights. * * That also includes Rays. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Player } from '../../../../../../shared/chess/util/typeutil.js'; import type { MoveTagged } from '../../../../../../shared/chess/logic/movepiece.js'; import type { IgnoreFunction } from '../../../../../../shared/chess/logic/movesets.js'; import type { Board, FullGame } from '../../../../../../shared/chess/logic/gamefile.js'; import type { Ray, Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import type { LegalMoves, SlideLimits } from '../../../../../../shared/chess/logic/legalmoves.js'; import type { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bimath from '../../../../../../shared/util/math/bimath.js'; import vectors from '../../../../../../shared/util/math/vectors.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import boardutil from '../../../../../../shared/chess/util/boardutil.js'; import checkresolver from '../../../../../../shared/chess/logic/checkresolver.js'; import geometry, { IntersectionPoint } from '../../../../../../shared/util/math/geometry.js'; import bounds, { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js'; import space from '../../misc/space.js'; import meshes from '../meshes.js'; import gameslot from '../../chess/gameslot.js'; import boardpos from '../boardpos.js'; import boardtiles from '../boardtiles.js'; import primitives from '../primitives.js'; import preferences from '../../../components/header/preferences.js'; import piecemodels from '../piecemodels.js'; import perspective from '../perspective.js'; import legalmoveshapes from '../instancedshapes.js'; import instancedshapes from '../instancedshapes.js'; import { AttributeInfoInstanced, RenderableInstanced, createRenderable, createRenderable_Instanced, createRenderable_Instanced_GivenInfo, } from '../../../webgl/Renderable.js'; // Types ----------------------------------------------------------------------- /** Information for iterating the instance data of a legal move line as far as it needs to be rendered. */ type RayIterationInfo = { /** The first TRUE coordinate the ray starts on. */ startCoords: Coords; /** The OFFSET coordinate the ray starts on. */ startCoordsOffset: Coords; /** How many times to repeat a highlight in one direction for this given ray. */ iterationCount: number; }; // Constants ----------------------------------------------------------------------------- /** The attribute info for all legal move highlight instanced rendering models. */ const ATTRIB_INFO: AttributeInfoInstanced = { vertexDataAttribInfo: [ { name: 'a_position', numComponents: 2 }, { name: 'a_color', numComponents: 4 }, ], instanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }], }; /** * An offset applied to the legal move highlights mesh, to keep all of the * vertex data less than this number. * * The offset snaps to the nearest grid number of this size. * * Without an offset, the vertex data has no imposed limit to how big the numbers can * get, which ends up creating graphical glitches MUCH SOONER, because the * GPU is only capable of Float32s, NOT Float64s (which javascript numbers are). * * The legal move highlights offset will snap to this nearest number on the grid. * * For example, if we're at position [8700,0] on the board, then the legal move highlight * offset will snap to [10000,0], making it so that the vertex data only needs to contain * numbers around 1300 instead of 8700 without an offset. * * Using an offset means the vertex data ALWAYS remains less than 10000! */ const highlightedMovesRegenRange = 10_000n; /** The distance, in perspective mode, we want to aim to render legal moves highlights out to, or farther. */ const PERSPECTIVE_VIEW_RANGE = 1000; /** Amount of screens in number the render range bounding box should try to aim for beyond the screen. */ const multiplier = 4; /** * In perspective mode, visible range is considered 1000. This is the multiplier to that for the render range bounding box. */ const multiplier_perspective = 2; const ZERO: BigDecimal = bd.fromBigInt(0n); // Variables ----------------------------------------------------------------------------- /** * The current view box to generate visible legal moves inside. * * We can only generate the mesh up to a finite distance. * This box dynamically grows, shrinks, and translates, * to ALWAYS keep the entire screen in the box. * * By default it expands past the screen somewhat, so that a little * panning around doesn't immediately trigger this view box to change. * * THIS REPRESENTS THE INTEGER TILES INCLUDED IN THE RANGE. * For example, a `right` of 10 means it includes the X=10 tiles. */ let boundingBoxOfRenderRange: BoundingBox | undefined; /** * How much the vertex data of the highlight models has been offset, to make their numbers * close to zero, to avoid floating point imprecision. * * This is the nearest multiple of {@link highlightedMovesRegenRange} our camera is at. */ let model_Offset: Coords = [0n, 0n]; // Updating Render Range and Offset -------------------------------------------------- /** Returns {@link model_Offset} */ function getOffset(): Coords { return model_Offset; } /** * Updates the offset and bounding box universal to all rendered legal move highlights. * If a change is made, it calls to regenerate the model. * @returns Whether a change was made, updating it. */ function updateRenderRange(): boolean { // Determine if our camera/screen exceeds the boundary of our render range box... if (isViewRangeContainedInRenderRange()) return false; // No change needed // Regenerate the legal move highlights render range bounding box // console.log("Recalculating bounding box of render range."); const [newWidth, newHeight] = perspective.getEnabled() ? getDimensionsOfPerspectiveViewRange() : getDimensionsOfOrthographicViewRange(); const halfNewWidth: BigDecimal = bd.fromNumber(newWidth / 2); const halfNewHeight: BigDecimal = bd.fromNumber(newHeight / 2); const boardPos = boardpos.getBoardPos(); boundingBoxOfRenderRange = { left: space.roundCoord(bd.subtract(boardPos[0], halfNewWidth)), right: space.roundCoord(bd.add(boardPos[0], halfNewWidth)), bottom: space.roundCoord(bd.subtract(boardPos[1], halfNewHeight)), top: space.roundCoord(bd.add(boardPos[1], halfNewHeight)), }; /** Update our offset to the nearest grid-point multiple of {@link highlightedMovesRegenRange} */ model_Offset = geometry.roundPointToNearestGridpoint( boardpos.getBoardPos(), highlightedMovesRegenRange, ); // console.log("Shifted offset of highlights."); return true; // A change was made } /** * Returns whether our camera/screen view box is contained within * our legal move highlights render range box, * OR if it's significantly smaller than it. */ function isViewRangeContainedInRenderRange(): boolean { if (!boundingBoxOfRenderRange) return false; // It isn't even initiated yet // The bounding box of what the camera currently sees on-screen. const boundingBoxOfView: BoundingBoxBD = perspective.getEnabled() ? getBoundingBoxOfPerspectiveView() : boardtiles.gboundingBoxFloat(); // In 2D mode, we also care about whether the // camera box is significantly smaller than our render range. if (!perspective.getEnabled()) { // We can cast to number since we're confident it's going to be small (we are zoomed in) const width: number = bd.toNumber( bd.subtract(boundingBoxOfView.right, boundingBoxOfView.left), ); const renderRangeWidth: number = Number(boundingBoxOfRenderRange.right - boundingBoxOfRenderRange.left) + 1; // multiplier needs to be squared cause otherwise when we zoom in it regenerates the render box every frame. if (width * multiplier * multiplier < renderRangeWidth) return false; } const floatingRenderRangeBox = meshes.expandTileBoundingBoxToEncompassWholeSquare(boundingBoxOfRenderRange); // Whether the camera view box exceeds the boundaries of the render range return bounds.boxContainsBoxBD(floatingRenderRangeBox, boundingBoxOfView); } /** [PERSPECTIVE] Returns our approximate camera view range bounding box. */ function getBoundingBoxOfPerspectiveView(): BoundingBoxBD { const boardPos = boardpos.getBoardPos(); const viewDist: BigDecimal = bd.fromNumber(PERSPECTIVE_VIEW_RANGE); return { left: bd.subtract(boardPos[0], viewDist), right: bd.add(boardPos[0], viewDist), bottom: bd.subtract(boardPos[1], viewDist), top: bd.add(boardPos[1], viewDist), }; } /** [PERSPECTIVE] Returns the target dimensions of the legal move highlights box. */ function getDimensionsOfPerspectiveViewRange(): DoubleCoords { const width = PERSPECTIVE_VIEW_RANGE * 2; const newWidth = width * multiplier_perspective; return [newWidth, newWidth]; } /** [ORTHOGRAPHIC] Returns the target dimensions of the legal move highlights box. */ function getDimensionsOfOrthographicViewRange(): DoubleCoords { // New improved method of calculating render bounding box const boundingBoxOfView = boardtiles.gboundingBox(false); const width: number = Number(boundingBoxOfView.right - boundingBoxOfView.left) + 1; // Need to +1 since the board bounding box just includes the integer squares, not floating point edges. const height: number = Number(boundingBoxOfView.top - boundingBoxOfView.bottom) + 1; const newWidth = width * multiplier; const newHeight = height * multiplier; if (boardpos.areZoomedOut()) throw Error("Don't recalculate legal move highlights box zoomed out!"); // Don't want to generate a stupidly large model return [newWidth, newHeight]; } // Generating Legal Move Buffer Models ---------------------------------------------------------------------------------- /** * Generates the renderable instanced rendering buffer models for the * legal move highlights of the given piece's legal moves. * @param coords - The coordinates of the piece with the provided legal moves * @param legalMoves - The legal moves of which to generate the highlights models for. * @param friendlyColor - The color of friendly pieces * @param highlightColor - The color to use, which may vary depending on if the highlights are for your piece, opponent's, or a premove. */ function generateModelsForPiecesLegalMoveHighlights( coords: Coords, legalMoves: LegalMoves, friendlyColor: Player, highlightColor: Color, ): { NonCaptureModel: RenderableInstanced; CaptureModel: RenderableInstanced } { const usingDots = preferences.getLegalMovesShape() === 'dots'; /** The vertex data OF A SINGLE INSTANCE of the NON-CAPTURING legal move highlight. Stride 6 (2 position, 4 color) */ const vertexData_NonCapture: number[] = usingDots ? legalmoveshapes.getDataLegalMoveDot(highlightColor) : legalmoveshapes.getDataLegalMoveSquare(highlightColor); /** The instance-specific data of the NON-CAPTURING legal move highlights mesh. Stride 2 (2 instanceposition) */ const instanceData_NonCapture: bigint[] = []; /** The vertex data OF A SINGLE INSTANCE of the CAPTURING legal move highlight. Stride 6 (2 position, 4 color) */ const vertexData_Capture: number[] = usingDots ? legalmoveshapes.getDataLegalMoveCornerTris(highlightColor) : legalmoveshapes.getDataLegalMoveSquare(highlightColor); /** The instance-specific data of the CAPTURING legal move highlights mesh. Stride 2 (2 instanceposition) */ const instanceData_Capture: bigint[] = []; const gamefile = gameslot.getGamefile()!; // Data of short range moves within 3 tiles pushIndividual(instanceData_NonCapture, instanceData_Capture, legalMoves, gamefile.boardsim); // Potentially infinite data on sliding moves... pushSliding( instanceData_NonCapture, instanceData_Capture, coords, legalMoves, gamefile, friendlyColor, ); return { // The NON-CAPTURING legal move highlights model NonCaptureModel: createRenderable_Instanced( vertexData_NonCapture, piecemodels.castBigIntArrayToFloat32(instanceData_NonCapture), 'TRIANGLES', 'colorInstanced', true, ), // The CAPTURING legal move highlights model CaptureModel: createRenderable_Instanced( vertexData_Capture, piecemodels.castBigIntArrayToFloat32(instanceData_Capture), 'TRIANGLES', 'colorInstanced', true, ), }; } /** * Generates a single instanced rendering model of white box outlines for all squares * in the given legal moves (captures and non-captures alike share the same shape). * Used for rendering slide-move highlights when dragging an off-screen piece via its arrow indicator. * @param coords - The coordinates of the piece with the provided legal moves. * @param legalMoves - The legal moves of which to generate the outline model for. */ function generateModelForSlideHighlightOutlines( coords: Coords, legalMoves: LegalMoves, ): RenderableInstanced { const vertexData: number[] = instancedshapes.getDataBoxOutline(); /** The instance-specific position data. Stride 2 (2 instanceposition). Captures and non-captures share the same outline shape. */ const instanceData: bigint[] = []; const gamefile = gameslot.getGamefile()!; // Pass the same array for both capture and non-capture — the outline looks identical for both. pushIndividual(instanceData, instanceData, legalMoves, gamefile.boardsim); // prettier-ignore pushSliding(instanceData, instanceData, coords, legalMoves, gamefile, gamefile.basegame.whosTurn); return createRenderable_Instanced( vertexData, piecemodels.castBigIntArrayToFloat32(instanceData), 'TRIANGLES', 'colorInstanced', true, ); } // Individual Moves ------------------------------------------------------------------------------------------------------ /** * Calculates instanceposition data of legal individual (jumping) moves and appends it to the provided instance data arrays. * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh. * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh. * @param legalMoves - The piece legal moves to highlight * @param boardsim - A reference to the current loaded gamefile's board */ function pushIndividual( instanceData_NonCapture: bigint[], instanceData_Capture: bigint[], legalMoves: LegalMoves, boardsim: Board, ): void { // Get an array of the list of individual legal squares the current selected piece can move to const legalIndividuals: Coords[] = legalMoves.individual; // For each of these squares, calculate it's buffer data for (const coord of legalIndividuals) { const offsetCoord = coordutil.subtractCoords(coord, model_Offset); const isPieceOnCoords = boardutil.isPieceOnCoords(boardsim.pieces, coord); if (isPieceOnCoords) instanceData_Capture.push(...offsetCoord); else instanceData_NonCapture.push(...offsetCoord); } } // Sliding Moves ------------------------------------------------------------------------------------------------------ /** * Calculates instanceposition data of legal sliding moves and appends it to the running instance data arrays. * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh. * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh. * @param coords - The coords of the piece with the provided legal moves * @param legalMoves - The piece legal moves to highlight * @param gamefile - A reference to the current loaded gamefile * @param friendlyColor - The color of friendly pieces */ function pushSliding( instanceData_NonCapture: bigint[], instanceData_Capture: bigint[], coords: Coords, legalMoves: LegalMoves, gamefile: FullGame, friendlyColor: Player, ): void { for (const [lineKey, limits] of Object.entries(legalMoves.sliding)) { // '1,0' const line: Vec2 = vectors.getVec2FromKey(lineKey as Vec2Key); // [dx,dy] // The intersection points this slide direction intersects // our legal move highlights render range bounding box, if it does. // eslint-disable-next-line prefer-const let [intsect1Tile, intsect2Tile] = geometry.findLineBoxIntersections( coords, line, boundingBoxOfRenderRange!, ); if (!intsect1Tile && !intsect2Tile) continue; // No intersection point (off the screen). if (!intsect2Tile) intsect2Tile = intsect1Tile; // If there's only one corner intersection, make the exit point the same as the entry. // prettier-ignore pushSlide(instanceData_NonCapture, instanceData_Capture, coords, line, intsect1Tile!, intsect2Tile!, limits, legalMoves.ignoreFunc, gamefile, friendlyColor, legalMoves.brute); } } /** * Adds the instanceposition data of a directional movement line, in both directions, of ANY SLOPED step to the running instance data arrays. * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh. * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh. * @param coords - The coords of the piece with the provided legal moves * @param step - Of the line / moveset * @param intsect1 - What point this line intersect the left side of the screen box. * @param intsect2 - What point this line intersect the right side of the screen box. * @param limits - Slide limit: [-7,Infinity]. May be an offset segment (same-sign) for colinear blocking. * @param ignoreFunc - The ignore function * @param gamefile - A reference to the current loaded gamefile * @param friendlyColor - The color of friendly pieces * @param brute - If true, each move will be simulated as to whether it results in check, and if so, not added to the mesh data. */ function pushSlide( instanceData_NonCapture: bigint[], instanceData_Capture: bigint[], coords: Coords, step: Vec2, intsect1: IntersectionPoint, intsect2: IntersectionPoint, limits: SlideLimits, ignoreFunc: IgnoreFunction, gamefile: FullGame, friendlyColor: Player, brute?: boolean, ): void { // Compute the negated direction and flipped intersections (used in offset-negative and normal cases) const negStep: Vec2 = vectors.negateVector(step); // Switch the order of intersections and negate their dot product const negVecIntsect1: IntersectionPoint = { coords: intsect2.coords, positiveDotProduct: !intsect2.positiveDotProduct, }; const negVecIntsect2: IntersectionPoint = { coords: intsect1.coords, positiveDotProduct: !intsect1.positiveDotProduct, }; // Special offset cases: the legal zone doesn't include the piece's own // square or span in both directions infinitely — e.g. a Huygen blocked // colinearly, so only squares 100-200 ahead are legal. Only one ray is rendered. if (limits[0] !== null && limits[0] > 0n) { // Offset positive: legal zone is entirely in the positive step direction. if (intsect2.positiveDotProduct) { // prettier-ignore pushRay(instanceData_NonCapture, instanceData_Capture, coords, step, intsect1, intsect2, limits[1], ignoreFunc, gamefile, friendlyColor, brute, limits[0]); } return; } if (limits[1] !== null && limits[1] < 0n) { // Offset negative: legal zone is entirely in the negative step direction. if (negVecIntsect2.positiveDotProduct) { const absLimit0 = limits[0] === null ? null : bimath.abs(limits[0]); const absLimit1 = bimath.abs(limits[1]); // prettier-ignore pushRay(instanceData_NonCapture, instanceData_Capture, coords, negStep, negVecIntsect1, negVecIntsect2, absLimit0, ignoreFunc, gamefile, friendlyColor, brute, absLimit1); } return; } // Normal case: limits span 0 (or one side is null). Render both rays outward from the piece. if (intsect2.positiveDotProduct) { // The start coords are either on screen, or the ray points towards the screen // prettier-ignore pushRay(instanceData_NonCapture, instanceData_Capture, coords, step, intsect1, intsect2, limits[1], ignoreFunc, gamefile, friendlyColor, brute); } // else the start coords are off screen and ray points in the opposite direction of the screen if (negVecIntsect2.positiveDotProduct) { // The start coords are either on screen, or the ray points towards the screen // The first index of slide limit is always negative (or null) in the normal case const absoluteSlideLimit = limits[0] === null ? null : bimath.abs(limits[0]); // prettier-ignore pushRay(instanceData_NonCapture, instanceData_Capture, coords, negStep, negVecIntsect1, negVecIntsect2, absoluteSlideLimit, ignoreFunc, gamefile, friendlyColor, brute); } // else the start coords are off screen and ray points in the opposite direction of the screen } /** * Adds the instanceposition data of a single directional ray (split in 2 from a normal slide) to the running instance data arrays. * @param instanceData_NonCapture - The running array of instance data for the NON-CAPTURING legal moves highlights mesh. * @param instanceData_Capture - The running array of instance data for the CAPTURING legal moves highlights mesh. * @param coords - The coords of the piece with the provided legal moves * @param step - Of the line / moveset * @param intsect1 - What point this line intersect the left side of the screen box. * @param intsect2 - What point this line intersect the right side of the screen box. * @param limit - Needs to be POSITIVE. The farthest number of steps the ray can travel. * @param ignoreFunc - The ignore function, to ignore squares * @param gamefile - A reference to the current loaded gamefile * @param friendlyColor - The color of friendly pieces * @param brute - If true, each move will be simulated as to whether it results in check, and if so, not added to the mesh data. * @param startStep - The step number of the first highlight, counting from the piece. Default 1. Use > 1 for offset segments. */ function pushRay( instanceData_NonCapture: bigint[], instanceData_Capture: bigint[], coords: Coords, step: Vec2, intsect1: IntersectionPoint, intsect2: IntersectionPoint, limit: bigint | null, ignoreFunc: IgnoreFunction, gamefile: FullGame, friendlyColor: Player, brute?: boolean, startStep: bigint = 1n, ): void { if (limit !== null && limit < startStep) return; // Slide range ends before it even starts // prettier-ignore const iterationInfo: RayIterationInfo | undefined = getRayIterationInfo(coords, step, intsect1, intsect2, limit, false, startStep); if (!iterationInfo) return; // None of the piece's slide is visible on screen, skip. const { startCoords, startCoordsOffset, iterationCount } = iterationInfo; // Recursively adds the coords to the instance data list, shifting by the step size. const targetCoords: Coords = startCoords; // The true coords of the square we're checking for (let i = 0; i < iterationCount; i++) { legal: if (ignoreFunc(coords, targetCoords)) { // Ignore function PASSED. (Is a prime square for huygens) // If we're brute force checking each move for check, do that here. (royal queen, or colinear pins) if (brute) { const moveTagged: MoveTagged = { startCoords: coords, endCoords: targetCoords }; if (checkresolver.getSimulatedCheck(gamefile, moveTagged, friendlyColor).check) break legal; } const isPieceOnCoords = boardutil.isPieceOnCoords( gamefile.boardsim.pieces, targetCoords, ); if (isPieceOnCoords) instanceData_Capture.push(...startCoordsOffset); else instanceData_NonCapture.push(...startCoordsOffset); } targetCoords[0] += step[0]; targetCoords[1] += step[1]; // The mesh-offset adjusted coords we're checking startCoordsOffset[0] += step[0]; startCoordsOffset[1] += step[1]; } } /** * Calculates how many times a highlight should be repeated * to cover all squares a ray can reach in the render range, * and calculates where it should start and end. * @param isRay - This will also include the starting coordinate, as is not the behavior for selected pieces. * @param startStep - The step number of the first highlight, counting from the piece. Defaults to 1. Pass a higher value for offset segments where the legal zone doesn't start adjacent to the piece. */ function getRayIterationInfo( coords: Coords, step: Vec2, intsect1: IntersectionPoint, intsect2: IntersectionPoint, limit: bigint | null, isRay: boolean, startStep: bigint = 1n, ): RayIterationInfo | undefined { const coordsBD: BDCoords = bdcoords.FromCoords(coords); const stepBD: BDCoords = bdcoords.FromCoords(step); const axis: 0 | 1 = step[0] === 0n ? 1 : 0; // Use the y axis if the x movement vector is zero // Determine the start coords. let startCoords: Coords = [...coords]; if (!isRay) { // The first highlight starts startStep squares off the piece coords startCoords[0] += step[0] * startStep; startCoords[1] += step[1] * startStep; } // Is the piece off screen in the opposite direction of the step? if (intsect1.positiveDotProduct) { // Adjust the start square to be the first square we land on after intsect1. const axisDistToIntsect1: BigDecimal = bd.subtract(intsect1.coords[axis], coordsBD[axis]); const distInSteps: bigint = bd.toBigInt( bd.ceil(bd.divide(axisDistToIntsect1, stepBD[axis])), ); // Minimum number of steps to overtake the first intersection. // Use the larger of distInSteps and startStep to respect both the screen edge and the slide range start const actualStartStep = distInSteps > startStep ? distInSteps : startStep; startCoords = [ coords[0] + step[0] * actualStartStep, coords[1] + step[1] * actualStartStep, ]; } // Determine the end coords. // How many steps could we take before we reached intsect2? const axisDistanceToIntsect2: BigDecimal = bd.subtract(intsect2.coords[axis], coordsBD[axis]); // The maximum number of steps we can take before exceeding the screen edge const axisStepsToReachIntsect2: bigint = bd.toBigInt( bd.floor(bd.divide(axisDistanceToIntsect2, stepBD[axis])), ); let endCoords: Coords = [ coords[0] + step[0] * axisStepsToReachIntsect2, coords[1] + step[1] * axisStepsToReachIntsect2, ]; if (limit !== null) { // Determine if we can't even slide far enough to reach intsect2. If so, we need to shorten our endCoords // What is the farthest point we can slide to? const furthestSquareWeCanSlide: Coords = [ coords[0] + step[0] * limit, coords[1] + step[1] * limit, ]; const furthestSquareWeCanSlideBD: BDCoords = bdcoords.FromCoords(furthestSquareWeCanSlide); const vectorFromFurthestSquareTowardsIntsect = coordutil.subtractBDCoords( intsect2.coords, furthestSquareWeCanSlideBD, ); const dotProd = vectors.dotProductBD(vectorFromFurthestSquareTowardsIntsect, stepBD); // A dotProd of zero would mean it can slide EXACTLY up to the end of the screen, that is okay // But positive means we can't slide far enough to reach intsect2. Shorten our endCoords! if (bd.compare(dotProd, ZERO) > 0) endCoords = furthestSquareWeCanSlide; } // Next, determine iterationCount and startCoordsOffset. // Calculate how many times we need to iteratively shift this vertex data and append it to our vertex data array const axisDistFromStartToEnd: bigint = endCoords[axis] - startCoords[axis]; // How many legal move squares/dots to render on this line const iterationCount = Number(axisDistFromStartToEnd / step[axis]) + 1; // +1 for start & end inclusive // This will occur if the piece isn't able to move past intsect1, the start of the screen. if (iterationCount <= 0) return undefined; // Shift the vertex data of our first step to the right place const startCoordsOffset: Coords = coordutil.subtractCoords(startCoords, model_Offset); return { startCoords, startCoordsOffset, iterationCount }; } // Rays -------------------------------------------------------------------------------------- /** * Generates a model for rendering all rays in the provided list. * * Rays are square highlights starting from a single coord * and going in one direction to infinity, unobstructed. */ function genModelForRays(rays: Ray[], color: Color): RenderableInstanced { const vertexData = instancedshapes.getDataLegalMoveSquare(color); const instanceData: bigint[] = []; for (const ray of rays) { const step = ray.vector; // eslint-disable-next-line prefer-const let [intsect1Tile, intsect2Tile] = geometry.findLineBoxIntersections( ray.start, ray.vector, boundingBoxOfRenderRange!, ); if (!intsect1Tile && !intsect2Tile) continue; // No intersection point (off the screen). if (!intsect2Tile) intsect2Tile = intsect1Tile; // If there's only one corner intersection, make the exit point the same as the entry. // prettier-ignore const iterationInfo: RayIterationInfo | undefined = getRayIterationInfo(ray.start, ray.vector, intsect1Tile!, intsect2Tile!, null, true); if (iterationInfo === undefined) continue; // Technically should never happen for rays since they are never blocked. const { startCoordsOffset, iterationCount } = iterationInfo; for (let i = 0; i < iterationCount; i++) { instanceData.push(...startCoordsOffset); startCoordsOffset[0] += step[0]; startCoordsOffset[1] += step[1]; } } return createRenderable_Instanced_GivenInfo( vertexData, piecemodels.castBigIntArrayToFloat32(instanceData), ATTRIB_INFO, 'TRIANGLES', 'colorInstanced', ); } // Rendering ---------------------------------------------------------------------------------------- /** * [DEBUG] Renders an outline of the box containing all legal move highlights. * Will only be visible if camera debug mode is on, as this is normally outside of the screen edge. */ function renderOutlineOfRenderBox(): void { // const color: Color = [1,0,1, 1]; // Magenta // const color: Color = [0.65, 0.15, 0, 1]; // Maroon (matches light brown wood theme) const color: Color = [1, 1, 1, 1]; // White const data = meshes.RectWorld(boundingBoxOfRenderRange!, color); createRenderable(data, 2, 'LINE_LOOP', 'color', true).render(); } /** * [DEBUG] Renders an outline of the provided floating point bounding box. */ function renderOutlineofFloatingBox(box: BoundingBoxBD): void { const color: Color = [0.65, 0.15, 0, 1]; const { left, right, bottom, top } = meshes.applyWorldTransformationsToBoundingBox(box); const data = primitives.Rect(left, bottom, right, top, color); createRenderable(data, 2, 'LINE_LOOP', 'color', true).render(); } // Exports ------------------------------------------------------------------------------------------ export default { // Updating Render Range and Offset getOffset, updateRenderRange, // Generating Legal Move Buffer Models generateModelsForPiecesLegalMoveHighlights, generateModelForSlideHighlightOutlines, // Rays genModelForRays, // Rendering renderOutlineOfRenderBox, renderOutlineofFloatingBox, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/movehints.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/movehints.ts /** * This script renders individual legal move hints when the position is in check * and our own piece is selected: * * [Zoomed out] Green entity squares at each individual legal move location. * [Zoomed in] Arrow indicators (via arrows.ts) for off-screen individual legal moves. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Coords } from '../../../../../../shared/chess/util/coordutil.js'; import type { LegalMoves } from '../../../../../../shared/chess/logic/legalmoves.js'; import vectors from '../../../../../../shared/util/math/vectors.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import legalmoves from '../../../../../../shared/chess/logic/legalmoves.js'; import gamefileutility from '../../../../../../shared/chess/util/gamefileutility.js'; import boardpos from '../boardpos.js'; import gameslot from '../../chess/gameslot.js'; import guipause from '../../gui/guipause.js'; import snapping from './snapping.js'; import selection from '../../chess/selection.js'; import gameloader from '../../chess/gameloader.js'; import drawsquares from './annotations/drawsquares.js'; import preferences from '../../../components/header/preferences.js'; import { GameBus } from '../../GameBus.js'; import squarerendering from './squarerendering.js'; // Variables ----------------------------------------------------------------------- /** The coords of the selected piece that owns the individual moves, or undefined. */ let selectedPieceCoords: Coords | undefined; /** The individual legal moves to highlight, if conditions are met. Empty otherwise. */ let individualMoves: Coords[] = []; // Event Listeners ------------------------------------------------------------------ GameBus.addEventListener('piece-selected', (event) => { const { legalMoves } = event.detail; updateIndividualMoves(legalMoves); }); GameBus.addEventListener('piece-unselected', () => { clearIndividualMoves(); }); // Functions ----------------------------------------------------------------------- /** * Updates the list of individual move hints based on the current selection and game state. * Only sets moves when our own non-premove piece is selected and the position is in check. */ function updateIndividualMoves(legalMoves: LegalMoves): void { const gamefile = gameslot.getGamefile()!; if ( selection.isOpponentPieceSelected() || !gameloader.isItOurTurn() || !gamefileutility.isCurrentViewedPositionInCheck(gamefile.boardsim) ) { clearIndividualMoves(); return; } const piece = selection.getPieceSelected()!; selectedPieceCoords = piece.coords; const moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piece.type); individualMoves = legalMoves.individual.filter((hintSquare) => { const diff = coordutil.subtractCoords(hintSquare, selectedPieceCoords!); const dir = vectors.absVector(vectors.normalizeVector(diff)); const vec2Key = vectors.getKeyFromVec2(dir); return !!(moveset.sliding && moveset.sliding[vec2Key]); }); } function clearIndividualMoves(): void { individualMoves = []; selectedPieceCoords = undefined; } // Export for snapping.ts --------------------------------------------------------- /** Returns the coords of the selected piece that owns the individual move hints, or undefined. */ function getPieceCoords(): Coords | undefined { return selectedPieceCoords; } /** Returns the current list of individual legal move hint squares. */ function getSquares(): Coords[] { return individualMoves; } // Rendering ----------------------------------------------------------------------- /** [Zoomed out] Renders the individual legal move hint squares as green entity squares. */ function render(): void { if (individualMoves.length === 0 || !boardpos.areZoomedOut() || guipause.areWePaused()) return; const color: Color = preferences.getLegalMoveHighlightColor({ isOpponentPiece: false, isPremove: false, }); const u_size = snapping.getEntityWidthWorld(); squarerendering.genModel(individualMoves, color).render(undefined, undefined, { u_size }); // Render hovered move hints at higher opacity const allHovered = drawsquares.getAllSquaresHovered(individualMoves); if (allHovered.length > 0) { const hoverColor: Color = [...color]; hoverColor[3] = drawsquares.HOVER_OPACITY; squarerendering.genModel(allHovered, hoverColor).render(undefined, undefined, { u_size }); } } // Exports ------------------------------------------------------------------------- export default { getPieceCoords, getSquares, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/selectedpiecehighlightline.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/selectedpiecehighlightline.ts /** * [Zoomed out] This script calculates and renders the highlight lines * of the currently selected piece's legal moves. */ import type { Ray } from './annotations/annotations.js'; import type { Line } from './highlightline.js'; import bd from '@naviary/bigdecimal'; import geometry from '../../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import vectors, { Vec2, Vec2Key } from '../../../../../../shared/util/math/vectors.js'; import coordutil, { BDCoords, Coords, CoordsKey, } from '../../../../../../shared/chess/util/coordutil.js'; import boardpos from '../boardpos.js'; import selection from '../../chess/selection.js'; import preferences from '../../../components/header/preferences.js'; import highlightline from './highlightline.js'; /** * Calculates all the lines formed from the highlight * lines of the current selected piece's legal moves. */ function getLines(): Line[] { const lines: Line[] = []; const pieceSelected = selection.getPieceSelected()!; if (!pieceSelected) return lines; const pieceCoords = pieceSelected.coords; const legalmoves = selection.getLegalMovesOfSelectedPiece()!; // CAREFUL not to modify! const boundingBox = highlightline.getRenderRange(); const color_options = { isOpponentPiece: selection.isOpponentPieceSelected(), isPremove: selection.arePremoving(), }; const color = preferences.getLegalMoveHighlightColor(color_options); // Returns a copy color[3] = 1; // Highlight lines should be fully opaque for (const [strline, limits] of Object.entries(legalmoves.sliding)) { const slideKey = strline as CoordsKey; const step = coordutil.getCoordsFromKey(slideKey); const lineIsVertical = step[0] === 0n; const cappingAxis = lineIsVertical ? 1 : 0; const intersectionPoints = geometry .findLineBoxIntersectionsBD(bdcoords.FromCoords(pieceCoords), step, boundingBox) .map((intersection) => intersection.coords); if (intersectionPoints.length < 2) continue; let start: BDCoords = intersectionPoints[0]!; if (limits[0] !== null) { // The left slide limit has a chance of not reaching intsect1 const leftLimit: BDCoords = bdcoords.FromCoords([ pieceCoords[0] + step[0] * limits[0], pieceCoords[1] + step[1] * limits[0], ]); // The first index of limits is already negative, so we don't have to negate the step. if (bd.compare(leftLimit[cappingAxis], start[cappingAxis]) > 0) start = leftLimit; } let end: BDCoords = intersectionPoints[1]!; if (limits[1] !== null) { // The right slide limit has a chance of not reaching intsect2 const rightLimit: BDCoords = bdcoords.FromCoords([ pieceCoords[0] + step[0] * limits[1], pieceCoords[1] + step[1] * limits[1], ]); if (bd.compare(rightLimit[cappingAxis], end[cappingAxis]) < 0) end = rightLimit; } // Skip if zero length if (coordutil.areBDCoordsEqual(start, end)) continue; const coefficients = vectors.getLineGeneralFormFromCoordsAndVec(pieceCoords, step); lines.push({ start, end, coefficients, color, piece: pieceSelected.type }); } return lines; } /** * Start and end of a line segment. PERFECT integers! * We don't need to precalculate the line coefficients because of that. */ type Segment = { start: Coords; end: Coords; }; /** * Converts the selected piece's legal move highlight lines into * their ray and line segment components, depending on which slides are infinite or not. * * Used by drawrays.ts during collapsing, so we can add additional * Square annotations at all the intersections of rays with components. */ function getLineComponents(): { rays: Ray[]; segments: Segment[] } { const rays: Ray[] = []; const segments: Segment[] = []; const pieceSelected = selection.getPieceSelected()!; if (!pieceSelected) return { rays, segments }; const pieceCoords = pieceSelected.coords; const legalmoves = selection.getLegalMovesOfSelectedPiece()!; // CAREFUL not to modify! for (const [strline, limits] of Object.entries(legalmoves.sliding)) { const slideKey = strline as Vec2Key; const step: Vec2 = vectors.getVec2FromKey(slideKey); const negStep: Vec2 = vectors.negateVector(step); if (limits[0] !== null && limits[0] > 0n) { // Special case: Offset positive -> legal zone is entirely ahead in the positive step direction. Close end is limits[0] steps away. const closeCoords: Coords = [ pieceCoords[0] + step[0] * limits[0], pieceCoords[1] + step[1] * limits[0], ]; const limit = limits[1] === null ? null : limits[1] - limits[0]; processComponent(closeCoords, step, limit); } else if (limits[1] !== null && limits[1] < 0n) { // Special case: Offset negative -> legal zone is entirely behind the piece in the negative step direction. Close end is abs(limits[1]) steps away. const closeCoords: Coords = [ pieceCoords[0] + step[0] * limits[1], pieceCoords[1] + step[1] * limits[1], ]; const limit = limits[0] === null ? null : limits[1] - limits[0]; processComponent(closeCoords, negStep, limit); } else { // Normal: limits span 0 (or one side is null), render both directions from the piece. processComponent(coordutil.copyCoords(pieceCoords), negStep, limits[0]); // Negative slide direction processComponent(coordutil.copyCoords(pieceCoords), step, limits[1]); // Positive slide direction } } function processComponent(start: Coords, step: Vec2, limit: bigint | null): void { if (limit === null) { // Can slide infinitly => RAY const coefficients = vectors.getLineGeneralFormFromCoordsAndVec(start, step); rays.push({ start, vector: step, line: coefficients }); } else { // Can't slide infinitly => SEGMENT const end: Coords = [start[0] + step[0] * limit, start[1] + step[1] * limit]; segments.push({ start, end }); } } return { rays, segments }; } function render(): void { if (!boardpos.areZoomedOut()) return; // Quit if we're not even zoomed out. const lines = getLines(); if (lines.length === 0) return; // No lines to draw highlightline.genLinesModel(lines).render(); } export default { getLines, getLineComponents, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/snapping.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/snapping.ts /** * This script initiates teleports to all mini images and square annotes clicked. * * It also manages all renderd entities when zoomed out. */ import type { Line } from './highlightline.js'; import type { Color } from '../../../../../../shared/util/math/math.js'; import type { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../../../../../shared/util/jsutil.js'; import geometry from '../../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import boardutil from '../../../../../../shared/chess/util/boardutil.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import vectors, { Ray, Vec2 } from '../../../../../../shared/util/math/vectors.js'; import space from '../../misc/space.js'; import mouse from '../../../util/mouse.js'; import meshes from '../meshes.js'; import guipause from '../../gui/guipause.js'; import gameslot from '../../chess/gameslot.js'; import drawrays from './annotations/drawrays.js'; import boardpos from '../boardpos.js'; import miniimage from '../miniimage.js'; import { Mouse } from '../../input.js'; import movehints from './movehints.js'; import Transition from '../transitions/Transition.js'; import primitives from '../primitives.js'; import perspective from '../perspective.js'; import drawsquares from './annotations/drawsquares.js'; import annotations from './annotations/annotations.js'; import preferences from '../../../components/header/preferences.js'; import texturecache from '../../../chess/rendering/texturecache.js'; import selectedpiecehighlightline from './selectedpiecehighlightline.js'; import { Renderable, createRenderable } from '../../../webgl/Renderable.js'; // Variables -------------------------------------------------------------- /** Width of entities (mini images, highlights) when zoomed out, in virtual pixels. */ const ENTITY_WIDTH_VPIXELS = 40; // Default: 40 /** The color of the line that shows you what entity your mouse is snapped to. */ const SNAP_LINE_COLOR = [0, 0, 1, 0.3] as const; /** Properties of the glow dot when rendering the snapped coord. */ const GLOW_DOT = { RADIUS_PIXELS: 8, RESOLUTION: 16, }; /** * The opacity of the ghost image that's rendered when hovering over * the highlight line of the selected piece's legal moves. */ const GHOST_IMAGE_OPACITY = 1; /** * If more pieces than this are present in the game, snapping skips * checking if we should snap to a piece, as it's too slow. */ const THRESHOLD_TO_SNAP_PIECES = 5_000; type Snap = { /** A snap could potentially be between squares, so we need floating precision. */ coords: BDCoords; /** The color of the line we are snapped to. Already made opaque. */ color: Color; /** The type of piece to render at the snap point, if applicable */ type?: number; /** The source that eminated the line we are snapping to, if we are snapping. */ source?: BDCoords; }; // Entity Hovering --------------------------------------------------------- /** * {@link ENTITY_WIDTH_VPIXELS}, but converted to world-space units. * This can change depending on the screen dimensions. * Scale doesn't affect entity's visible size on screen. */ function getEntityWidthWorld(): number { return space.convertPixelsToWorldSpace_Virtual(ENTITY_WIDTH_VPIXELS); } function getAllEntitiesWorldHovers(world: DoubleCoords): Coords[] { const imagesHovered = miniimage.getImagesBelowWorld(world, false).images; const allSquares: Coords[] = [...annotations.getSquares(), ...movehints.getSquares()]; const highlightsHovered = drawsquares.getSquaresBelowWorld(allSquares, world, false).squares; return [...imagesHovered, ...highlightsHovered]; } type ClosestEntity = { coords: Coords; /** The euclidean distance in coordinates from the mouse to the entity. */ dist: number; type: 'miniimage' | 'square'; /** The index of the entity within its home list. */ index: number; }; /** Calculates the closest entity (piece/square) to the given world coords. */ function getClosestEntityToWorld(world: DoubleCoords): ClosestEntity | undefined { if (!isSnappingEnabledThisFrame()) return undefined; // Find the closest hovered entity to the pointer let closestEntity: ClosestEntity | undefined = undefined; const imagesHovered = miniimage.getImagesBelowWorld(world, true); const allSquares: Coords[] = [...annotations.getSquares(), ...movehints.getSquares()]; const highlightsHovered = drawsquares.getSquaresBelowWorld(allSquares, world, true); // Pieces for (let i = 0; i < imagesHovered.images.length; i++) { const coords = imagesHovered.images[i]!; const dist = imagesHovered.dists![i]!; if (closestEntity === undefined || dist <= closestEntity.dist) closestEntity = { coords, dist, type: 'miniimage', index: i }; } // Square Highlights and Individual legal move hints for (let i = 0; i < highlightsHovered.squares.length; i++) { const coords = highlightsHovered.squares[i]!; const dist = highlightsHovered.dists![i]!; if (closestEntity === undefined || dist <= closestEntity.dist) closestEntity = { coords, dist, type: 'square', index: i }; } return closestEntity; } /** * Calculates what entities are below the click location. * Teleports to them, claiming the click. */ function teleportToEntitiesIfClicked(): void { if (!isSnappingEnabledThisFrame()) return; if (!mouse.isMouseClicked(Mouse.LEFT) && !mouse.isMouseDown(Mouse.LEFT)) return; // Only teleport if clicked const mouseWorld = mouse.getMouseWorld(Mouse.LEFT); if (!mouseWorld) return; // Maybe looking into sky? const allEntitiesHovered = getAllEntitiesWorldHovers(mouseWorld); // console.log("Hovered entities:", jsutil.deepCopyObject(allEntitiesHovered)); if (allEntitiesHovered.length === 0) return; // No images to teleport to if (mouse.isMouseClicked(Mouse.LEFT)) { mouse.claimMouseClick(Mouse.LEFT); Transition.singleZoomToCoordsList(allEntitiesHovered); } else if (mouse.isMouseDown(Mouse.LEFT)) { // Allows second finger to grab the board mouse.claimMouseDown(Mouse.LEFT); // Remove the mouseDown so that other navigation controls don't use it (like board-grabbing) } } // Snapping -------------------------------------------------------------------- /** We do not snap when zoomed in. */ function isSnappingEnabledThisFrame(): boolean { if (!boardpos.areZoomedOut()) return false; if (guipause.areWePaused()) return false; if (perspective.getEnabled() && !perspective.isMouseLocked()) return false; return true; } /** Snap's the provided world coords to the nearest snappable coords. */ function getWorldSnapCoords(world: DoubleCoords): Coords | undefined { if (!isSnappingEnabledThisFrame()) return undefined; const snap = snapPointerWorld(world); if (snap === undefined) return undefined; else return space.roundCoords(snap.coords); } type LineSnapPoint = { line: Line; snapPoint: { coords: BDCoords; distance: BigDecimal }; }; /** * Reads all calculated highlights lines (selected piece legal moves, drawn Rays), * eminates lines in all directions from all entities and calculates where those * intersect any of the highlight lines, calculating where we should snap the mouse to, * and teleporting if clicked. */ function snapPointerWorld(world: DoubleCoords): Snap | undefined { const pointerCoords = space.convertWorldSpaceToCoords(world); const { boardsim } = gameslot.getGamefile()!; const drawnRays = annotations.getRays(); const presetRays = drawrays.getPresetRays(); /** All rays / selected piece legal move lines converted to SEGMENTS. */ const allLines: Line[] = getAllLinesSegmented(drawnRays, presetRays); if (allLines.length === 0) return; // No lines to have snap const snapDistVPixels = ENTITY_WIDTH_VPIXELS * 0.5; /** THe minimum distance from a snap point, in world space, for our mouse to snap to it. */ const snapDistWorld: BigDecimal = bd.fromNumber( space.convertPixelsToWorldSpace_Virtual(snapDistVPixels), ); /** The mimimum distance from a snap point, in squares, for our mouse to snap to it. */ const snapDistSquares: BigDecimal = bd.divideFloating(snapDistWorld, boardpos.getBoardScale()); // First see if the pointer is even CLOSE to any of these lines, // as otherwise we can't snap to anything anyway. const linesSnapPoints: LineSnapPoint[] = allLines.map((line) => { const snapPoint = geometry.closestPointOnLineSegment( line.coefficients, line.start, line.end, pointerCoords, ); return { line, snapPoint }; }); let closestSnap: LineSnapPoint = linesSnapPoints[0]!; for (const lineSnapPoint of linesSnapPoints) { if (bd.compare(lineSnapPoint.snapPoint.distance, closestSnap.snapPoint.distance) < 0) closestSnap = lineSnapPoint; } if (bd.compare(closestSnap.snapPoint.distance, snapDistSquares) > 0) { // console.log("pointer no close snap"); return; // No line close enough for the pointer to snap to anything } // At this point we know we WILL be snapping to something. // Filter out lines which the mouse is too far away from const closeLines = linesSnapPoints.filter( (lsp) => bd.compare(lsp.snapPoint.distance, snapDistSquares) <= 0, ); /** * Next, calculate all intersection points of all highlight lines (drawn rays, preset rays, and legal moves), * and see if the mouse is close enough to snap to them. * * If so, those take priority. */ type Intersection = { coords: BDCoords; line1: Line; line2: Line; }; const line_intersections: Intersection[] = []; for (let a = 0; a < closeLines.length - 1; a++) { const line1 = closeLines[a]!; for (let b = a + 1; b < closeLines.length; b++) { const line2 = closeLines[b]!; // Calculate where they intersect // prettier-ignore const intsect = geometry.intersectLineSegments(line1.line.coefficients, line1.line.start, line1.line.end, line2.line.coefficients, line2.line.start, line2.line.end); if (intsect === undefined) continue; // Don't intersect // Push it to the intersections, preventing duplicates if (!line_intersections.some((i) => coordutil.areBDCoordsEqual(i.coords, intsect))) line_intersections.push({ coords: intsect, line1: line1.line, line2: line2.line, }); } } // Calculate closest one to the pointer let closestIntsect: { intersection: Intersection; dist: BigDecimal } | undefined; for (const i of line_intersections) { // Calculate distance to mouse const dist = vectors.euclideanDistanceBD(i.coords, pointerCoords); if (closestIntsect === undefined || bd.compare(dist, closestIntsect.dist) < 0) closestIntsect = { intersection: i, dist }; } if (closestIntsect !== undefined && bd.compare(closestIntsect.dist, snapDistSquares) <= 0) { // SNAP to this line intersection, and exit! It takes priority // If one of the lines `piece` is defined, set the snap's type to that piece. const type = closestIntsect.intersection.line1.piece ?? closestIntsect.intersection.line2.piece; // Blend the colors of the two lines const color1 = closestIntsect.intersection.line1.color; const color2 = closestIntsect.intersection.line2.color; const color: Color = [ (color1[0] + color2[0]) / 2, (color1[1] + color2[1]) / 2, (color1[2] + color2[2]) / 2, (color1[3] + color2[3]) / 2, ]; return { coords: closestIntsect.intersection.coords, color, type }; } /** * At this point, there is no intersections of lines to snap to. * * Next, eminate lines in all directions from each entity, seeing where they cross * existing lines, calculating what we should snap to. */ // Allows snapping to all hippogonals, even the ones in 4D variants. // const allPrimitiveSlidesInGame = boardsim.pieces.slides.filter((vector: Vec2) => math.GCD(vector[0], vector[1]) === 1); // Filters out colinears, and thus potential repeats. // Minimal snapping vectors // prettier-ignore const searchVectors = boardsim.pieces.hippogonalsPresent ? [ ...vectors.VECTORS_ORTHOGONAL, ...vectors.VECTORS_DIAGONAL, ...vectors.VECTORS_HIPPOGONAL ] : [ ...vectors.VECTORS_ORTHOGONAL, ...vectors.VECTORS_DIAGONAL ]; // 1. Square Annotes & Intersections of Rays & Ray starts (same priority) ================== const annoteSnapPoints = getAnnoteSnapPoints(false); const closestAnnoteSnap = findClosestEntityOfGroup( annoteSnapPoints, closeLines, pointerCoords, searchVectors, ); if (closestAnnoteSnap !== undefined) { // Is the snap within snapping distance of the mouse? if (bd.compare(closestAnnoteSnap.dist, snapDistSquares) < 0) return closestAnnoteSnap.snap; } // 2. Pieces ======================================== // Only snap to these if there isn't too many pieces (slow) if (boardutil.getPieceCountOfGame(boardsim.pieces) < THRESHOLD_TO_SNAP_PIECES) { const pieces: BDCoords[] = boardutil .getCoordsOfAllPieces(boardsim.pieces) .map((c) => bdcoords.FromCoords(c)); // Convert to BDCoords const closestPieceSnap = findClosestEntityOfGroup( pieces, closeLines, pointerCoords, searchVectors, ); if (closestPieceSnap !== undefined) { // Is the snap within snapping distance of the mouse? if (bd.compare(closestPieceSnap.dist, snapDistSquares) < 0) return closestPieceSnap.snap; } } // 3. Origin (Center of Play) ============================== // DISABLED for now. I don't really like it // const startingBox = gamefileutility.getStartingAreaBox(boardsim); // const startingBoxBD = bounds.castBoundingBoxToBigDecimal(startingBox); // const origin: BDCoords = bounds.calcCenterOfBoundingBox(startingBoxBD); // const closestOriginSnap = findClosestEntityOfGroup([origin], closeLines, pointerCoords, searchVectors); // if (closestOriginSnap !== undefined) { // // Is the snap within snapping distance of the mouse? // if (bd.compare(closestOriginSnap.dist, snapDistSquares) < 0) return closestOriginSnap.snap; // } // No snap found! =========================================== // Instead, set the snap to the closest point on the line. return { coords: closestSnap.snapPoint.coords, color: closestSnap.line.color, type: closestSnap.line.piece, }; } function teleportToSnapIfClicked(): void { if (!isSnappingEnabledThisFrame()) return; if (mouse.isMouseClicked(Mouse.LEFT) || mouse.isMouseDown(Mouse.LEFT)) { const world = mouse.getMouseWorld(Mouse.LEFT); if (!world) return; // Maybe looking into sky? const snap = snapPointerWorld(world); if (snap === undefined) return; // No snap to teleport to if (mouse.isMouseClicked(Mouse.LEFT)) { mouse.claimMouseClick(Mouse.LEFT); Transition.singleZoomToBDCoords(snap.coords); } else if (mouse.isMouseDown(Mouse.LEFT)) { mouse.claimMouseDown(Mouse.LEFT); // Remove the mouseDown so that other navigation controls don't use it (like board-grabbing) } } } /** * Finds the entity which snapping point to a line near the mouse is closest to the mouse. * Eminates lines from each entity in all directions, and checks if they intersect any of the lines close to the mouse. */ function findClosestEntityOfGroup( entities: BDCoords[], closeLines: LineSnapPoint[], mouseCoords: BDCoords, searchVectors: Vec2[], ): { snap: Snap; dist: BigDecimal } | undefined { let closestEntitySnap: { snap: Snap; dist: BigDecimal } | undefined; for (const entityCoords of entities) { // Eminate lines in all directions from the entity coords const eminatingLines = searchVectors.map((l) => vectors.getLineGeneralFormFromCoordsAndVecBD(entityCoords, l), ); // Calculate their intersections with each individual line close to the mouse for (const eminatedLine of eminatingLines) { for (const highlightLine of closeLines) { // Do they intersect? const intersection = geometry.intersectLineAndSegment( eminatedLine, highlightLine.line.coefficients, highlightLine.line.start, highlightLine.line.end, ); if (intersection === undefined) continue; // They DO intersect. // 25% fps boost: The (faster to calculate) chebyshev distance can never be larger than the euclidean distance. // So, we know we only have to calculate the euclidean distance if the chebyshev distance is closer than the previous closest snap. const chebyDist = vectors.chebyshevDistanceBD(intersection, mouseCoords); if ( closestEntitySnap !== undefined && bd.compare(chebyDist, closestEntitySnap.dist) >= 0 ) continue; // Chebyshev distance isn't even within the threshold, the euclidean distance won't be either. const euclidDist = vectors.euclideanDistanceBD(intersection, mouseCoords); // Is the intersection point closer to the mouse than the previous closest snap? // const intersectionWorld = space.convertCoordToWorldSpace(intersection); if ( closestEntitySnap === undefined || bd.compare(euclidDist, closestEntitySnap.dist) < 0 ) { const snap = { coords: intersection, color: highlightLine.line.color, type: highlightLine.line.piece, source: jsutil.deepCopyObject(entityCoords), }; closestEntitySnap = { snap, dist: euclidDist }; } } } } return closestEntitySnap; } /** All rays / selected piece legal move lines converted to SEGMENTS. */ function getAllLinesSegmented(drawnRays: Ray[], presetRays: Ray[]): Line[] { // Drawn rays const rayColor = preferences.getAnnoteSquareColor(); rayColor[3] = 1; // Highlightlines are fully opaque const rayLines = drawrays.getLines(drawnRays, rayColor); // Preset rays const presetRayColor: Color = [...drawrays.PRESET_RAY_COLOR]; presetRayColor[3] = 1; // Highlightlines are fully opaque const presetRayLines = drawrays.getLines(presetRays, presetRayColor); // Selected piece legal move line const selectedPieceLegalMovesLines = selectedpiecehighlightline.getLines(); return [...rayLines, ...presetRayLines, ...selectedPieceLegalMovesLines]; } /** * Returns a list of coords of all the highest priority snap points. * That is all Square annotations, Ray starts, and intersections of rays * (which may include legal move ray intersections). * @param trimDecimals - Whether to ignore points that don't end up at an integer square. */ function getAnnoteSnapPoints(trimDecimals: boolean): BDCoords[] { return [ ...annotations.getSquares().map((s) => bdcoords.FromCoords(s)), // Cast square annotations to BDCoords ...drawrays.collapseRays(annotations.getRays(), trimDecimals), ]; } // Rendering -------------------------------------------------------------- /** * Snapping is in charge of rendering either a glow dot on the snap point, * or a mini image of a piece on the legal move line. */ function render(): void { if (!isSnappingEnabledThisFrame()) return; const relevantListener = mouse.getRelevantListener(); const allPhysicalPointerIds = relevantListener.getAllPhysicalPointers(); const allSnaps: Snap[] = []; for (const physicalPointerId of allPhysicalPointerIds) { if ( drawrays.areDrawing() && relevantListener.doesPointerBelongToPhysicalPointer( drawrays.getPointerId(), physicalPointerId, ) ) continue; // Don't snap the physical pointer that is currently drawing a ray const pointerWorld = mouse.getPhysicalPointerWorld(physicalPointerId); if (!pointerWorld) continue; // This pointer may be in the sky? if (getAllEntitiesWorldHovers(pointerWorld).length > 0) continue; // Don't snap if this pointer is hovering over an entity const snap = snapPointerWorld(pointerWorld); if (snap !== undefined) allSnaps.push(snap); } if (allSnaps.length === 0) return; // No snaps to render for (const snap of allSnaps) { // Render a single line between the snap point and its source if (snap.source !== undefined) { const [r, g, b, a] = SNAP_LINE_COLOR; const start = space.convertCoordToWorldSpace(snap.source); const end = space.convertCoordToWorldSpace(snap.coords); // prettier-ignore const data = [ // Vertex Color start[0], start[1], r, g, b, a, end[0], end[1], r, g, b, a ]; createRenderable(data, 2, 'LINES', 'color', true).render(); } // Next we render either the glow dot or the mini image of the piece. const coordsWorld = space.convertCoordToWorldSpace_IgnoreSquareCenter(snap.coords); if (snap.type === undefined) { // Render glow dot const color = snap.color; const colorTransparent = jsutil.deepCopyObject(color); colorTransparent[3] = 0; const radius = space.convertPixelsToWorldSpace_Virtual(GLOW_DOT.RADIUS_PIXELS); // prettier-ignore const data: number[] = primitives.GlowDot(...coordsWorld, radius, GLOW_DOT.RESOLUTION, color, colorTransparent); createRenderable(data, 2, 'TRIANGLE_FAN', 'color', true).render(); } else { // Render mini image of piece const model = generateGhostImageModel(snap.type, coordsWorld); model.render(); } } } function generateGhostImageModel(type: number, coords: DoubleCoords): Renderable { const dataGhost: number[] = []; const { texleft, texbottom, texright, textop } = meshes.getPieceTexCoords(); const entityWorldWidth = getEntityWidthWorld(); const halfWidth = entityWorldWidth / 2; const startX = coords[0] - halfWidth; const startY = coords[1] - halfWidth; const endX = startX + entityWorldWidth; const endY = startY + entityWorldWidth; // prettier-ignore const data = primitives.Quad_ColorTexture(startX, startY, endX, endY, texleft, texbottom, texright, textop, 1, 1, 1, GHOST_IMAGE_OPACITY); dataGhost.push(...data); return createRenderable( dataGhost, 2, 'TRIANGLES', 'colorTexture', true, texturecache.getTexture(type), ); } // Exports -------------------------------------------------------------- export default { getEntityWidthWorld, getClosestEntityToWorld, teleportToEntitiesIfClicked, getAnnoteSnapPoints, getWorldSnapCoords, teleportToSnapIfClicked, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/specialrighthighlights.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/specialrighthighlights.ts /** * This is a DEBUGGING script for rendering special right and enpassant highlights. * * Enable by pressing `7`. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Coords } from '../../../../../../shared/chess/util/coordutil.js'; import coordutil from '../../../../../../shared/chess/util/coordutil.js'; import toast from '../../gui/toast.js'; import meshes from '../meshes.js'; import gameslot from '../../chess/gameslot.js'; import boardpos from '../boardpos.js'; import piecemodels from '../piecemodels.js'; import { GameBus } from '../../GameBus.js'; import frametracker from '../frametracker.js'; import legalmovemodel from './legalmovemodel.js'; import legalmoveshapes from '../instancedshapes.js'; import squarerendering from './squarerendering.js'; import { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js'; // Variables ------------------------------------------------------------------------------------- /** The color of the special rights indicator. */ const SPECIAL_RIGHTS_COLOR: Color = [0, 1, 0.5, 0.3]; /* The color of the enpassant indicator. */ const ENPASSANT_COLOR: Color = [0.5, 0, 1, 0.3]; /** Whether to render special right and enpassant highlights */ let enabled = false; let model: RenderableInstanced | undefined; // Events ---------------------------------------------------------------------------------------- GameBus.addEventListener('game-loaded', () => { regenModel(); }); GameBus.addEventListener('game-unloaded', () => { // Erase the model so it doesn't carry over to next loaded game model = undefined; }); GameBus.addEventListener('physical-move', () => { regenModel(); }); // Functions ------------------------------------------------------------------------------------- function enable(): void { enabled = true; regenModel(); frametracker.onVisualChange(); } function disable(): void { enabled = false; frametracker.onVisualChange(); } function toggle(): void { enabled = !enabled; toast.show(`Toggled special rights highlights: ${enabled}`, { durationMultiplier: 0.5 }); regenModel(); frametracker.onVisualChange(); } function render(): void { if (!enabled) return; // Not enabled renderSpecialRights(); renderEnPassant(); } function regenModel(): void { if (!enabled) return; // Not enabled // console.log('Regenerating specialrights model'); const gamefile = gameslot.getGamefile()!; const model_Offset: Coords = legalmovemodel.getOffset(); // Instance data const squaresToHighlight: bigint[] = []; for (const key of gamefile.boardsim.state.global.specialRights) { const coords = coordutil.getCoordsFromKey(key); const offsetCoord = coordutil.subtractCoords(coords, model_Offset); squaresToHighlight.push(...offsetCoord); } // const vertexData: number[] = legalmoveshapes.getDataLegalMoveCornerTris(SPECIAL_RIGHTS_COLOR); // const vertexData: number[] = legalmoveshapes.getDataLegalMoveSquare(SPECIAL_RIGHTS_COLOR); const vertexData: number[] = legalmoveshapes.getDataPlusSign(SPECIAL_RIGHTS_COLOR); model = createRenderable_Instanced( vertexData, piecemodels.castBigIntArrayToFloat32(squaresToHighlight), 'TRIANGLES', 'colorInstanced', true, ); } function renderSpecialRights(): void { if (!model) throw Error('Specialrights model not initialized'); const { position, scale } = meshes.getBoardRenderTransform(legalmovemodel.getOffset()); model.render(position, scale); } function renderEnPassant(): void { const gamefile = gameslot.getGamefile()!; if (!gamefile.boardsim.state.global.enpassant) return; // No enpassant gamefile property const u_size = boardpos.getBoardScaleAsNumber(); squarerendering .genModel([gamefile.boardsim.state.global.enpassant.square], ENPASSANT_COLOR) .render(undefined, undefined, { u_size }); } // Exports ----------------------------------------------------------------------- export default { enable, disable, toggle, regenModel, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/highlights/squarerendering.ts ================================================ // src/client/scripts/esm/game/rendering/highlights/squarerendering.ts /** * This script knows how to generate buffer * models for rendering square highlights, such as: * * * Last move highlight * * Square annotations * * Premove highlights */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { Coords } from '../../../../../../shared/chess/util/coordutil.js'; import bdcoords from '../../../../../../shared/chess/util/bdcoords.js'; import space from '../../misc/space.js'; import instancedshapes from '../instancedshapes.js'; import { RenderableInstanced, createRenderable_Instanced } from '../../../webgl/Renderable.js'; /** * Generates a renderable buffer model for square highlights from given coordinates. * Doesn't require any position or scale tranformations before rendering, you can just call * `.render(undefined, undefined, { u_size: boardpos.getBoardScaleAsNumber() });` on the returned model. * * This type of model requires regeneration every single frame, so don't use it * if you have an arbitrary number of squares to render. */ function genModel(highlights: Coords[], color: Color): RenderableInstanced { const vertexData: number[] = instancedshapes.getDataLegalMoveSquare(color); const instanceData: number[] = []; highlights.forEach((coords) => { // const worldLoc = space.convertCoordToWorldSpace_IgnoreSquareCenter(bd.FromCoords(coords)); const worldLoc = space.convertCoordToWorldSpace(bdcoords.FromCoords(coords)); instanceData.push(...worldLoc); }); return createRenderable_Instanced(vertexData, instanceData, 'TRIANGLES', 'highlights', true); } export default { genModel, }; ================================================ FILE: src/client/scripts/esm/game/rendering/instancedshapes.ts ================================================ // src/client/scripts/esm/game/rendering/instancedshapes.ts /** * This script calculates the vertex data of a single instance * of several different kinds of shapes. * * Many are used for rendering legal moves, like the square, dot, or corner triangles. * The plus sign is used for special rights highlighting. * * The vertex data returned from any shape in this script * ALWAYS has a stride length of 6 (x,y, r,g,b,a) */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import board from './boardtiles.js'; import meshes from './meshes.js'; import primitives from './primitives.js'; import preferences from '../../components/header/preferences.js'; // Variables ------------------------------------------------------------------------------ /** * Properties for the dots that are rendered on legal squares without an occupying piece. */ const DOTS = { /** The radius of the dots, where 1 equals the width of one square. */ RADIUS: 0.16, /** How many points the edge of the dots have. */ RESOLUTION: 32, /** * This will be added to the theme's legal move color's opacity, * as dots are a little less noticeable than big squares, * so increasing their opacity helps. */ OPACITY_OFFSET: 0.2, }; /** * Properties for the corner triangles that are rendered on legal squares with an occupied piece, * they typically signify legal captures. */ const CORNER_TRIS = { /** The radius of the corner triangles, where 1 equals the width of one square. */ TRI_WIDTH: 0.5, /** * This will be added to the theme's legal move color's opacity, * as the triangles are a little less noticeable than big squares, * so increasing their opacity helps. */ OPACITY_OFFSET: 0.2, }; /** Properties for the box outline that is rendered over the hovered square during dragging. */ const BOX_OUTLINE = { /** The width of the outline border, where 1 equals the width of one square. */ EDGE_WIDTH: 0.07, }; /** * Properties for the plus sign that is rendered when the special rights highlighing * debug mode is enabled, next to each piece that has its special rights. */ const PLUS_SIGN = { /** Default position of the plus sign center within a square ([0,0] is square center, [0.5,0.5] is top-right corner) */ POSITION: [0.3, 0.3] as DoubleCoords, // Default: [0.3, 0.3] /** Length of both arms (horizontal and vertical) where 1.0 spans full square */ ARM_LENGTH: 0.4, // Default: 0.4 /** Width of the plus sign arms */ EDGE_WIDTH: 0.12, // Default: 0.12 /** Added to color alpha for better visibility */ OPACITY_OFFSET: 0.2, // Default: 0.2 }; // Functions ------------------------------------------------------------------------------ /** * Generates the legal move square instance mesh, centered on [0,0] * @param color - The color [r, g, b, a]. This should MATCH the current theme's legal move color! * @returns The vertex data for the legal move square. */ function getDataLegalMoveSquare(color: Color): number[] { const coords: DoubleCoords = [0, 0]; // The instance is going to be at [0,0] // Generate and return the vertex data for the legal move square. return meshes.QuadModel_Color(coords, color); } /** * Generates the legal move dot instance mesh, centered on [0,0] * @param color - The color [r, g, b, a]. This should MATCH the current theme's legal move color! An offset will be applied to its opacity. * @returns The vertex data for the "legal move dot" (circle). */ function getDataLegalMoveDot(color: Color): number[] { const colorCopy: Color = [...color]; // Don't mutate the original colorCopy[3] += DOTS.OPACITY_OFFSET; // Add the offset colorCopy[3] = Math.min(colorCopy[3], 1); // Cap it const coords: DoubleCoords = [0, 0]; // The instance is going to be at [0,0] // The calculated dot's x & y have to be the VISUAL-CENTER of the square, not exactly at [0,0] const x = coords[0] + (1 - board.getSquareCenterAsNumber()) - 0.5; const y = coords[1] + (1 - board.getSquareCenterAsNumber()) - 0.5; // Generate and return the vertex data for the legal move dot (circle) return primitives.Circle(x, y, DOTS.RADIUS, DOTS.RESOLUTION, colorCopy); } /** * Generates vertex data for four corner triangles used for legal move indicators, * with opacity adjustment and proper visual centering. * @param color - Color [r, g, b, a] from theme (opacity offset will be applied) * @returns Vertex data for four corner triangles */ function getDataLegalMoveCornerTris(color: [number, number, number, number]): number[] { // Adjust opacity // eslint-disable-next-line prefer-const let [r, g, b, a] = color; a = Math.min(a + CORNER_TRIS.OPACITY_OFFSET, 1); // Calculate visual center position (original [0,0] instance adjusted for board centering) const boardCenterAdjust = 1 - board.getSquareCenterAsNumber() - 0.5; const centerX = boardCenterAdjust; const centerY = boardCenterAdjust; const vertices: number[] = []; const squareHalfSize = 0.5; const triHalfWidth = CORNER_TRIS.TRI_WIDTH / 2; // Helper to add a single corner triangle const addTriangle = (cornerX: number, cornerY: number, dx: number, dy: number): void => { // prettier-ignore vertices.push( cornerX, cornerY, r, g, b, a, cornerX + dx, cornerY, r, g, b, a, cornerX, cornerY + dy, r, g, b, a ); }; // Generate all four corners addTriangle(centerX - squareHalfSize, centerY + squareHalfSize, triHalfWidth, -triHalfWidth); // Top-left addTriangle(centerX + squareHalfSize, centerY + squareHalfSize, -triHalfWidth, -triHalfWidth); // Top-right addTriangle(centerX - squareHalfSize, centerY - squareHalfSize, triHalfWidth, triHalfWidth); // Bottom-left addTriangle(centerX + squareHalfSize, centerY - squareHalfSize, -triHalfWidth, triHalfWidth); // Bottom-right return vertices; } /** * Generates vertex data for a plus sign using 5 non-overlapping rectangles */ function getDataPlusSign(color: Color): number[] { // eslint-disable-next-line prefer-const let [r, g, b, a] = color; a = Math.min(a + PLUS_SIGN.OPACITY_OFFSET, 1); const halfEdge = PLUS_SIGN.EDGE_WIDTH / 2; const armLength = PLUS_SIGN.ARM_LENGTH; const [posX, posY] = PLUS_SIGN.POSITION; const vertices: number[] = []; // Helper to add quad vertices (2 triangles) // prettier-ignore const addQuad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void => { // Triangle 1 vertices.push(x1, y1, r, g, b, a); vertices.push(x2, y2, r, g, b, a); vertices.push(x3, y3, r, g, b, a); // Triangle 2 vertices.push(x3, y3, r, g, b, a); vertices.push(x4, y4, r, g, b, a); vertices.push(x1, y1, r, g, b, a); }; // Vertical arm (top segment) addQuad( posX - halfEdge, posY + armLength / 2, // top-left posX + halfEdge, posY + armLength / 2, // top-right posX + halfEdge, posY + halfEdge, // bottom-right posX - halfEdge, posY + halfEdge, // bottom-left ); // Vertical arm (bottom segment) addQuad( posX - halfEdge, posY - halfEdge, // top-left posX + halfEdge, posY - halfEdge, // top-right posX + halfEdge, posY - armLength / 2, // bottom-right posX - halfEdge, posY - armLength / 2, // bottom-left ); // Horizontal arm (left segment) addQuad( posX - armLength / 2, posY + halfEdge, // top-left posX - halfEdge, posY + halfEdge, // top-right posX - halfEdge, posY - halfEdge, // bottom-right posX - armLength / 2, posY - halfEdge, // bottom-left ); // Horizontal arm (right segment) addQuad( posX + halfEdge, posY + halfEdge, // top-left posX + armLength / 2, posY + halfEdge, // top-right posX + armLength / 2, posY - halfEdge, // bottom-right posX + halfEdge, posY - halfEdge, // bottom-left ); // Center square addQuad( posX - halfEdge, posY + halfEdge, // top-left posX + halfEdge, posY + halfEdge, // top-right posX + halfEdge, posY - halfEdge, // bottom-right posX - halfEdge, posY - halfEdge, // bottom-left ); return vertices; } /** * Generates the vertex data for a box outline (frame) indicating a hovered square, centered on [0,0]. * The outline wraps exactly around the full square tile. The color is taken from the current theme. * @returns The vertex data for the box outline. */ function getDataBoxOutline(): number[] { const [r, g, b, a] = preferences.getBoxOutlineColor(); const squareCenter = board.getSquareCenterAsNumber(); const centerX = 0.5 - squareCenter; const centerY = 0.5 - squareCenter; const halfBox = 0.5; const outerLeft = centerX - halfBox; const outerRight = centerX + halfBox; const outerTop = centerY + halfBox; const outerBottom = centerY - halfBox; const edgeWidth = BOX_OUTLINE.EDGE_WIDTH; const innerLeft = outerLeft + edgeWidth; const innerRight = outerRight - edgeWidth; const innerTop = outerTop - edgeWidth; const innerBottom = outerBottom + edgeWidth; const vertices: number[] = []; // Helper to add a rectangle (two triangles) // prettier-ignore function addRectangle(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): void { vertices.push( x1, y1, r, g, b, a, // Triangle 1, Vertex 1 x2, y2, r, g, b, a, // Triangle 1, Vertex 2 x3, y3, r, g, b, a, // Triangle 1, Vertex 3 x3, y3, r, g, b, a, // Triangle 2, Vertex 1 x4, y4, r, g, b, a, // Triangle 2, Vertex 2 x1, y1, r, g, b, a // Triangle 2, Vertex 3 ); } // Top edge addRectangle( outerLeft, outerTop, // Outer top-left outerRight, outerTop, // Outer top-right innerRight, innerTop, // Inner top-right innerLeft, innerTop, // Inner top-left ); // Bottom edge addRectangle( outerLeft, outerBottom, // Outer bottom-left innerLeft, innerBottom, // Inner bottom-left innerRight, innerBottom, // Inner bottom-right outerRight, outerBottom, // Outer bottom-right ); // Left edge addRectangle( outerLeft, outerTop, // Outer top-left innerLeft, innerTop, // Inner top-left innerLeft, innerBottom, // Inner bottom-left outerLeft, outerBottom, // Outer bottom-left ); // Right edge addRectangle( outerRight, outerTop, // Outer top-right outerRight, outerBottom, // Outer bottom-right innerRight, innerBottom, // Inner bottom-right innerRight, innerTop, // Inner top-right ); return vertices; } /** * Generates the vertex data for a single square draw with a texture, centered on [0,0] * @param inverted - Whether to invert the position data. Should be true if we're viewing black's perspective. */ function getDataTexture(inverted: boolean): number[] { let { left, right, bottom, top } = meshes.getCoordBoxModel([0, 0]); if (inverted) { [left, right] = [right, left]; // Swap left and right [bottom, top] = [top, bottom]; // Swap bottom and top } return primitives.Quad_Texture(left, bottom, right, top, 0, 0, 1, 1); } /** * Generates the vertex data for a single square draw with a colored texture, centered on [0,0] * @param inverted - Whether to invert the position data. Should be true if we're viewing black's perspective. */ function getDataColoredTexture(color: Color, inverted: boolean): number[] { let left = -0.5; let right = 0.5; let bottom = -0.5; let top = 0.5; if (inverted) { [left, right] = [right, left]; // Swap left and right [bottom, top] = [top, bottom]; // Swap bottom and top } return primitives.Quad_ColorTexture(left, bottom, right, top, 0, 0, 1, 1, ...color); } export default { getDataLegalMoveSquare, getDataLegalMoveDot, getDataLegalMoveCornerTris, getDataPlusSign, getDataBoxOutline, getDataTexture, getDataColoredTexture, }; ================================================ FILE: src/client/scripts/esm/game/rendering/meshes.ts ================================================ // src/client/scripts/esm/game/rendering/meshes.ts /** * This script can generate mesh vertex data for common shapes, * given game info such as coordinates, color, and textures. * * [Model Space] - REQUIRES position and scale transformations when rendering. * [World Space] - DOES NOT require positional or scale transformations when rendering. */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { BoundingBox, BoundingBoxBD, DoubleBoundingBox, } from '../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bounds from '../../../../../shared/util/math/bounds.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import { Vec3 } from '../../../../../shared/util/math/vectors.js'; import coordutil, { BDCoords, Coords, DoubleCoords, } from '../../../../../shared/chess/util/coordutil.js'; import boardpos from './boardpos.js'; import boardtiles from './boardtiles.js'; import primitives from './primitives.js'; import perspective from './perspective.js'; // Constants ------------------------------------------------------------------------- const ONE = bd.fromBigInt(1n); // Square Bounds --------------------------------------------------------------------------- /** * [Model Space] Returns a bounding box of a square. * @param coords - Must be within double bounds because it should only be for model vertice data. */ function getCoordBoxModel(coords: DoubleCoords): DoubleBoundingBox { const squareCenter = boardtiles.getSquareCenterAsNumber(); const left = coords[0] - squareCenter; const bottom = coords[1] - squareCenter; const right = left + 1; const top = bottom + 1; return { left, right, bottom, top }; } /** * [World Space] Returns a bounding box of a square. */ function getCoordBoxWorld(coords: Coords): DoubleBoundingBox { const boardPos = boardpos.getBoardPos(); const boardScale = boardpos.getBoardScaleAsNumber(); const squareCenterScaled = boardtiles.getSquareCenterAsNumber() * boardScale; const coordsBD = bdcoords.FromCoords(coords); const relativeCoords: DoubleCoords = bdcoords.coordsToDoubles( coordutil.subtractBDCoords(coordsBD, boardPos), ); const scaledCoords: DoubleCoords = [ relativeCoords[0] * boardScale, relativeCoords[1] * boardScale, ]; const left = scaledCoords[0] - squareCenterScaled; const right = left + boardScale; const bottom = scaledCoords[1] - squareCenterScaled; const top = bottom + boardScale; return { left, right, bottom, top }; } /** * [Model Space] If you have say a bounding box from coordinate [1,1] to [9,9], * this will round that outwards from [0.5,0.5] to [9.5,9.5]. * * Expands the edges of the box, which should contain integer squares for values, * to encapsulate the whole of the squares on their edges. * Turns it into a floating point edge. */ function expandTileBoundingBoxToEncompassWholeSquare(boundingBox: BoundingBox): BoundingBoxBD { const boxBD = bounds.castBoundingBoxToBigDecimal(boundingBox); return expandTileBoundingBoxToEncompassWholeSquareBD(boxBD); } /** * {@link expandTileBoundingBoxToEncompassWholeSquare}, but use this if you already have a BigDecimal bounding box. */ function expandTileBoundingBoxToEncompassWholeSquareBD(boundingBox: BoundingBoxBD): BoundingBoxBD { const squareCenter = boardtiles.getSquareCenter(); const inverseSquareCenter = bd.subtract(ONE, squareCenter); const left = bd.subtract(boundingBox.left, squareCenter); const right = bd.add(boundingBox.right, inverseSquareCenter); const bottom = bd.subtract(boundingBox.bottom, squareCenter); const top = bd.add(boundingBox.top, inverseSquareCenter); return { left, bottom, right, top }; } /** * [World Space] Applies our board position and scale transformations to a floating bounding box * so it can be rendered exactly where it is without requiring uniform translations. * * Since its floating, we don't bother to subtract squareCenter. */ function applyWorldTransformationsToBoundingBox(boundingBox: BoundingBoxBD): DoubleBoundingBox { const boardPos = boardpos.getBoardPos(); const boardScale = boardpos.getBoardScaleAsNumber(); const left: number = bd.toNumber(bd.subtract(boundingBox.left, boardPos[0])) * boardScale; const right: number = bd.toNumber(bd.subtract(boundingBox.right, boardPos[0])) * boardScale; const bottom: number = bd.toNumber(bd.subtract(boundingBox.bottom, boardPos[1])) * boardScale; const top: number = bd.toNumber(bd.subtract(boundingBox.top, boardPos[1])) * boardScale; return { left, bottom, right, top }; } // Mesh Data --------------------------------------------------------------------------------- /** * [Model Space] Generates the vertex data of a square highlight, given the coords and color. */ function QuadModel_Color(coords: DoubleCoords, color: Color): number[] { const { left, bottom, right, top } = getCoordBoxModel(coords); return primitives.Quad_Color(left, bottom, right, top, color); } /** * [World Space] Generates the vertex data of a square highlight, given the coords and color. */ function QuadWorld_Color(coords: Coords, color: Color): number[] { const { left, bottom, right, top } = getCoordBoxWorld(coords); return primitives.Quad_Color(left, bottom, right, top, color); } /** * [World Space] Generates the vertex data of a colored texture. */ function QuadWorld_ColorTexture(coords: Coords, color: Color): number[] { const { texleft, texbottom, texright, textop } = getPieceTexCoords(); const { left, right, bottom, top } = getCoordBoxWorld(coords); const [r, g, b, a] = color; // prettier-ignore return primitives.Quad_ColorTexture(left, bottom, right, top, texleft, texbottom, texright, textop, r, g, b, a); } /** * Returns the texture coordinates for a full-texture piece quad (UV range 0–1), * flipped when viewing from black's perspective. */ function getPieceTexCoords(): { texleft: number; texbottom: number; texright: number; textop: number; } { const isBlack = perspective.getIsViewingBlackPerspective(); return { texleft: isBlack ? 1 : 0, texbottom: isBlack ? 1 : 0, texright: isBlack ? 0 : 1, textop: isBlack ? 0 : 1, }; } /** * [World Space, LINE_LOOP] Generates the vertex data of a rectangle outline. */ function RectWorld(boundingBox: BoundingBox, color: Color): number[] { const boundingBoxBD = expandTileBoundingBoxToEncompassWholeSquare(boundingBox); const { left, right, bottom, top } = applyWorldTransformationsToBoundingBox(boundingBoxBD); return primitives.Rect(left, bottom, right, top, color); } // /** // * [World Space, TRIANGLES] Generates the vertex data of a filled rectangle. // */ // function RectWorld_Filled(boundingBox: BoundingBox, color: Color): number[] { // const boundingBoxBD = expandTileBoundingBoxToEncompassWholeSquare(boundingBox); // const { left, right, bottom, top } = applyWorldTransformationsToBoundingBox(boundingBoxBD); // return primitives.Quad_Color(left, bottom, right, top, color); // } // Transforming Vertices --------------------------------------------------------------- /** Applies a rotational & translational transformation to an array of points. */ // function applyTransformToPoints(points: DoubleCoords[], rotation: number, translation: DoubleCoords): DoubleCoords[] { // // convert rotation angle to radians // const cos = Math.cos(rotation); // const sin = Math.sin(rotation); // // apply rotation matrix and translation vector to each point // const transformedPoints = points.map(point => { // const xRot = point[0] * cos - point[1] * sin; // const yRot = point[0] * sin + point[1] * cos; // const xTrans = xRot + translation[0]; // const yTrans = yRot + translation[1]; // return [xTrans, yTrans] as DoubleCoords; // }); // // return transformed points as an array of length-2 arrays // return transformedPoints; // } // Other Generic Rendering Methods ------------------------------------------------------- /** Returns the position and uniform scale needed to render a board-space model. */ function getBoardRenderTransform(offset: Coords, z: number = 0): { position: Vec3; scale: Vec3 } { const boardPos = boardpos.getBoardPos(); const position = getModelPosition(boardPos, offset, z); const boardScale = boardpos.getBoardScaleAsNumber(); const scale: Vec3 = [boardScale, boardScale, 1]; return { position, scale }; } /** * Returns a model's transformed position that should be used when rendering its buffer model. * * Any model that has a bigint offset, should be able to subtract that offset * from our board position to obtain a number small emough for the gpu to render. * * Typically this will always include numbers smaller than 10,000 */ function getModelPosition(boardPos: BDCoords, modelOffset: Coords, z: number = 0): Vec3 { function getAxis(position: BigDecimal, offset: bigint): number { const offsetBD = bd.fromBigInt(offset); return bd.toNumber(bd.subtract(offsetBD, position)); } return [ // offset - boardPos getAxis(boardPos[0], modelOffset[0]), getAxis(boardPos[1], modelOffset[1]), z, ]; } // Exports ----------------------------------------------------------------------- export default { // Square Bounds getCoordBoxModel, getCoordBoxWorld, expandTileBoundingBoxToEncompassWholeSquare, expandTileBoundingBoxToEncompassWholeSquareBD, applyWorldTransformationsToBoundingBox, // Mesh Data QuadModel_Color, QuadWorld_Color, QuadWorld_ColorTexture, getPieceTexCoords, RectWorld, // RectWorld_Filled, // Other Generic Rendering Methods getBoardRenderTransform, }; ================================================ FILE: src/client/scripts/esm/game/rendering/miniimage.ts ================================================ // src/client/scripts/esm/game/rendering/miniimage.ts /** * This script handles the rendering of the mini images of our pieces when we're zoomed out */ import type { BDCoords, Coords, CoordsKey, DoubleCoords, } from '../../../../../shared/chess/util/coordutil.js'; import bd from '@naviary/bigdecimal'; import jsutil from '../../../../../shared/util/jsutil.js'; import vectors from '../../../../../shared/util/math/vectors.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import { Color } from '../../../../../shared/util/math/math.js'; import boardutil, { Piece } from '../../../../../shared/chess/util/boardutil.js'; import { players as p, TypeGroup } from '../../../../../shared/chess/util/typeutil.js'; import toast from '../gui/toast.js'; import webgl from './webgl.js'; import space from '../misc/space.js'; import mouse from '../../util/mouse.js'; import gameslot from '../chess/gameslot.js'; import boardpos from './boardpos.js'; import snapping from './highlights/snapping.js'; import premoves from '../chess/premoves.js'; import animation from './animation.js'; import selection from '../chess/selection.js'; import boardtiles from './boardtiles.js'; import perspective from './perspective.js'; import { GameBus } from '../GameBus.js'; import frametracker from './frametracker.js'; import texturecache from '../../chess/rendering/texturecache.js'; import instancedshapes from './instancedshapes.js'; import { RenderableInstanced, AttributeInfoInstanced, createRenderable_Instanced_GivenInfo, } from '../../webgl/Renderable.js'; // Variables -------------------------------------------------------------- /** * The maximum numbers of pieces in a game before we disable mini image rendering * for all pieces that aren't underneath a square annotation, ray intersection, being animated, or selected, for performance. */ const pieceCountToDisableMiniImages = 40_000; const MINI_IMAGE_OPACITY: number = 0.6; /** The maximum distance in virtual pixels an animated mini image can travel before teleporting mid-animation near the end of its destination, so it doesn't move too rapidly on-screen. */ const MAX_ANIM_DIST_VPIXELS = bd.fromBigInt(2300n); /** The attribute info for all mini image vertex & attribute data. */ const attribInfo: AttributeInfoInstanced = { vertexDataAttribInfo: [ { name: 'a_position', numComponents: 2 }, { name: 'a_texturecoord', numComponents: 2 }, { name: 'a_color', numComponents: 4 }, ], instanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }], }; /** True if we're disabled and not rendering mini images, such as when there's too many pieces. */ let disabled: boolean = false; // Disabled when there's too many pieces // Events --------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { // Re-enable them if the previous game turned them off due to too many pieces. enable(); }); // Toggling -------------------------------------------------------------- function isDisabled(): boolean { return disabled; } function enable(): void { disabled = false; } function disable(): void { disabled = true; } function toggle(): void { disabled = !disabled; frametracker.onVisualChange(); if (disabled) toast.show(translations.rendering.icon_rendering_off); else toast.show(translations.rendering.icon_rendering_on); } // Updating -------------------------------------------------------------------------- /** Iterate over every renderable piece (static and animated) and invoke the callback with its board coords and type. */ function forEachRenderablePiece(callback: (_coords: BDCoords, _type: number) => void): void { const gamefile = gameslot.getGamefile()!; const pieces = gamefile.boardsim.pieces; // Animated pieces const maxDistB4Teleport = bd.divideFloating( MAX_ANIM_DIST_VPIXELS, boardtiles.gtileWidth_Pixels(), ); /** Pieces temporarily being hidden via transparent squares on their destination square. */ const activeHides: Set = new Set(); for (const a of animation.animations) { const segmentInfo = animation.getCurrentSegment(a, maxDistB4Teleport); const currentAnimationPosition = animation.getCurrentAnimationPosition( a.segments, segmentInfo, ); callback(currentAnimationPosition, a.type); animation.forEachActiveKeyframe(a.showKeyframes, segmentInfo.segmentNum, (pieces) => pieces.forEach((p) => { const pieceBDCoords = bdcoords.FromCoords(p.coords); callback(pieceBDCoords, p.type); }), ); // Construct the hidden pieces for below animation.forEachActiveKeyframe(a.hideKeyframes, segmentInfo.segmentNum, (pieces) => pieces.map(coordutil.getKeyFromCoords).forEach((c) => activeHides.add(c)), ); } // Static pieces gamefile.boardsim.existingTypes.forEach((type: number) => { if (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type))) return; // Skip voids const range = pieces.typeRanges.get(type)!; // Skip types with no pieces if (boardutil.getPieceCountOfTypeRange(range) === 0) return; boardutil.iteratePiecesInTypeRange(pieces, type, (idx) => { const coords = boardutil.getCoordsFromIdx(pieces, idx); const coordsKey = coordutil.getKeyFromCoords(coords); if (activeHides.has(coordsKey)) return; // Skip pieces that are being hidden due to animations const coordsBD = bdcoords.FromCoords(coords); callback(coordsBD, type); }); }); } /** Generates the instance data for the miniimages of the pieces this frame. */ function getImageInstanceData(): { instanceData: TypeGroup; instanceData_hovered: TypeGroup; } { const instanceData: TypeGroup = {}; const instanceData_hovered: TypeGroup = {}; const pointerWorlds = mouse.getAllPointerWorlds(); const boardsim = gameslot.getGamefile()!.boardsim; const halfWorldWidth: number = snapping.getEntityWidthWorld() / 2; const areWatchingMousePosition: boolean = !perspective.getEnabled() || perspective.isMouseLocked(); // Prepare empty arrays by type boardsim.existingTypes.forEach((type: number) => { if (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(type))) return; // Skip voids instanceData[type] = []; instanceData_hovered[type] = []; }); if (!disabled) { // Enabled => normal behavior forEachRenderablePiece(processPiece); // Process each renderable piece } else { // Disabled (too many pieces) => Only process pieces on highlights or being animated const piecesToRender = getAllPiecesBelowAnnotePoints(); piecesToRender.forEach((p) => { const coordsBD = bdcoords.FromCoords(p.coords); processPiece(coordsBD, p.type); }); // Calculate their instance data } /** Calculates and appends the instance data of the piece */ function processPiece(coords: BDCoords, type: number): void { const coordsWorld = space.convertCoordToWorldSpace(coords); instanceData[type]!.push(...coordsWorld); // Are we hovering over? If so, add the same data to instanceData_hovered if (areWatchingMousePosition) { for (const pointerWorld of pointerWorlds) { if (vectors.chebyshevDistanceDoubles(coordsWorld, pointerWorld) < halfWorldWidth) instanceData_hovered[type]!.push(...coordsWorld); } } } return { instanceData, instanceData_hovered }; } /** Returns a list of mini image coordinates that are all being hovered over by the provided world coords. */ function getImagesBelowWorld( world: DoubleCoords, trackDists: boolean, ): { images: Coords[]; dists?: number[] } { const imagesHovered: Coords[] = []; const dists: number[] = []; const halfWorldWidth: number = snapping.getEntityWidthWorld() / 2; if (!disabled) { // Enabled => normal behavior // Check static and animated pieces for hover forEachRenderablePiece(processPiece); } else { // Disabled (too many pieces) => Only process pieces on highlights or being animated const piecesToConsider = getAllPiecesBelowAnnotePoints(); piecesToConsider.forEach((p) => { const coordsBD = bdcoords.FromCoords(p.coords); processPiece(coordsBD); }); // Calculate if their underneath the world coords } function processPiece(coords: BDCoords): void { const coordsWorld = space.convertCoordToWorldSpace(coords); if (vectors.chebyshevDistanceDoubles(coordsWorld, world) < halfWorldWidth) { const integerCoords = bdcoords.coordsToBigInt(coords); imagesHovered.push(integerCoords); // Upgrade the distance to euclidean if (trackDists) dists.push(vectors.euclideanDistanceDoubles(coordsWorld, world)); } } return trackDists ? { images: imagesHovered, dists } : { images: imagesHovered }; } /** * Returns a list of all pieces that should be rendered when mini-images are disabled. * This includes pieces below an annotation snap point, the selected piece, all animated pieces, * and the pieces involved in the last and next moves. */ function getAllPiecesBelowAnnotePoints(): Piece[] { /** Running list of all pieces to render. */ const piecesToRender: Piece[] = []; function pushPieceNoDuplicatesOrVoids(piece: Piece): void { if (typeutil.SVGLESS_TYPES.has(typeutil.getRawType(piece.type))) return; // Skip voids if (!piecesToRender.some((p) => coordutil.areCoordsEqual(p.coords, piece.coords))) { piecesToRender.push(piece); } } const gamefile = gameslot.getGamefile()!; const boardsim = gamefile.boardsim; const pieces = boardsim.pieces; const mesh = gameslot.getMesh(); // 1. Process all animations and add pieces relevant to the current move const maxDistB4Teleport = bd.divideFloating( MAX_ANIM_DIST_VPIXELS, boardtiles.gtileWidth_Pixels(), ); /** Pieces temporarily being hidden via transparent squares on their destination square. */ const activeHides: Set = new Set(); for (const a of animation.animations) { const segmentInfo = animation.getCurrentSegment(a, maxDistB4Teleport); const currentAnimationPosition = animation.getCurrentAnimationPosition( a.segments, segmentInfo, ); // Add the main animated piece pushPieceNoDuplicatesOrVoids({ coords: bdcoords.coordsToBigInt(currentAnimationPosition), type: a.type, index: -1, }); // Add the captured pieces being shown animation.forEachActiveKeyframe(a.showKeyframes, segmentInfo.segmentNum, (pieces) => pieces.forEach((p) => pushPieceNoDuplicatesOrVoids(p)), ); // Construct the hidden pieces for below animation.forEachActiveKeyframe(a.hideKeyframes, segmentInfo.segmentNum, (pieces) => pieces.map(coordutil.getKeyFromCoords).forEach((c) => activeHides.add(c)), ); } // Queued premoves must be rewound BEFORE reading the pieces, so they are in the expected locations as the last and next move! premoves.rewindPremoves(gamefile, mesh); // 2. Get pieces on top of highlights (ray starts, intersections, etc.) const annotePoints: Coords[] = snapping .getAnnoteSnapPoints(true) .map((p) => bdcoords.coordsToBigInt(p)); annotePoints.forEach((ap) => { const piece = boardutil.getPieceFromCoords(pieces, ap); if (!piece) return; // No piece beneath this highlight const coordsKey = coordutil.getKeyFromCoords(ap); if (activeHides.has(coordsKey)) return; // Skip pieces that are being hidden due to animations pushPieceNoDuplicatesOrVoids(piece); }); // 3. Add the selected piece, if any const pieceSelected = selection.getPieceSelected(); if (pieceSelected) pushPieceNoDuplicatesOrVoids(jsutil.deepCopyObject(pieceSelected)); // 4. Add pieces from the last and next moves const moveIndex = boardsim.state.local.moveIndex; // Last move's destination piece const lastMove = boardsim.moves[moveIndex]; if ( lastMove && !animation.animations.some((a) => coordutil.areCoordsEqual(lastMove.endCoords, a.path[a.path.length - 1]!), ) ) { // SKIP PIECES that are currently being animated to this location!!! Those are already rendered. const lastMovedPiece = boardutil.getPieceFromCoords(pieces, lastMove.endCoords)!; if (!lastMovedPiece) throw new Error( 'Could not find last moved piece at its destination coords: ' + lastMove.endCoords, ); pushPieceNoDuplicatesOrVoids(lastMovedPiece); } // Next move's starting piece const nextMove = boardsim.moves[moveIndex + 1]; if ( nextMove && !animation.animations.some((a) => coordutil.areCoordsEqual(nextMove.startCoords, a.path[a.path.length - 1]!), ) ) { // SKIP PIECES that are currently being animated to this location!!! Those are already rendered. const nextToMovePiece = boardutil.getPieceFromCoords(pieces, nextMove.startCoords)!; if (!nextToMovePiece) throw new Error( 'Could not find next to move piece at its starting coords: ' + nextMove.startCoords, ); pushPieceNoDuplicatesOrVoids(nextToMovePiece); } premoves.applyPremoves(gamefile, mesh); return piecesToRender; } // Rendering --------------------------------------------------------------- function render(): void { if (!boardpos.areZoomedOut()) return; const boardsim = gameslot.getGamefile()!.boardsim; const inverted = perspective.getIsViewingBlackPerspective(); const { instanceData, instanceData_hovered } = getImageInstanceData(); const models: TypeGroup = {}; const models_hovered: TypeGroup = {}; // Create the models for (const [typeStr, thisInstanceData] of Object.entries(instanceData)) { if (thisInstanceData.length === 0) continue; // No pieces of this type visible const color = [1, 1, 1, MINI_IMAGE_OPACITY] as Color; const vertexData: number[] = instancedshapes.getDataColoredTexture(color, inverted); const type = Number(typeStr); const texture: WebGLTexture = texturecache.getTexture(type); models[type] = createRenderable_Instanced_GivenInfo( vertexData, new Float32Array(thisInstanceData), attribInfo, 'TRIANGLES', 'miniImages', [{ texture, uniformName: 'u_sampler' }], ); // Create the hovered model if it's non empty if (instanceData_hovered[type]!.length > 0) { const color_hovered = [1, 1, 1, 1] as Color; // Hovered mini images are fully opaque const vertexData_hovered: number[] = instancedshapes.getDataColoredTexture( color_hovered, inverted, ); models_hovered[type] = createRenderable_Instanced_GivenInfo( vertexData_hovered, new Float32Array(instanceData_hovered[type]!), attribInfo, 'TRIANGLES', 'miniImages', [{ texture, uniformName: 'u_sampler' }], ); } } // Sort the types in descending order, so that lower player number pieces are rendered on top, and kings are rendered on top. const sortedNeutrals = boardsim.existingTypes .filter((t: number) => typeutil.getColorFromType(t) === p.NEUTRAL) .sort((a: number, b: number) => b - a); const sortedColors = boardsim.existingTypes .filter((t: number) => typeutil.getColorFromType(t) !== p.NEUTRAL) .sort((a: number, b: number) => b - a); const u_size = snapping.getEntityWidthWorld(); webgl.executeWithDepthFunc_ALWAYS(() => { for (const neut of sortedNeutrals) { models[neut]?.render(undefined, undefined, { u_size }); models_hovered[neut]?.render(undefined, undefined, { u_size }); } for (const col of sortedColors) { models[col]?.render(undefined, undefined, { u_size }); models_hovered[col]?.render(undefined, undefined, { u_size }); } }); } // Exports --------------------------------------------------------------------------------- export default { pieceCountToDisableMiniImages, isDisabled, disable, toggle, getImagesBelowWorld, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/perspective.ts ================================================ // src/client/scripts/esm/game/rendering/perspective.ts /** * This script handles our perspective mode! * Also rendering our crosshair */ import type { Color } from '../../../../../shared/util/math/math.js'; import mat4 from './gl-matrix.js'; import toast from '../gui/toast.js'; import webgl from './webgl.js'; import config from '../config.js'; import docutil from '../../util/docutil.js'; import guipause from '../gui/guipause.js'; import gameslot from '../chess/gameslot.js'; import selection from '../chess/selection.js'; import { Mouse } from '../input.js'; import preferences from '../../components/header/preferences.js'; import frametracker from './frametracker.js'; import camera, { Mat4 } from './camera.js'; import { Renderable, createRenderable } from '../../webgl/Renderable.js'; import { listener_document, listener_overlay } from '../chess/game.js'; /** Whether perspective mode is enabled. */ let enabled = false; let rotX = 0; // Positive x looks down. Min 0 let rotZ = 0; // Positive z looks right // rotY = 0, // Y is tilt, we will not be using this let isViewingBlackPerspective = false; const mouseSensitivityMultiplier = 0.13; // 0.13 Default This is Multiplied by our perspective_sensitivity in the preferences. // How far to render the board into the distance const distToRenderBoard = 1500; // Default 1500. When changing this, also change camera.getZFar() // Crosshair const crosshairThickness = 2.5; // Default: 2.5 const crosshairColor: Color = [1, 1, 1, 1]; // RGBA. It will invert the colors in the buffer. This is what color BLACKS will be dyed! Whites will appear black. /** The buffer model of the mouse crosshair when in perspective mode. */ let crosshairModel: Renderable; // Getters function getEnabled(): boolean { return enabled; } function getRotX(): number { return rotX; } function getRotZ(): number { return rotZ; } function getIsViewingBlackPerspective(): boolean { return isViewingBlackPerspective; } function toggle(): void { if (!docutil.isMouseSupported()) return toast.show(translations.rendering.perspective_mode_on_desktop); if (!enabled) enable(); else disable(); } function enable(): void { if (enabled) return console.error('Should not be enabling perspective when it is already enabled.'); enabled = true; guipause.getelement_perspective().textContent = `${translations.rendering.perspective}: ${translations.rendering.on}`; guipause.callback_Resume(); lockMouse(); initCrosshairModel(); toast.show(translations.rendering.movement_tutorial); } function disable(): void { if (!enabled) return; frametracker.onVisualChange(); enabled = false; // document.exitPointerLock() guipause.callback_Resume(); guipause.getelement_perspective().textContent = `${translations.rendering.perspective}: ${translations.rendering.off}`; const viewWhitePerspective = gameslot.areInGame() ? gameslot.isLoadedGameViewingWhitePerspective() : true; resetRotations(viewWhitePerspective); } // Sets rotations to orthographic view. Sensitive to if we're white or black. function resetRotations(viewWhitePerspective = true): void { rotX = 0; rotZ = viewWhitePerspective ? 0 : 180; updateIsViewingBlackPerspective(); camera.onPositionChange(); } // Called when the mouse re-clicks the screen after ALREADY in perspective. function relockMouse(): void { if (!enabled) return; if (isMouseLocked()) return; if (guipause.areWePaused()) return; if (selection.getSquarePawnIsCurrentlyPromotingOn()) return; lockMouse(); } function lockMouse(): void { camera.canvas.requestPointerLock(); // Disables OS-level mouse acceleration. This does NOT solve safari being more sensitive. // camera.canvas.requestPointerLock({ unadjustedMovement: true }); } function update(): void { if (!enabled) return; // If they pushed escape, the mouse will no longer be locked // If the mouse is unlocked, don't rotate view. if (!isMouseLocked()) { // Check if needs to relock if (listener_overlay.isMouseClicked(Mouse.LEFT)) { listener_overlay.claimMouseClick(Mouse.LEFT); relockMouse(); } else if (listener_overlay.isMouseDown(Mouse.LEFT)) listener_overlay.claimMouseDown(Mouse.LEFT); // Prevents piece drag start from claiming this mouse down. return; } const mouseChange = listener_document.getPhysicalPointerDelta('mouse'); if (!mouseChange) throw Error('Mouse pointer not present!'); const thisSensitivity = mouseSensitivityMultiplier * (preferences.getPerspectiveSensitivity() / 100); // Divide by 100 to bring it to the range 0.25-2 // Change rotations based on mouse motion rotX += mouseChange[1] * thisSensitivity; rotZ += mouseChange[0] * thisSensitivity; capRotations(); updateIsViewingBlackPerspective(); camera.onPositionChange(); // Calculate new viewMatrix } // Applies perspective rotation to default camera viewMatrix function applyRotations(viewMatrix: Mat4): void { if (haveZeroRotation()) return; // No perspective rotation const cameraPos = camera.getPosition(); // devMode-sensitive // Shift the origin before rotating plane mat4.translate(viewMatrix, viewMatrix, cameraPos); if (rotX < 0) { // Looking up somewhat const rotXRad = rotX * (Math.PI / 180); mat4.rotate(viewMatrix, viewMatrix, rotXRad, [1, 0, 0]); } // const rotYRad = rotY * (Math.PI / 180); // mat4.rotate(viewMatrix, viewMatrix, rotYRad, [0,1,0]) const rotZRad = rotZ * (Math.PI / 180); mat4.rotate(viewMatrix, viewMatrix, rotZRad, [0, 0, 1]); // Shift the origin back where it was const negativeCameraPos = [-cameraPos[0], -cameraPos[1], -cameraPos[2]]; mat4.translate(viewMatrix, viewMatrix, negativeCameraPos); } /** Returns true if we have no perspective rotation */ function haveZeroRotation(): boolean { return rotX === 0 && rotZ === 0; } /** Returns *true* if we're looking above the horizon. */ function isLookingUp(): boolean { return enabled && rotX <= -90; } // Makes sure we don't go upside-down function capRotations(): void { if (rotX > 0) rotX = 0; else if (rotX < -180) rotX = -180; if (rotZ < 0) rotZ += 360; else if (rotZ > 360) rotZ -= 360; } function isMouseLocked(): boolean { return document.pointerLockElement === camera.canvas; } // Buffer model of crosshair. Called whenever perspective is enabled, screen is resized, or devMode is toggled. function initCrosshairModel(): void { if (!enabled) return; const screenHeight = camera.getScreenHeightWorld(); const innerSide = ((crosshairThickness / 2) * screenHeight) / camera.getCanvasHeightVirtualPixels(); const [r, g, b, a] = crosshairColor; // prettier-ignore const data = new Float32Array([ // Vertex Color -innerSide, -innerSide, r, g, b, a, -innerSide, innerSide, r, g, b, a, innerSide, innerSide, r, g, b, a, innerSide, innerSide, r, g, b, a, innerSide, -innerSide, r, g, b, a, -innerSide, -innerSide, r, g, b, a, ]); crosshairModel = createRenderable(data, 2, 'TRIANGLES', 'color', true); } function renderCrosshair(): void { if (!enabled) return; if (config.VIDEO_MODE) return; // Don't render while recording renderWithoutPerspectiveRotations(() => { webgl.executeWithInverseBlending(() => { crosshairModel.render(); }); }); } /** * Renders (performs) whatever function is passed to it, * as if our camera was looking straight at the board from * white's perspective. ZERO perspective rotations! * Works both in 3D perspective mode and in 2D black's-perspective mode. */ function renderWithoutPerspectiveRotations(func: Function): void { if (haveZeroRotation()) return func(); const perspectiveViewMatrixCopy = camera.getViewMatrix(); camera.initViewMatrix(true); // Init view while ignoring perspective rotations func(); camera.setViewMatrix(perspectiveViewMatrixCopy); // Re-put back the perspective rotation } // Used when the promotion UI opens function unlockMouse(): void { if (!enabled) return; document.exitPointerLock(); } function updateIsViewingBlackPerspective(): void { isViewingBlackPerspective = rotZ > 90 && rotZ < 270; } // Exports ----------------------------------------------------------------------- export default { getEnabled, getRotX, getRotZ, distToRenderBoard, getIsViewingBlackPerspective, toggle, disable, resetRotations, relockMouse, update, applyRotations, isMouseLocked, renderCrosshair, renderWithoutPerspectiveRotations, unlockMouse, isLookingUp, initCrosshairModel, }; ================================================ FILE: src/client/scripts/esm/game/rendering/piecemodels.ts ================================================ // src/client/scripts/esm/game/rendering/piecemodels.ts /** * This generates and renders the meshes of each individual piece type in the game. */ import type { Piece } from '../../../../../shared/chess/util/boardutil.js'; import type { Board } from '../../../../../shared/chess/logic/gamefile.js'; import type { Coords } from '../../../../../shared/chess/util/coordutil.js'; import type { TypeGroup } from '../../../../../shared/chess/util/typeutil.js'; import vectors from '../../../../../shared/util/math/vectors.js'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import geometry from '../../../../../shared/util/math/geometry.js'; import bdcoords from '../../../../../shared/chess/util/bdcoords.js'; import coordutil from '../../../../../shared/chess/util/coordutil.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import { rawTypes as r } from '../../../../../shared/chess/util/typeutil.js'; import meshes from './meshes.js'; import { gl } from './webgl.js'; import boardpos from './boardpos.js'; import miniimage from './miniimage.js'; import perspective from './perspective.js'; import frametracker from './frametracker.js'; import texturecache from '../../chess/rendering/texturecache.js'; import instancedshapes from './instancedshapes.js'; import { AttributeInfoInstanced, RenderableInstanced, createRenderable_Instanced, createRenderable_Instanced_GivenInfo, } from '../../webgl/Renderable.js'; // Types -------------------------------------------------------------------------------------------- /** * Piece Mesh Instance Data. * HIGH RESOLUTION bigint values. * null === undefined placeholder */ type InstanceData = (bigint | null)[]; /** Mesh data of a single piece type in mesh.types */ interface MeshData { /** Infinite precision BIGINT instance data for performing arithmetic. */ instanceData: InstanceData; /** Buffer model for rendering. (This automatically stores the instanceData32 array going into the gpu) */ model: RenderableInstanced; } /** An object that contains the buffer models to render the pieces in a game. */ interface Mesh { /** The amount the mesh data has been linearly shifted to make it closer to the origin, in coordinates `[x,y]`. * This helps require less severe uniform translations upon rendering when traveling massive distances. * The amount it is shifted depends on the nearest `REGEN_RANGE`. */ offset: Coords; /** Whether the position data of each piece mesh is inverted. This will be true if we're viewing black's perspective. */ inverted: boolean; /** An object containing the mesh data for each type of piece in the game. One for every type in `pieces` */ types: TypeGroup; } // Variables ---------------------------------------------------------------------------------------- /** * A tiny z offset, to prevent the pieces from tearing with highlights while in perspective. * * We can't solve that problem by using blending mode ALWAYS because we need animations * to be able to mask (block out) the currently-animated piece by rendering a transparent square * on the animated piece's destination that is higher in the depth buffer. */ const Z: number = 0.005; /** * The interval at which to modify the mesh's linear offset once you travel this distance. * 10,000 was arbitrarily chosen because once you reach uniform translations much bigger * than that, the rendering of the pieces start to get somewhat gittery. */ const REGEN_RANGE = 10_000n; // /** // * The distance of which panning will noticably distort the pieces mesh. // * If we ever shift the piece models by more than this, we should regenerate them instead. // */ // const DISTANCE_AT_WHICH_MESH_GLITCHES = Number.MAX_SAFE_INTEGER; // ~9 Quadrillion /** The instance data array stride, per piece. */ const STRIDE_PER_PIECE = 2; // instanceposition: (x,y) /** The attribute info of each of the piece type models, excluding voids. */ const ATTRIBUTE_INFO: AttributeInfoInstanced = { vertexDataAttribInfo: [ { name: 'a_position', numComponents: 2 }, { name: 'a_texturecoord', numComponents: 2 }, ], instanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }], }; // Generating Meshes ------------------------------------------------------------------------ /** * Regenerates every single piece mesh in the gamefile. * Call when first loading a game. * * SLOWEST. Minimize calling. */ function regenAll(boardsim: Board, mesh: Mesh | undefined): void { if (!mesh) return; console.log('Regenerating all piece type meshes.'); // Update the offset mesh.offset = geometry.roundPointToNearestGridpoint(boardpos.getBoardPos(), REGEN_RANGE); // Calculate whether the textures should be inverted or not, based on whether we're viewing black's perspective. mesh.inverted = perspective.getIsViewingBlackPerspective(); // For each piece type in the game, generate its mesh for (const type of boardsim.existingTypes) { // [43] pawn(white) if (typeutil.getRawType(type) === r.VOID) mesh.types[type] = genVoidModel(boardsim, mesh, type); // Custom mesh generation logic for voids else mesh.types[type] = genTypeModel(boardsim, mesh, type); // Normal generation logic for all pieces with a texture } frametracker.onVisualChange(); delete boardsim.pieces.newlyRegenerated; // Delete this flag now. It was to let us know the piece models needed to be regen'd. } /** * MIGHT BE UNUSED, SOON?? * * Regenerates the single model of the provided type. * Call externally after adding more undefined placeholders to a type list. * @param boardsim * @param mesh * @param type - The type of piece to regen the model for (e.g. 'pawnsW') */ function regenType(boardsim: Board, mesh: Mesh, type: number): void { console.log(`Regenerating mesh of type ${type}.`); if (typeutil.getRawType(type) === r.VOID) mesh.types[type] = genVoidModel(boardsim, mesh, type); // Custom mesh generation logic for voids else mesh.types[type] = genTypeModel(boardsim, mesh, type); // Normal generation logic for all pieces with a texture frametracker.onVisualChange(); } /** * Generates the mesh data for a specific piece type in the gamefile that has a texture. (not compatible with voids) * Must be called whenever we add more undefineds placeholders to the this piece list. * * SLOWEST. Minimize calling. * @param boardsim * @param mesh * @param type - The type of piece of which to generate the model for (e.g. "pawnsW") */ function genTypeModel(boardsim: Board, mesh: Mesh, type: number): MeshData { const vertexData = instancedshapes.getDataTexture(mesh.inverted); const instanceData: InstanceData = getInstanceDataForTypeRange(boardsim, mesh, type); const texture = texturecache.getTexture(type); return { instanceData, model: createRenderable_Instanced_GivenInfo( vertexData, castInstanceDataToFloat32(instanceData), ATTRIBUTE_INFO, 'TRIANGLES', 'textureInstanced', [{ texture, uniformName: 'u_sampler' }], ), }; } /** * Generates the model of the voids in the game. * Must be called whenever we add more undefineds placeholders to the voids piece list. * * SLOWEST. Minimize calling. */ function genVoidModel(boardsim: Board, mesh: Mesh, type: number): MeshData { // const voidColor = preferences.getTintColorOfType(type); // Black, from the pieceTheme const voidColor = gl.getParameter(gl.COLOR_CLEAR_VALUE); // Same color as the sky / void space star field. DOESN'T EVEN MATTER SINCE IT'S A MASK! const vertexData: number[] = instancedshapes.getDataLegalMoveSquare(voidColor); const instanceData: InstanceData = getInstanceDataForTypeRange(boardsim, mesh, type); return { instanceData, model: createRenderable_Instanced( vertexData, castInstanceDataToFloat32(instanceData), 'TRIANGLES', 'colorInstanced', true, ), }; } /** * Calculates the instance data of a piece list that will go into its mesh constructor. * The instance data contains only the offset of each piece instance, with a stride of 2. * Thus, this works will all types of pieces, even those without a texture, such as voids. */ function getInstanceDataForTypeRange(boardsim: Board, mesh: Mesh, type: number): InstanceData { // const range = boardsim.pieces.typeRanges.get(type)!; // const instanceData64: Float64Array = new Float64Array((range.end - range.start) * STRIDE_PER_PIECE); // Initialize with all 0's const instanceData: InstanceData = []; // Initialize empty let currIndex: number = 0; boardutil.iteratePiecesInTypeRange_IncludeUndefineds( boardsim.pieces, type, (idx: number, isUndefined: boolean) => { if (isUndefined) { // Undefined placeholder, this one should not be visible. If we leave it at 0, then there would be a visible void at [0,0] instanceData[currIndex] = null; instanceData[currIndex + 1] = null; } else { // NOT undefined const coords = boardutil.getCoordsFromIdx(boardsim.pieces, idx); // Apply the piece mesh offset to the coordinates instanceData[currIndex] = coords[0] - mesh.offset[0]; instanceData[currIndex + 1] = coords[1] - mesh.offset[1]; } currIndex += STRIDE_PER_PIECE; }, ); return instanceData; } /** * Converts a (bigint | null) array containing into a `Float32Array`. * Which should then be used to pass into a buffer model constructor. */ function castInstanceDataToFloat32(instanceData: InstanceData): Float32Array { // Pre-allocate the Float32Array to the final size. Critical for performance. const result: Float32Array = new Float32Array(instanceData.length); // Iterate through the source array once and place the converted value directly into the result array. // This single-pass approach is much faster than methods like .map(), which create a temporary intermediate array. for (let i: number = 0; i < instanceData.length; i++) { const value: bigint | null = instanceData[i]!; if (value === null) { // Convert null to NaN. When used as a vertex position, NaN values are typically // discarded by the GPU's rasterizer, effectively making the vertex invisible. result[i] = NaN; // Alternative would be Infinity } else { // value === bigint // Convert the bigint to a number. The Float32Array will store it as a 32-bit float. // Naturally, precision loss occurs. result[i] = Number(value); } } return result; } /** * Converts a bigint instance data array into a `Float32Array`. * Which should then be used to pass into a buffer model constructor. */ function castBigIntArrayToFloat32(instanceData: bigint[]): Float32Array { // Pre-allocate the Float32Array to the final size. This is critical for performance. const result: Float32Array = new Float32Array(instanceData.length); // Iterate through the source array once and place the converted value directly into the result array. // This single-pass approach is much faster than methods like .map(), which create a temporary intermediate array. for (let i: number = 0; i < instanceData.length; i++) { // Convert the bigint to a number. The Float32Array will store it as a 32-bit float. // Be aware of potential precision loss for very large BigInts. result[i] = Number(instanceData[i]); } return result; } // Shifting Meshes ------------------------------------------------------------------------ /** * Shifts the instance data of each piece mesh in the game to require less severe * uniform translations upon rendering, and reinits them on the gpu. * Faster than {@link regenAll}. */ function shiftAll(boardsim: Board, mesh: Mesh): void { console.log('Shifting all piece meshes.'); const newOffset = geometry.roundPointToNearestGridpoint(boardpos.getBoardPos(), REGEN_RANGE); const diffXOffset = mesh.offset[0] - newOffset[0]; const diffYOffset = mesh.offset[1] - newOffset[1]; // const chebyshevDistance = vectors.chebyshevDistance(mesh.offset, newOffset); // if (chebyshevDistance > DISTANCE_AT_WHICH_MESH_GLITCHES) { // console.log(`REGENERATING the piece models instead of shifting them. They were shifted by ${chebyshevDistance} tiles!`); // regenAll(boardsim, mesh); // return; // } mesh.offset = newOffset; // Go ahead and shift each model for (const meshData of Object.values(mesh.types)) { shiftModel(meshData, diffXOffset, diffYOffset); } } /** * Shifts the vertex data of the piece model and reinits it on the gpu. * Faster than {@link regenType} or {@link genTypeModel}. * @param meshData - An object containing the infinite resolution bigint instanceData, and the actual model. * @param diffXOffset - The x-amount to shift the model's vertex data. * @param diffYOffset - The y-amount to shift the model's vertex data. */ function shiftModel(meshData: MeshData, diffXOffset: bigint, diffYOffset: bigint): void { const instanceData = meshData.instanceData; // High precision floats for performing calculations const instanceData32 = meshData.model.instanceData; // Low precision floats for sending to the gpu for (let i = 0; i < instanceData32.length; i += STRIDE_PER_PIECE) { if (instanceData[i] === null) continue; // Skip undefined placeholders instanceData[i]! += diffXOffset; instanceData[i + 1]! += diffYOffset; // Copy the float32 values from the bigint array so as to retain the most precision instanceData32[i]! = Number(instanceData[i]!); instanceData32[i + 1]! = Number(instanceData[i + 1]!); } // Update the buffer on the gpu! meshData.model.updateBufferIndices_InstanceBuffer(0, instanceData.length); // Update every index } // Rotating Models ------------------------------------------------------------------------------ /** * Rotates each piece model (except voids) by updating its vertex data of * a single instance with the updated rotation, then reinits them on the gpu. * * FAST, as this only needs to modify the vertex data of a single instance per piece type. */ function rotateAll(mesh: Mesh, newInverted: boolean): void { // console.log("Rotating position data of all type meshes!"); mesh.inverted = newInverted; const newVertexData = instancedshapes.getDataTexture(mesh.inverted); for (const [stringType, meshData] of Object.entries(mesh.types)) { const rawType = typeutil.getRawType(Number(stringType)); if (typeutil.SVGLESS_TYPES.has(rawType)) continue; // Skip voids and other non-textured pieces, currently they are symmetrical // Not a void, which means its guaranteed to be a piece with a texture... const vertexData = meshData.model.vertexData; if (vertexData.length !== newVertexData.length) throw Error( 'New vertex data must be the same length as the existing! Cannot update buffer indices.', ); // Safety net vertexData.set(newVertexData); // Copies the values over without changing the memory location meshData.model.updateBufferIndices_VertexBuffer(0, vertexData.length); // Send those changes off to the gpu } } // Modifying Mesh Data -------------------------------------------------------------------------- /** * Overwrites the instance data of the specified piece within its * piece type mesh with the new coordinates of the instance. * Then sends that change off to the gpu. * * FAST, much faster than regenerating the entire mesh * whenever a piece moves or something is captured/generated! */ function overwritebufferdata(mesh: Mesh, piece: Piece): void { const meshData = mesh.types[piece.type]!; const i = piece.index * STRIDE_PER_PIECE; const offsetCoord = coordutil.subtractCoords(piece.coords, mesh.offset); meshData.instanceData[i] = offsetCoord[0]; meshData.instanceData[i + 1] = offsetCoord[1]; meshData.model.instanceData[i] = Number(offsetCoord[0]); meshData.model.instanceData[i + 1] = Number(offsetCoord[1]); // Update the buffer on the gpu! meshData.model.updateBufferIndices_InstanceBuffer(i, STRIDE_PER_PIECE); // Update only the indices the piece is at } /** * Deletes the instance data of the specified piece within its piece type mesh * by overwriting it with Infinity's, then sends that change off to the gpu. * * FAST, much faster than regenerating the entire mesh * whenever a piece moves or something is captured/generated! */ function deletebufferdata(mesh: Mesh, piece: Piece): void { const meshData = mesh.types[piece.type]!; const i = piece.index * STRIDE_PER_PIECE; // Unfortunately we can't set them to 0 to hide it, as an actual piece instance would be visible at [0,0] meshData.instanceData[i] = null; meshData.instanceData[i + 1] = null; meshData.model.instanceData[i] = NaN; meshData.model.instanceData[i + 1] = NaN; // Update the buffer on the gpu! meshData.model.updateBufferIndices_InstanceBuffer(i, STRIDE_PER_PIECE); // Update only the indices the piece was at } // Rendering ---------------------------------------------------------------------------------------- /** * Renders ever piece type mesh of the game, EXCLUDING voids, * translating and scaling them into position. */ function renderAll(boardsim: Board, mesh: Mesh | undefined): void { if (!mesh) return; // Mesh hasn't been generated yet const { position, scale } = meshes.getBoardRenderTransform(mesh.offset, Z); if (boardpos.areZoomedOut() && !miniimage.isDisabled()) { // Only render voids // NOT ANYMORE SINCE ADDING STAR FIELD ANIMATION (voids are rendered separately) // mesh.types[r.VOID]?.model.render(position, scale); return; } // We can render everything... // Do we need to shift the instance data of the piece models? Are we out of bounds of our REGEN_RANGE? if (!boardpos.areZoomedOut() && isOffsetOutOfRangeOfRegenRange(mesh.offset)) shiftAll(boardsim, mesh); // Test if the rotation has changed const correctInverted = perspective.getIsViewingBlackPerspective(); if (mesh.inverted !== correctInverted) rotateAll(mesh, correctInverted); for (const [typeStr, meshData] of Object.entries(mesh.types)) { const type = Number(typeStr); if (type === r.VOID) continue; // Skip voids, they should be rendered separately meshData.model.render(position, scale); } } /** Renders the voids mesh. */ function renderVoids(mesh: Mesh | undefined): void { if (!mesh) return; // Mesh hasn't been generated yet const { position, scale } = meshes.getBoardRenderTransform(mesh.offset, Z); mesh.types[r.VOID]?.model.render(position, scale); } /** * Tests if the board position is at least REGEN_RANGE-distance away from the current offset. * If so, each piece mesh data should be shifted to require less severe uniform translations when rendering. */ function isOffsetOutOfRangeOfRegenRange(offset: Coords): boolean { // offset: [x,y] const boardPosRounded: Coords = bdcoords.coordsToBigInt(boardpos.getBoardPos()); const chebyshevDist = vectors.chebyshevDistance(boardPosRounded, offset); return chebyshevDist > REGEN_RANGE; } // Exports -------------------------------------------------------------------------------------------- export default { ATTRIBUTE_INFO, regenAll, regenType, castBigIntArrayToFloat32, overwritebufferdata, deletebufferdata, renderAll, renderVoids, }; export type { Mesh }; ================================================ FILE: src/client/scripts/esm/game/rendering/pieces.ts ================================================ // src/client/scripts/esm/game/rendering/pieces.ts /** * This script renders all of our pieces on the board, * including voids, and mini images. */ import type { Mesh } from './piecemodels.js'; import type { Board } from '../../../../../shared/chess/logic/gamefile.js'; import type { Coords } from '../../../../../shared/chess/util/coordutil.js'; import meshes from './meshes.js'; import miniimage from './miniimage.js'; import piecemodels from './piecemodels.js'; import texturecache from '../../chess/rendering/texturecache.js'; import { createRenderable } from '../../webgl/Renderable.js'; // Variables --------------------------------------------------------------------- /** Opacity of ghost piece over legal move highlights. Default: 0.4 */ const ghostOpacity: number = 0.4; // Functions ----------------------------------------------------------------------- /** * Renders all of our pieces on the board, * including voids, and mini images, if visible. */ function renderPiecesInGame(boardsim: Board, mesh: Mesh | undefined): void { piecemodels.renderAll(boardsim, mesh); miniimage.render(); } /** Renders a semi-transparent piece at the specified coordinates. */ function renderGhostPiece(type: number, coords: Coords): void { const data = meshes.QuadWorld_ColorTexture(coords, [1, 1, 1, ghostOpacity]); const model = createRenderable( data, 2, 'TRIANGLES', 'colorTexture', true, texturecache.getTexture(type), ); model.render(); } // ------------------------------------------------------------------------------ export default { renderPiecesInGame, renderGhostPiece, }; ================================================ FILE: src/client/scripts/esm/game/rendering/primitives.ts ================================================ // src/client/scripts/esm/game/rendering/primitives.ts /** * This script contains methods for obtaining the vertex array data * of many common shapes, when their dimensions and position are known. * * This vertex data can then be used to pass into a buffer model for rendering. */ import type { Color } from '../../../../../shared/util/math/math.js'; // =========================================== Quads ================================================== /** [TRIANGLES] Generates vertex data for a 2D quad with NO COLOR DATA. */ function Quad(left: number, bottom: number, right: number, top: number): number[] { // prettier-ignore return [ // Position left, bottom, left, top, right, bottom, right, bottom, left, top, right, top, ]; } /** [TRIANGLES] Generates vertex data for a solid-colored 2D quad. */ // prettier-ignore function Quad_Color(left: number, bottom: number, right: number, top: number, [r,g,b,a]: Color): number[] { return [ // Position Color left, bottom, r, g, b, a, left, top, r, g, b, a, right, bottom, r, g, b, a, right, bottom, r, g, b, a, left, top, r, g, b, a, right, top, r, g, b, a, ]; } /** [TRIANGLES] Generates vertex data for a solid-colored 3D quad. */ // prettier-ignore function Quad_Color3D(left: number, bottom: number, right: number, top: number, z: number, [r,g,b,a]: Color): number[] { return [ // Position Color left, bottom, z, r, g, b, a, left, top, z, r, g, b, a, right, bottom, z, r, g, b, a, right, bottom, z, r, g, b, a, left, top, z, r, g, b, a, right, top, z, r, g, b, a, ]; } /** [TRIANGLES] Generates vertex and texture coordinate data for a textured 2D quad. */ // prettier-ignore function Quad_Texture(left: number, bottom: number, right: number, top: number, texleft: number, texbottom: number, texright: number, textop: number): number[] { return [ // Position Texture Coord left, bottom, texleft, texbottom, left, top, texleft, textop, right, bottom, texright, texbottom, right, bottom, texright, texbottom, left, top, texleft, textop, right, top, texright, textop, ]; } /** [TRIANGLES] Generates vertex, texture coordinate, and color data for a tinted textured 2D quad. */ // prettier-ignore function Quad_ColorTexture(left: number, bottom: number, right: number, top: number, texleft: number, texbottom: number, texright: number, textop: number, r: number, g: number, b: number, a: number): number[] { return [ // Position Texture Coord Color left, bottom, texleft, texbottom, r, g, b, a, left, top, texleft, textop, r, g, b, a, right, bottom, texright, texbottom, r, g, b, a, right, bottom, texright, texbottom, r, g, b, a, left, top, texleft, textop, r, g, b, a, right, top, texright, textop, r, g, b, a, ]; } /** [TRIANGLES] Generates vertex, texture coordinate, and color data for a tinted textured 3D quad. */ // prettier-ignore function Quad_ColorTexture3D(left: number, bottom: number, right: number, top: number, z: number, texleft: number, texbottom: number, texright: number, textop: number, r: number, g: number, b: number, a: number): number[] { return [ // Position Texture Coord Color left, bottom, z, texleft, texbottom, r, g, b, a, left, top, z, texleft, textop, r, g, b, a, right, bottom, z, texright, texbottom, r, g, b, a, right, bottom, z, texright, texbottom, r, g, b, a, left, top, z, texleft, textop, r, g, b, a, right, top, z, texright, textop, r, g, b, a, ]; } /** [LINE_LOOP] Generates vertex data for the outline of a 2D rectangle. */ // prettier-ignore function Rect(left: number, bottom: number, right: number, top: number, [r,g,b,a]: Color): number[] { return [ // x y color left, bottom, r, g, b, a, left, top, r, g, b, a, right, top, r, g, b, a, right, bottom, r, g, b, a, ]; } /** [TRIANGLES] Generates vertex data for the outline of a 2D DASHED rectangle. */ // prettier-ignore function DashedRect(left: number, bottom: number, right: number, top: number, thickness: number, dashLength: number, gapLength: number, [r,g,b,a]: Color): number[] { const data: number[] = []; const cycleLength = dashLength + gapLength; const halfThick = thickness / 2; // Return empty array for invalid parameters to avoid infinite loops or drawing garbage. if (dashLength <= 0 || thickness <= 0 || cycleLength <= 0) return []; const pushQuad = (left: number, bottom: number, right: number, top: number): void => { data.push( // Position Color left, bottom, r, g, b, a, left, top, r, g, b, a, right, bottom, r, g, b, a, right, bottom, r, g, b, a, left, top, r, g, b, a, right, top, r, g, b, a ); }; // Horizontal dashes (bottom and top edges) for (let x = left; x < right; x += cycleLength) { const dashEnd = Math.min(x + dashLength, right); if (dashEnd > x) { // Bottom pushQuad(x, bottom - halfThick, dashEnd, bottom + halfThick); // Top pushQuad(x, top - halfThick, dashEnd, top + halfThick); } } // Vertical dashes (left and right edges) for (let y = bottom; y < top; y += cycleLength) { const dashEnd = Math.min(y + dashLength, top); if (dashEnd > y) { // Left pushQuad(left - halfThick, y, left + halfThick, dashEnd); // Right pushQuad(right - halfThick, y, right + halfThick, dashEnd); } } return data; } // =========================================== Circles ================================================ /** [LINE_LOOP] Generates vertex data for the outline of a hollow circle. */ // function Circle_LINES(x: number, y: number, radius: number, r: number, g: number, b: number, a: number, resolution: number): number[] { // res is resolution // if (resolution < 3) throw Error("Resolution must be 3+ to get data of a circle."); // const data: number[] = []; // for (let i = 0; i < resolution; i++) { // const theta = (i / resolution) * 2 * Math.PI; // const thisX = x + radius * Math.cos(theta); // const thisY = y + radius * Math.sin(theta); // // Points around the circle // data.push(thisX, thisY, r, g, b, a); // } // return data; // } /** [TRIANGLES] Generates vertex data for a solid-colored circle composed of triangles. */ // prettier-ignore function Circle(x: number, y: number, radius: number, resolution: number, [r,g,b,a]: Color): number[] { if (resolution < 3) throw Error("Resolution must be 3+ to get data of a circle."); const data: number[] = []; for (let i = 0; i < resolution; i++) { // Current and next angle positions const theta = (i / resolution) * 2 * Math.PI; const nextTheta = ((i + 1) / resolution) * 2 * Math.PI; // Position of current and next points on the circumference const x1 = x + radius * Math.cos(theta); const y1 = y + radius * Math.sin(theta); const x2 = x + radius * Math.cos(nextTheta); const y2 = y + radius * Math.sin(nextTheta); // Center point data.push(x, y, r, g, b, a); // Points around the circle data.push(x1, y1, r, g, b, a); data.push(x2, y2, r, g, b, a); } return data; } /** [TRIANGLE_FAN] Generates vertex data for a circle with a color gradient from the center to the edge. */ // prettier-ignore function GlowDot(x: number, y: number, radius: number, resolution: number, [r1,g1,b1,a1]: Color, [r2,g2,b2,a2]: Color): number[] { if (resolution < 3) throw Error("Resolution must be 3+ to get data of a fuzz ball."); const data: number[] = [x, y, r1, g1, b1, a1]; // Mid point for (let i = 0; i <= resolution; i++) { // Add all outer points const theta = (i / resolution) * 2 * Math.PI; const thisX = x + radius * Math.cos(theta); const thisY = y + radius * Math.sin(theta); data.push(...[thisX, thisY, r2, g2, b2, a2]); } return data; } /** [TRIANGLES] Generates vertex data for a solid-colored ring. */ // function RingSolid(x: number, y: number, inRad: number, outRad: number, resolution: number, [r,g,b,a]: Color): number[] { // if (resolution < 3) throw Error("Resolution must be 3+ to get data of a ring."); // const data: number[] = []; // for (let i = 0; i < resolution; i++) { // const theta = (i / resolution) * 2 * Math.PI; // const nextTheta = ((i + 1) / resolution) * 2 * Math.PI; // const innerX = x + inRad * Math.cos(theta); // const innerY = y + inRad * Math.sin(theta); // const outerX = x + outRad * Math.cos(theta); // const outerY = y + outRad * Math.sin(theta); // const innerXNext = x + inRad * Math.cos(nextTheta); // const innerYNext = y + inRad * Math.sin(nextTheta); // const outerXNext = x + outRad * Math.cos(nextTheta); // const outerYNext = y + outRad * Math.sin(nextTheta); // // Add triangles for the current and next segments // data.push( // innerX, innerY, r, g, b, a, // outerX, outerY, r, g, b, a, // innerXNext, innerYNext, r, g, b, a, // outerX, outerY, r, g, b, a, // outerXNext, outerYNext, r, g, b, a, // innerXNext, innerYNext, r, g, b, a // ); // } // return data; // } /** [TRIANGLES] Generates vertex data for a ring with color gradients between the inner and outer edges. */ // prettier-ignore function Ring(x: number, y: number, inRad: number, outRad: number, resolution: number, [r1,g1,b1,a1]: Color, [r2,g2,b2,a2]: Color): number[] { if (resolution < 3) throw Error("Resolution must be 3+ to get data of a ring."); const data: number[] = []; for (let i = 0; i < resolution; i++) { const theta = (i / resolution) * 2 * Math.PI; const nextTheta = ((i + 1) / resolution) * 2 * Math.PI; const innerX = x + inRad * Math.cos(theta); const innerY = y + inRad * Math.sin(theta); const outerX = x + outRad * Math.cos(theta); const outerY = y + outRad * Math.sin(theta); const innerXNext = x + inRad * Math.cos(nextTheta); const innerYNext = y + inRad * Math.sin(nextTheta); const outerXNext = x + outRad * Math.cos(nextTheta); const outerYNext = y + outRad * Math.sin(nextTheta); // Add triangles for the current and next segments data.push( innerX, innerY, r1, g1, b1, a1, outerX, outerY, r2, g2, b2, a2, innerXNext, innerYNext, r1, g1, b1, a1, outerX, outerY, r2, g2, b2, a2, outerXNext, outerYNext, r2, g2, b2, a2, innerXNext, innerYNext, r1, g1, b1, a1 ); } return data; } /** * [TRIANGLES] Generates vertex data for a radial gradient centered at (x, y). * Colors repeat outward with the given spacing (same units as x/y) and phase offset. */ // prettier-ignore function RadialGradient(x: number, y: number, radius: number, colors: Color[], spacing: number, phase: number, resolution: number): number[] { if (colors.length === 0 || spacing <= 0 || radius <= 0) return []; const n = colors.length; function colorAtRadius(r: number): Color { const t = (r + phase) / spacing; const lower = Math.floor(t); const frac = t - lower; const c1 = colors[((lower % n) + n) % n]!; const c2 = colors[(((lower + 1) % n) + n) % n]!; return [ c1[0] + (c2[0] - c1[0]) * frac, c1[1] + (c2[1] - c1[1]) * frac, c1[2] + (c2[2] - c1[2]) * frac, c1[3] + (c2[3] - c1[3]) * frac, ]; } // Build ring boundaries: radii where (r + phase) is an exact multiple of spacing. const phasemod = ((phase % spacing) + spacing) % spacing; const firstBoundary = phasemod === 0 ? 0 : spacing - phasemod; const boundaries: number[] = [0]; let r = firstBoundary > 0 ? firstBoundary : spacing; while (r < radius) { boundaries.push(r); r += spacing; } boundaries.push(radius); const data: number[] = []; for (let i = 0; i < boundaries.length - 1; i++) { const innerR = boundaries[i]!; const outerR = boundaries[i + 1]!; const [r1, g1, b1, a1] = colorAtRadius(innerR); const [r2, g2, b2, a2] = colorAtRadius(outerR); for (let j = 0; j < resolution; j++) { const theta = (j / resolution) * 2 * Math.PI; const nextTheta = ((j + 1) / resolution) * 2 * Math.PI; const outerX = x + outerR * Math.cos(theta); const outerY = y + outerR * Math.sin(theta); const outerXNext = x + outerR * Math.cos(nextTheta); const outerYNext = y + outerR * Math.sin(nextTheta); if (innerR === 0) { data.push( x, y, r1, g1, b1, a1, outerX, outerY, r2, g2, b2, a2, outerXNext, outerYNext, r2, g2, b2, a2, ); } else { const innerX = x + innerR * Math.cos(theta); const innerY = y + innerR * Math.sin(theta); const innerXNext = x + innerR * Math.cos(nextTheta); const innerYNext = y + innerR * Math.sin(nextTheta); data.push( innerX, innerY, r1, g1, b1, a1, outerX, outerY, r2, g2, b2, a2, innerXNext, innerYNext, r1, g1, b1, a1, outerX, outerY, r2, g2, b2, a2, outerXNext, outerYNext, r2, g2, b2, a2, innerXNext, innerYNext, r1, g1, b1, a1, ); } } } return data; } // =========================================== Other Shapes ================================================ /** [TRIANGLES] Generates vertex data for a four-sided, hollow rectangular prism. */ // prettier-ignore function BoxTunnel(left: number, bottom: number, startZ: number, right: number, top: number, endZ: number, r: number, g: number, b: number, a: number): number[] { return [ // Vertex Color left, bottom, startZ, r, g, b, a, left, bottom, endZ, r, g, b, a, right, bottom, startZ, r, g, b, a, right, bottom, startZ, r, g, b, a, left, bottom, endZ, r, g, b, a, right, bottom, endZ, r, g, b, a, right, bottom, startZ, r, g, b, a, right, bottom, endZ, r, g, b, a, right, top, startZ, r, g, b, a, right, top, startZ, r, g, b, a, right, bottom, endZ, r, g, b, a, right, top, endZ, r, g, b, a, right, top, startZ, r, g, b, a, right, top, endZ, r, g, b, a, left, top, startZ, r, g, b, a, left, top, startZ, r, g, b, a, right, top, endZ, r, g, b, a, left, top, endZ, r, g, b, a, left, top, startZ, r, g, b, a, left, top, endZ, r, g, b, a, left, bottom, startZ, r, g, b, a, left, bottom, startZ, r, g, b, a, left, top, endZ, r, g, b, a, left, bottom, endZ, r, g, b, a, ]; } // =========================================== Exports ================================================ export default { // Quads Quad, Quad_Color, Quad_Color3D, Quad_Texture, Quad_ColorTexture, Quad_ColorTexture3D, Rect, DashedRect, // Circles Circle, GlowDot, Ring, RadialGradient, // Other Shapes BoxTunnel, }; ================================================ FILE: src/client/scripts/esm/game/rendering/promotionlines.ts ================================================ // src/client/scripts/esm/game/rendering/promotionlines.ts /** * This script handles the rendering of our promotion lines. */ import type { Color } from '../../../../../shared/util/math/math.js'; import bd from '@naviary/bigdecimal'; import { players as p } from '../../../../../shared/chess/util/typeutil.js'; import camera from './camera.js'; import meshes from './meshes.js'; import gameslot from '../chess/gameslot.js'; import boardpos from './boardpos.js'; import boardtiles from './boardtiles.js'; import primitives from './primitives.js'; import { createRenderable } from '../../webgl/Renderable.js'; // ===================================== Constants ===================================== /** How many tiles on both ends the promotion lines should extend past the farthest piece */ const EXTRA_LENGTH = 2; /** Vertical thickness of the promotion lines. */ const THICKNESS = 0.01; // ===================================== Functions ===================================== function render(): void { const gamefile = gameslot.getGamefile()!; if (gamefile.basegame.gameRules.promotionRanks === undefined) return; // No promotion ranks in this game // Generate the vertex data const position = boardpos.getBoardPos(); const scale = boardpos.getBoardScaleAsNumber(); let left: number; let right: number; if (gamefile.boardsim.editor) { // In editor mode, the promotion lines extend to the edges of the screen ({ left, right } = camera.getRespectiveScreenBox()); } else { // Round the start position box away to encapsulate the entirity of all squares const floatingBox = meshes.expandTileBoundingBoxToEncompassWholeSquare( gamefile.boardsim.startSnapshot.box, ); left = (bd.toNumber(bd.subtract(floatingBox.left, position[0])) - EXTRA_LENGTH) * scale; right = (bd.toNumber(bd.subtract(floatingBox.right, position[0])) + EXTRA_LENGTH) * scale; } const squareCenterNum = boardtiles.getSquareCenterAsNumber(); const color: Color = [0, 0, 0, 1]; const vertexData: number[] = []; addDataForSide(gamefile.basegame.gameRules.promotionRanks[p.WHITE], 1); addDataForSide(gamefile.basegame.gameRules.promotionRanks[p.BLACK], 0); function addDataForSide(ranks: bigint[] | undefined, yShift: 1 | 0): void { if (!ranks) return; ranks.forEach((rank) => { const rankBD = bd.fromBigInt(rank); const relativeRank: number = bd.toNumber(bd.subtract(rankBD, position[1])); // Subtract our board position const bottom = (relativeRank - squareCenterNum + yShift - THICKNESS) * scale; const top = (relativeRank - squareCenterNum + yShift + THICKNESS) * scale; vertexData.push(...primitives.Quad_Color(left, bottom, right, top, color)); }); } // Create and Render the model createRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render(); } // ===================================== Exports ===================================== export default { render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/screenshake.ts ================================================ // src/client/scripts/esm/game/rendering/screenshake.ts /** * This module can apply a screen shake effect to the camera when requested. */ import type { Mat4 } from './camera'; import mat4 from './gl-matrix.js'; import camera from './camera'; import { GameBus } from '../GameBus.js'; import loadbalancer from '../misc/loadbalancer.js'; import frametracker from './frametracker.js'; // Constants ----------------------------------------------------------------------- // Shake Parameters /** Maximum rotation in any direction (in degrees). */ const MAX_ROTATION_DEGREES = 1.7; // Default: 2.1 /** Maximum translation in any direction (in world units). */ const MAX_TRANSLATION = 0.23; // Default: 0.28 /** How quickly trauma fades. Higher is faster. */ const TRAUMA_DECAY = 1.2; // State --------------------------------------------------------------------------- let trauma = 0.0; // Current shake intensity, 0.0 to 1.0 // Events -------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { clear(); }); // Functions ----------------------------------------------------------------------- /** * Adds trauma to the camera, triggering or intensifying the shake. * @param amount The amount of trauma to add (usually between 0.1 and 1.0). */ function trigger(amount: number): void { // console.log("Shake trauma added: " + amount); trauma = Math.min(trauma + amount, 1.0); frametracker.onVisualChange(); // Request an animation frame camera.onPositionChange(); // Camera will update its view matrix } /** Clears all trauma, stopping any shake immediately. */ function clear(): void { trauma = 0.0; frametracker.onVisualChange(); camera.onPositionChange(); // Camera will update its view matrix } /** * Updates the trauma level. Called once per frame. */ function update(): void { if (trauma === 0) return; // Decrease trauma over time const deltaTimeSecs = loadbalancer.getDeltaTime(); trauma = Math.max(trauma - deltaTimeSecs * TRAUMA_DECAY, 0); frametracker.onVisualChange(); // Request an animation frame camera.onPositionChange(); // Camera will update its view matrix } /** * Calculates and returns a 4x4 transformation matrix representing the current shake offset. * If there is no trauma, it returns an identity matrix (no shake). */ function getShakeMatrix(): Mat4 { if (trauma <= 0) return mat4.create(); // Returns an identity matrix // The intensity of the shake is proportional to the square of the trauma. // This makes small amounts of trauma barely noticeable, and large amounts very dramatic. const shakePower = trauma; /** Generates a random value in a [-1, 1] range. */ const getRandomNoise = (): number => (Math.random() - 0.5) * 2; // Calculate Rotation const yaw = MAX_ROTATION_DEGREES * shakePower * getRandomNoise(); const pitch = MAX_ROTATION_DEGREES * shakePower * getRandomNoise(); const roll = MAX_ROTATION_DEGREES * shakePower * getRandomNoise(); // Convert degrees to radians for gl-matrix const yawRad = (yaw * Math.PI) / 180; const pitchRad = (pitch * Math.PI) / 180; const rollRad = (roll * Math.PI) / 180; // Calculate Translation const offsetX = MAX_TRANSLATION * shakePower * getRandomNoise(); const offsetY = MAX_TRANSLATION * shakePower * getRandomNoise(); const offsetZ = MAX_TRANSLATION * shakePower * getRandomNoise(); // Create the Transformation Matrix const shakeMatrix = mat4.create(); // Apply translation mat4.translate(shakeMatrix, shakeMatrix, [offsetX, offsetY, offsetZ]); // Apply rotations (order can matter, Z then X then Y is common) mat4.rotateZ(shakeMatrix, shakeMatrix, rollRad); mat4.rotateX(shakeMatrix, shakeMatrix, pitchRad); mat4.rotateY(shakeMatrix, shakeMatrix, yawRad); return shakeMatrix; } // Exports ------------------------------------------------------------------------- export default { trigger, update, getShakeMatrix, }; ================================================ FILE: src/client/scripts/esm/game/rendering/starfield.ts ================================================ // src/client/scripts/esm/game/rendering/starfield.ts /** * Renders a starfield background inside voids and the world border */ import type { Color } from '../../../../../shared/util/math/math.js'; import type { DoubleCoords } from '../../../../../shared/chess/util/coordutil.js'; import bounds from '../../../../../shared/util/math/bounds.js'; import boardutil from '../../../../../shared/chess/util/boardutil.js'; import { rawTypes as r } from '../../../../../shared/chess/util/typeutil.js'; import camera from './camera.js'; import docutil from '../../util/docutil.js'; import gameslot from '../chess/gameslot.js'; import primitives from './primitives.js'; import boardtiles from './boardtiles.js'; import gameloader from '../chess/gameloader.js'; import preferences from '../../components/header/preferences.js'; import perspective from './perspective.js'; import { GameBus } from '../GameBus.js'; import loadbalancer from '../misc/loadbalancer.js'; import frametracker from './frametracker.js'; import { AttributeInfoInstanced, createRenderable_Instanced_GivenInfo, } from '../../webgl/Renderable.js'; /** A sigle star particle. */ type Star = { /** Determines if the star should use the light or dark tile color theme. */ isLight: boolean; /** Lifespan in milliseconds */ lifespan: number; position: DoubleCoords; velocity: DoubleCoords; size: number; /** The maximum size offset for this star's pulse (the amplitude). */ pulseSize: number; /** The speed of this star's pulse in radians per second. */ pulseSpeed: number; /** The timestamp when the star was created. */ createdAt: number; }; /** The attribute info of our instanced models' vertex data. */ const ATTRIB_INFO: AttributeInfoInstanced = { vertexDataAttribInfo: [{ name: 'a_position', numComponents: 2 }], instanceDataAttribInfo: [ { name: 'a_instanceposition', numComponents: 2 }, { name: 'a_instancecolor', numComponents: 4 }, { name: 'a_instancesize', numComponents: 1 }, ], }; /** Configuration variables for Star Field appearance. */ const CONFIG = { /** The DENSITY of stars, measured in stars per square unit of world space. */ starDensity: 0.07, // Default: 0.07 // starDensity: 1, /** How many additional units of world space beyond the edges of the screen to spawn stars. */ screenPadding: 3, // Units of world space /** Maximum opacity of a star. */ opacity: 0.3, // Default: 0.3 // --- Lifespan --- /** Average lifespan in seconds. */ baseLifespan: 25.0, // baseLifespan: 5.0, /** How much the lifespan can vary from the base. */ lifespanVariance: 5.0, // lifespanVariance: 0.0, // --- Size --- /** The average width of a star in world units. */ baseWidth: 0.5, /** How much the width can vary from the base. */ widthVariance: 0.2, // --- Motion --- /** Average speed in world units per second. */ baseSpeed: 0.2, /** How much the speed can vary from the base. */ speedVariance: 0.1, // --- Pulse Animation --- /** * The average maximum amount a star's size will increase from its base due to the pulse. * DOES NOT decrease the size below baseWidth, only pulses it LARGER. */ basePulseSize: 0.13, /** How much the pulse amplitude can vary. */ pulseSizeVariance: 0.04, /** The average speed of the pulse in radians per second. Higher is faster. */ basePulseSpeed: 1.5, /** How much the pulse speed can vary. */ pulseSpeedVariance: 0.4, // --- Fading --- /** The duration of the fade-in/out at the start/end of a star's life, in seconds. */ fadeDuration: 3.0, // fadeDuration: 0.0, } as const; // Module State ------------------------------------------------------------ /** All star objects. The entire star field. */ const stars: Star[] = []; /** * Whether the star field has been initialized or not. * It will never be initialized if they are disabled. */ let isInitialized: boolean = false; /** * This frame's desired number of stars. * This varies based on your screen area. */ let desiredNumStars: number = 0; // Initialization ----------------------------------------------------------------------- /** Event listener for when we toggle Starfield in the settings dropdown. */ document.addEventListener('starfield-toggle', (e) => { if (!gameloader.areInAGame()) return; // Not in a game => Starfield should not be initiated or terminated. const enabled: boolean = e.detail; if (enabled) init(); else terminate(); }); GameBus.addEventListener('game-unloaded', () => { // Terminate starfield on game unload (can't be in gameloader since that doesn't unload its stuff on a pasted game) terminate(); }); /** * Initializes the starfield system, creating all the star objects. * This must be called once before `update`. */ function init(): void { if (isInitialized) throw Error('Starfield is already initialized.'); if (!couldStarfieldEverBeVisible()) { // console.log("Starfield cannot ever be visible in this game, not initializing."); return; // Starfield cannot be visible in this game } // First, calculate the initial desired number of stars. desiredNumStars = getDesiredNumStars(); // Now populate the field. for (let i = 0; i < desiredNumStars; i++) { const star: Star = createStar(true); stars.push(star); } isInitialized = true; } /** Closes the starfield system, resetting its state. */ function terminate(): void { desiredNumStars = 0; // Clear any existing stars stars.length = 0; isInitialized = false; } /** * Creates a brand new star with random properties. * @param randomizeAge - If true, the star's age will be randomized to a value between 0 and its lifespan. * This is useful for initial population of stars, so they don't all fade in/out near the same time. */ function createStar(randomizeAge: boolean): Star { // Position const screenBox = camera.getScreenBoundingBox(false); // Apply padding screenBox.left -= CONFIG.screenPadding; screenBox.right += CONFIG.screenPadding; screenBox.bottom -= CONFIG.screenPadding; screenBox.top += CONFIG.screenPadding; const width = screenBox.right - screenBox.left; const height = screenBox.top - screenBox.bottom; const position: DoubleCoords = [ Math.random() * width + screenBox.left, Math.random() * height + screenBox.bottom, ]; // Velocity const speed: number = applyVariance(CONFIG.baseSpeed, CONFIG.speedVariance); const angle: number = Math.random() * 2 * Math.PI; const velocity: DoubleCoords = [Math.cos(angle) * speed, Math.sin(angle) * speed]; // Lifespan let newLifespan = applyVariance(CONFIG.baseLifespan, CONFIG.lifespanVariance) * 1000; // Convert to milliseconds if (randomizeAge) newLifespan = Math.random() * newLifespan; return { isLight: Math.random() < 0.5, lifespan: newLifespan, position, velocity, size: Math.max(0.1, applyVariance(CONFIG.baseWidth, CONFIG.widthVariance)), pulseSize: applyVariance(CONFIG.basePulseSize, CONFIG.pulseSizeVariance), pulseSpeed: applyVariance(CONFIG.basePulseSpeed, CONFIG.pulseSpeedVariance), createdAt: performance.now(), }; } /** Calculate's this frames desired number of stars, dependant on your screen area. */ function getDesiredNumStars(): number { const screenBox = camera.getScreenBoundingBox(false); const paddedWidth = screenBox.right - screenBox.left + CONFIG.screenPadding * 2; const paddedHeight = screenBox.top - screenBox.bottom + CONFIG.screenPadding * 2; const area = paddedWidth * paddedHeight; return Math.round(area * CONFIG.starDensity); } /** * A helper function to apply random variance to a base value. * @param base The central value. * @param variance The maximum amount the value can deviate from the base. * @returns A randomized value. */ function applyVariance(base: number, variance: number): number { return base + (Math.random() - 0.5) * 2 * variance; } // Updating ---------------------------------------------------------------------- /** Updates all stars motion, opacity, pulsing, birth, and death! */ function update(): void { if (!isInitialized) return; // Call for a render this frame if the starfield is visible if (isStarfieldVisible()) { frametracker.onVisualChange(); // console.log("Starfield visible, requesting render."); } // Update the desired number of stars for this frame --- desiredNumStars = getDesiredNumStars(); const deltaTimeSecs = loadbalancer.getDeltaTime(); const now = performance.now(); // Get the current time once. // 1. Update existing stars and handle deaths for (let i = stars.length - 1; i >= 0; i--) { const star = stars[i]!; // Update position and size star.position[0] += star.velocity[0] * deltaTimeSecs; star.position[1] += star.velocity[1] * deltaTimeSecs; // Check for death based on actual elapsed time. const starAge = now - star.createdAt; if (starAge >= star.lifespan) { // A star has died. Check if we should replace it. if (stars.length > desiredNumStars) { // We have too many stars right now, so just remove this one. // This can happen if the user shrinks their window. stars.splice(i, 1); } else { // We need to keep the population up, so replace it with a new one. stars[i] = createStar(false); } } } // 2. Add new stars if we are below the desired count --- // This can happen if the user enlarges their window.``` while (stars.length < desiredNumStars) { // Randomize the age (since we may be creating a lot at once) stars.push(createStar(true)); } } /** * Returns whether the starfield could at ANY point be visible during the current game. * Doesn't care whether Starfield mode could be turned on later, as that is handled by the event listener. */ function couldStarfieldEverBeVisible(): boolean { // If starfield is disabled, it will never be visible. // (The fact it could be toggled on later is handled by the event listener) if (!preferences.getStarfieldMode()) return false; // If we're on desktop, perspective mode can be toggled, so the starfield could be visible. if (docutil.isMouseSupported()) return true; // On mobile... // If voids can be present in the game, the starfield could be visible. const gamefile = gameslot.getGamefile()!; // Will be present since starfield is only initialized when we're in a game if (gamefile.boardsim.existingRawTypes.includes(r.VOID)) return true; // Voids are PRESENT (or can be added in the editor) // If there is a world border, the starfield could be visible. if (gamefile.basegame.gameRules.worldBorder !== undefined) return true; return false; } /** Returns whether there's a good chance the starfield is visible RIGHT NOW. Assuming it's initialized. */ function isStarfieldVisible(): boolean { // If we're in perspective mode, there's a good chance we can // see the sky, which the starfield is visible in. if (perspective.getEnabled()) return true; // 2D Mode... // If voids are present in the game, there's also a good chance // we can see the starfield underneath them. // It would take too much effort to determine if the void mesh // overlaps with the screen, so just assume the're visible. const gamefile = gameslot.getGamefile()!; // Will be present since starfield is only initialized when we're in a game if (boardutil.getPieceCountOfType(gamefile.boardsim.pieces, r.VOID) > 0) return true; // Voids are PRESENT // At this point, if there isn't a world border, we know starfield is NOT visible. if (gamefile.basegame.gameRules.worldBorder === undefined) return false; // There IS a world border... // Last check is whether our screen is entirely contained within the worldBorder box. // If so, the starfield is NOT visible. const screenBox = boardtiles.gboundingBox(false); return !bounds.boxContainsBox(gamefile.basegame.gameRules.worldBorder, screenBox); } // Rendering ---------------------------------------------------------------------- /** Renders the star field. */ function render(): void { const vertexData: number[] = primitives.Quad(-0.5, -0.5, 0.5, 0.5); const instanceData: number[] = []; // Per instance data: Position (2), Color (4), Size (1) const lightTileColor = preferences.getColorOfLightTiles(); const darkTileColor = preferences.getColorOfDarkTiles(); // Convert the fade duration from seconds to milliseconds. const fadeMillis = CONFIG.fadeDuration * 1000; const now = performance.now(); // Get current time once for this frame. stars.forEach((star) => { const age = now - star.createdAt; const timeUntilDeath = star.lifespan - age; // Sinusoidal Pulsing Size Calculation const pulsingCycleSecs = timeUntilDeath / 1000; // Oscillates between 0 and 1 (only increasing size) const sinWave = -0.5 * Math.cos(pulsingCycleSecs * star.pulseSpeed) + 0.5; // The final size is the base size plus the scaled sine wave const currentSize = star.size + sinWave * star.pulseSize; // Fade In/Out Alpha Calculation let fadeInAlpha = CONFIG.opacity; if (age < fadeMillis) fadeInAlpha = (age / fadeMillis) * CONFIG.opacity; let fadeOutAlpha = CONFIG.opacity; if (timeUntilDeath < fadeMillis) fadeOutAlpha = (timeUntilDeath / fadeMillis) * CONFIG.opacity; // Use the minimum of the two alphas. // If a star's lifespan is shorter than 2x fadeDuration, // this will prevent it from reaching full opacity. const currentAlpha = Math.max(0.0, Math.min(fadeInAlpha, fadeOutAlpha)); // Select Color & Combine With Alpha const baseColor = star.isLight ? lightTileColor : darkTileColor; const currentColor: Color = [baseColor[0], baseColor[1], baseColor[2], currentAlpha]; // Push instance data instanceData.push(...star.position, ...currentColor, currentSize); }); perspective.renderWithoutPerspectiveRotations(() => { createRenderable_Instanced_GivenInfo( vertexData, instanceData, ATTRIB_INFO, 'TRIANGLES', 'starfield', ).render(); }); } // Exports ----------------------------------------------------------------------- export default { init, update, render, }; ================================================ FILE: src/client/scripts/esm/game/rendering/text/glyphatlas.ts ================================================ // src/client/scripts/esm/game/rendering/text/glyphatlas.ts /** * This script generates and manages a runtime glyph atlas texture for text rendering. * * The atlas supports all printable ASCII characters (U+0020–U+007E) plus the Unicode * replacement character U+FFFD (displayed when an unsupported character is requested). * * Glyphs are packed into a multi-row atlas with variable column widths so that the * texture remains roughly square (at most 512 × 512 px). * * Each glyph cell is CELL_HEIGHT pixels tall and as wide as the character's measured * advance width (rounded up, with some specified padding on each side to prevent UV bleeding). */ import { gl } from '../webgl.js'; // Types ------------------------------------------------------------------------- /** * UV coordinates and advance-width ratio for a single glyph in the atlas. * All UV values are in [0, 1] where (0, 0) is the bottom-left corner of the texture * (after UNPACK_FLIP_Y_WEBGL is applied during upload). */ interface GlyphMetrics { /** Left UV edge of the glyph cell. */ u0: number; /** Bottom UV edge of the glyph cell (after Y-flip). */ v0: number; /** Right UV edge of the glyph cell. */ u1: number; /** Top UV edge of the glyph cell (after Y-flip). */ v1: number; /** * Advance width relative to CELL_HEIGHT. Multiply by the desired world-space * character height (`size`) to get the world-space quad width for this glyph. */ advanceWidth: number; } // Constants ------------------------------------------------------------------------- /** * Height of every glyph cell in the atlas in pixels. * The font is rendered at {@link FONT_SIZE} px inside this cell. */ const CELL_HEIGHT = 64; /** Font size used when rendering glyphs onto the atlas canvas. */ const FONT_SIZE = Math.round(CELL_HEIGHT * 0.8); const FONT_FAMILY = 'sans-serif'; /** * Fraction of the glyph cell height that lies below the alphabetic baseline. * For typical sans-serif: ascent ≈ 0.8 × FONT_SIZE, descent ≈ 0.2 × FONT_SIZE. * The baseline sits (ascent − descent) / 2 = 0.3 × FONT_SIZE below the em midpoint, * so the fraction of the cell below the baseline is 0.5 − 0.3 × (FONT_SIZE / CELL_HEIGHT). */ const ATLAS_DESCENDER_FRACTION = 0.5 - 0.3 * (FONT_SIZE / CELL_HEIGHT); // ≈ 0.26 /** * Fraction of the glyph cell height from the em-midpoint (drawing y) up to the top of a digit glyph. * For typical sans-serif: cap height ≈ 0.72 × FONT_SIZE; baseline is 0.3 × FONT_SIZE below the midpoint, * so the distance from midpoint to digit top ≈ (0.72 − 0.3) × FONT_SIZE = 0.42 × FONT_SIZE. * As a fraction of CELL_HEIGHT: 0.42 × (FONT_SIZE / CELL_HEIGHT). */ const ATLAS_ASCENT_FRACTION = 0.42 * (FONT_SIZE / CELL_HEIGHT); /** * Horizontal padding (pixels) added on each side of a glyph cell to prevent * UV bleeding between adjacent cells at low resolutions / with mipmaps. */ const CELL_PADDING = 2; /** * Target atlas width in pixels. Must be a power of two. * With ~96 glyphs at an average advance width of ~38 px (+ 2 px padding), * each row holds ≈ 12 glyphs, and all glyphs fit inside a 512 × 512 atlas. */ const ATLAS_WIDTH = 512; /** * The Unicode replacement character (U+FFFD '?'). Rendered whenever * {@link render} encounters a character that is not in the atlas. */ const REPLACEMENT_CHAR = '\uFFFD'; /** * All characters pre-rendered into the atlas. * * Printable ASCII 0x20–0x7E (95 chars) followed by the replacement character * so that every out-of-range character has a visible fallback glyph. */ const SUPPORTED_CHARS: string[] = [ ...Array.from({ length: 95 }, (_, i) => String.fromCharCode(i + 0x20)), REPLACEMENT_CHAR, ]; // Variables ------------------------------------------------------------------------- /** WebGL texture for the glyph atlas. Lazily initialised on first use. Takes ~1 ms. */ let atlasTexture: WebGLTexture | undefined; /** * Per-character metrics table. * Keys are individual characters; values describe where that glyph lives in the atlas. */ let metricsTable: Map | undefined; // Functions ------------------------------------------------------------------------- /** Returns the next integer that is a power of two and ≥ `n`. */ function nextPowerOfTwo(n: number): number { if (n <= 1) return 1; let p = 1; while (p < n) p <<= 1; return p; } /** * Builds the glyph atlas: measures every supported character, packs the glyphs * into rows, draws them onto a Canvas 2D, uploads the result as a WebGL texture, * and populates {@link metricsTable}. */ function initGlyphAtlas(): void { // ── 1. Measure every glyph ────────────────────────────────────────────── const measureCanvas = document.createElement('canvas'); measureCanvas.width = ATLAS_WIDTH; measureCanvas.height = CELL_HEIGHT; const mCtx = measureCanvas.getContext('2d'); if (!mCtx) throw new Error('Could not get 2D context for glyph measurement.'); /** Font string passed to Canvas 2D context. */ const FONT_STRING = `${FONT_SIZE}px ${FONT_FAMILY}`; mCtx.font = FONT_STRING; /** Cell width (px) for each character, including padding on both sides. */ const cellWidths: number[] = SUPPORTED_CHARS.map((ch) => { const measured = mCtx.measureText(ch).width; return Math.ceil(measured) + CELL_PADDING * 2; }); // ── 2. Pack glyphs into rows ───────────────────────────────────────────── interface GlyphPlacement { char: string; /** Pixel X of left edge of cell (including left padding) */ cellX: number; // /** Pixel Y of top edge of cell (row top, canvas-space, y-down) */ cellY: number; cellWidth: number; } const placements: GlyphPlacement[] = []; let cursorX = 0; let cursorY = 0; let numRows = 1; for (let i = 0; i < SUPPORTED_CHARS.length; i++) { const cw = cellWidths[i]!; if (cursorX + cw > ATLAS_WIDTH) { // Start a new row. cursorX = 0; cursorY += CELL_HEIGHT; numRows++; } placements.push({ char: SUPPORTED_CHARS[i]!, cellX: cursorX, cellY: cursorY, cellWidth: cw, }); cursorX += cw; } const atlasHeight = nextPowerOfTwo(numRows * CELL_HEIGHT); // ── 3. Draw all glyphs onto the atlas canvas ───────────────────────────── const atlasCanvas = document.createElement('canvas'); atlasCanvas.width = ATLAS_WIDTH; atlasCanvas.height = atlasHeight; const ctx = atlasCanvas.getContext('2d'); if (!ctx) throw new Error('Could not get 2D context for glyph atlas.'); ctx.clearRect(0, 0, ATLAS_WIDTH, atlasHeight); ctx.fillStyle = 'white'; ctx.textBaseline = 'middle'; ctx.font = FONT_STRING; // Build the metrics table while drawing. const table = new Map(); for (const p of placements) { // Draw glyph centred within its cell (excluding padding). const drawX = p.cellX + CELL_PADDING; const drawY = p.cellY + CELL_HEIGHT / 2; ctx.fillText(p.char, drawX, drawY); // UV coordinates: (0,0) = bottom-left after UNPACK_FLIP_Y_WEBGL. // Canvas Y increases downward; flipping maps canvasY → (atlasHeight - canvasY). // Inset by CELL_PADDING so UVs reference only the inner glyph pixels, not the padding border. const u0 = (p.cellX + CELL_PADDING) / ATLAS_WIDTH; const u1 = (p.cellX + p.cellWidth - CELL_PADDING) / ATLAS_WIDTH; // Cell top in flipped space is the larger V value. const v0 = (atlasHeight - (p.cellY + CELL_HEIGHT)) / atlasHeight; const v1 = (atlasHeight - p.cellY) / atlasHeight; // advanceWidth is the inner glyph width (without padding) relative to cell height. const innerWidth = p.cellWidth - CELL_PADDING * 2; const advanceWidth = innerWidth / CELL_HEIGHT; table.set(p.char, { u0, v0, u1, v1, advanceWidth }); } // ── 4. Upload to GPU ───────────────────────────────────────────────────── const texture = gl.createTexture(); if (!texture) throw new Error('Failed to create glyph atlas WebGL texture.'); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas); gl.generateMipmap(gl.TEXTURE_2D); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // CLAMP_TO_EDGE prevents UV bleeding at the atlas borders. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); atlasTexture = texture; metricsTable = table; // DEBUG: Uncomment to log atlas dimensions and append the canvas to the document for visual inspection. // console.log( // `[glyphatlas] Atlas generated: ${ATLAS_WIDTH} × ${atlasHeight} px, ${numRows} row(s), ${SUPPORTED_CHARS.length} glyphs.`, // ); // atlasCanvas.style.cssText = // 'position:fixed;bottom:0;right:0;background:#888;z-index:9999;border:2px solid red;'; // document.body.appendChild(atlasCanvas); } // API ------------------------------------------------------------------------- /** * Returns the WebGL texture of the glyph atlas. * * Lazily initialises the atlas on first call, which takes ~1 ms. */ function getAtlasTexture(): WebGLTexture { if (atlasTexture === undefined) initGlyphAtlas(); return atlasTexture!; } /** * Returns the {@link GlyphMetrics} for `char`, or the replacement * character U+FFFD if the character is not present in the atlas. * * Lazily initialises the atlas on first call. */ function getGlyphMetrics(char: string): GlyphMetrics { if (metricsTable === undefined) initGlyphAtlas(); return metricsTable!.get(char) ?? metricsTable!.get(REPLACEMENT_CHAR)!; // fallback to replacement char for unsupported glyphs } // Exports ------------------------------------------------------------------------- export { getAtlasTexture, getGlyphMetrics, ATLAS_DESCENDER_FRACTION, ATLAS_ASCENT_FRACTION }; ================================================ FILE: src/client/scripts/esm/game/rendering/text/textrenderer.ts ================================================ // src/client/scripts/esm/game/rendering/text/textrenderer.ts /** * This script renders arbitrary strings in world space. * * Each character is rendered as a textured quad whose height equals `size` * world-space units and whose width is `size × advanceWidth` — where * `advanceWidth` is the per-glyph ratio measured at atlas-generation time. */ import type { Color } from '../../../../../../shared/util/math/math.js'; import type { DoubleCoords } from '../../../../../../shared/chess/util/coordutil.js'; import type { DoubleBoundingBox } from '../../../../../../shared/util/math/bounds.js'; import primitives from '../primitives.js'; import { createRenderable } from '../../../webgl/Renderable.js'; import { getAtlasTexture, getGlyphMetrics, ATLAS_ASCENT_FRACTION, ATLAS_DESCENDER_FRACTION, } from './glyphatlas.js'; // Functions ------------------------------------------------------------------------- /** * Computes the total world-space width of `text` when rendered at the given `size`. * Unsupported characters are treated as if they were the replacement character. */ function getTextWidth(text: string, size: number): number { let width = 0; for (const char of text) { const m = getGlyphMetrics(char); width += size * m.advanceWidth; } return width; } /** * Computes the world-space axis-aligned bounding box of `text` when rendered at the given parameters. * The bottom edge is at the alphabetic baseline rather than the bottom of the cell, * so the invisible descender space below the baseline is excluded. * @param text - The string to measure. * @param coords - World-space [x, y] of the anchor point, positioned according to `align`. * @param size - World-space height of each character. * @param align - Horizontal alignment relative to `coords[0]`. */ function getTextBounds( text: string, coords: DoubleCoords, size: number, align: 'left' | 'center' | 'right', ): DoubleBoundingBox { const totalWidth = getTextWidth(text, size); let left: number; if (align === 'left') left = coords[0]; else if (align === 'center') left = coords[0] - totalWidth / 2; else left = coords[0] - totalWidth; // 'right' return { left, right: left + totalWidth, // Exclude the descender space: bottom is the alphabetic baseline, not the cell bottom. bottom: coords[1] - size * (0.5 - ATLAS_DESCENDER_FRACTION), // Use the measured cap height of a digit so the top aligns with the visible top of numbers. top: coords[1] + size * ATLAS_ASCENT_FRACTION, }; } /** * Renders a text string. * @param text - The string to render. * @param coords - World-space [x, y] of the anchor point. * `x` is positioned according to `align`; `y` is the vertical centre. * @param size - World-space height of each character. * @param color - RGBA tint applied to all characters. * @param align - Horizontal alignment relative to `coords[0]`. */ function render( text: string, coords: DoubleCoords, size: number, color: Color, align: 'left' | 'center' | 'right', ): void { if (text.length === 0) return; const totalWidth = getTextWidth(text, size); // Compute world-space X of the left edge of the first character. let cursorX: number; if (align === 'left') cursorX = coords[0]; else if (align === 'center') cursorX = coords[0] - totalWidth / 2; else cursorX = coords[0] - totalWidth; // 'right' // Vertical extents are constant for all glyphs (text is vertically centred on y). const bottom = coords[1] - size / 2; const top = coords[1] + size / 2; const data: number[] = []; for (const char of text) { const m = getGlyphMetrics(char); const quadWidth = size * m.advanceWidth; const left = cursorX; const right = cursorX + quadWidth; data.push( // prettier-ignore ...primitives.Quad_ColorTexture(left, bottom, right, top, m.u0, m.v0, m.u1, m.v1, ...color), ); cursorX += quadWidth; } createRenderable(data, 2, 'TRIANGLES', 'colorTexture', true, getAtlasTexture()).render(); } // Exports ------------------------------------------------------------------------- export default { getTextWidth, getTextBounds, render }; ================================================ FILE: src/client/scripts/esm/game/rendering/transitions/Transition.ts ================================================ // src/client/scripts/esm/game/rendering/transitions/Transition.ts /** * This handles the smooth transitioning from one area of the board to another. * * There are two types of transitions: * * Panning Transition - Quicker, doesn't zoom at all, teleports at the halfway t value so it can * span arbitrary distances in constant time. * * Zooming Transition - Slower. For varying differences in scale, it uses different * models with varying stages. The goal is to perform the entire transition * within a constant duration, while still feeling smooth and natural. */ import type { BoundingBox, BoundingBoxBD } from '../../../../../../shared/util/math/bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import math from '../../../../../../shared/util/math/math.js'; import coordutil, { BDCoords, Coords, DoubleCoords, } from '../../../../../../shared/chess/util/coordutil.js'; import space from '../../misc/space.js'; import meshes from '../meshes.js'; import boardpos from '../boardpos.js'; import boarddrag from '../boarddrag.js'; import boardtiles from '../boardtiles.js'; import perspective from '../perspective.js'; import { GameBus } from '../../GameBus.js'; import preferences from '../../../components/header/preferences.js'; import area, { Area } from '../area.js'; // Types --------------------------------------------------------------------------------- /** Main Transition type. Either Zooming OR Panning. */ type Transition = | (ZoomTransition & { /** Whether this is a Zooming Transition, vs a Panning one. Panning transitions don't need a destination scale. */ isZoom: true; }) | (PanTransition & { isZoom: false; }); export type ZoomTransition = { /** The destination board location. */ destinationCoords: BDCoords; /** The destination board location. */ destinationScale: BigDecimal; }; type PanTransition = { /** The destination board location. */ destinationCoords: BDCoords; }; // Constants ---------------------------------------------------------------------- /** The maximum number of transitions we will retain in our history, for undoing transitions. */ const HISTORY_CAP = 20; /** Stores config for Panning Transitions. */ const PAN_TRANSITION_CONFIG = { /** Duration of ALL Panning Transitions. */ get DURATION_MILLIS() { return preferences.getFastTransitionsMode() ? 500 : 800; }, /** * The maximum distance a Panning Transition will travel before * teleporting mid-transition to reach its destination in constant time, * in world space units (not affected by board scale). */ get MAX_PAN_DISTANCE() { return preferences.getFastTransitionsMode() ? 45 : 90; }, } as const; /** Stores config for Zooming Transitions. */ const ZOOM_TRANSITION_CONFIG = { /** The minimum duration any zooming transition must take. */ get MIN_DURATION() { return preferences.getFastTransitionsMode() ? 400 : 600; }, /** The maximum duration any zooming transition can take. */ get MAX_DURATION() { return preferences.getFastTransitionsMode() ? 1200 : 3500; }, /** In perspective mode we apply a multiplier so the transition goes a tad slower. */ DURATION_PERSPECTIVE_MULTIPLIER: 1.3, /** The "comfortable" acceleration used for the start and end of the 2 & 3 stage models. */ get EDGE_ACCELERATION() { return preferences.getFastTransitionsMode() ? 80.0 : 40.0; }, /** How the total duration of the 3-Stage Model is split between them. MUST sum to 1.0. */ STAGE_SPLIT: { ACCELERATE: 0.25, // 25% of time accelerating scale CRUISE: 0.5, // 50% arbitrarily fast scale change DECELERATE: 0.25, // 25% decelerating scale }, } as const; const ONE = bd.fromBigInt(1n); const NEGONE = bd.fromBigInt(-1n); // Variables ---------------------------------------------------------------------- const teleportHistory: Transition[] = []; // State -------------------------------------------------------------------------- // The state of the current transition /** Whether we're currently transitioning. */ let isTransitioning: boolean = false; /** * If defined, then after the current transition is * finished, we should immediately start this transition. * * This should be defined for transitions which first require us to * zoom out to fit everything on screen before zooming back into them. */ let nextTransition: ZoomTransition | undefined; /** Precalculated total duration of the current transition. */ let durationMillis: number; let startTime: number; /** Whether the current transition is a Zooming Transition, vs a Panning Transition. */ let isZoom: boolean; /** * If the current transition is a Zooming Transition, this is whether * the destination scale requires us to zoom out to get there. */ let isZoomOut: boolean; // Shared State /** The origin/start coords of the current transition. */ let originCoords: BDCoords; /** The destination coords. */ let destinationCoords: BDCoords; /** The origin/start scale of the current transition. */ let originScale: BigDecimal; /** The destination scale. */ let destinationScale: BigDecimal; /** The logarithm of the origin scale. */ let originE: number; /** The logarithm of the destination scale. */ let destinationE: number; /** Precalculated difference between the current transition's origin and destination scale's "e" value. */ let differenceE: number; // Pan-specific State /** * If the current transition is a Panning Transition, this is the precalculated * difference between the current transition's origin and destination coords. */ let differenceCoords: BDCoords; // Zoom-specific State, pre-calculated /** [ESTIMATION] If the current transition is a Zooming Transition, this is the origin world space coords. */ let originWorldSpace: DoubleCoords; /** If the current transition is a Zooming Transition, this is the destination world space coords. */ let destinationWorldSpace: DoubleCoords; /** Precalculated difference between the current transition's calculated origin and destination world space coords. */ let differenceWorldSpace: DoubleCoords; /** * Which kinematic model to use for the current long zoom transition. * * - C_INF: C-infinity, 1-stage model (shortest duration, smoothest). * Used if its natural duration fits within the cap transition duration. * * - C_ONE_2_STAGE: C¹, velocity-continuous, 2-stage model. * Used if C_INF would take too long (4e36), but this model fits within the cap duration. * Without this fallback model, C_ONE_3_STAGE at specific zooms would have to * accelerate, decelerate, accelerate, then decelerate again, which feels bad. * * - C_ONE_3_STAGE: C¹, velocity-continuous, 3-stage model with fixed duration. * Used if both other models would take too long (4e54). * Compresses the potentially arbitrarily large scale difference into stage 2. */ let zoomModel: 'C_INF' | 'C_ONE_2_STAGE' | 'C_ONE_3_STAGE'; let stageEndTimes: { stage1: number; stage2: number; stage3: number }; // C-infinity model state let initial_accel_c_inf: number; let jerk_c_inf: number; // C¹ models state let accel_stage1: number; let accel_stage2: number; let e_at_stage1_end: number; let v_at_stage1_end: number; let e_at_stage2_mid: number; let v_at_stage2_mid: number; let e_at_stage2_end: number; let v_at_stage2_end: number; // Events --------------------------------------------------------------------------- GameBus.addEventListener('game-unloaded', () => { eraseTelHist(); }); // Initiating Transitions --------------------------------------------------------------------- /** Sets common variables between starting either a Zooming or Panning Transition. */ function onTransitionStart(): void { isTransitioning = true; startTime = Date.now(); originCoords = boardpos.getBoardPos(); originScale = boardpos.getBoardScale(); boardpos.eraseMomentum(); // Reset velocities to zero boarddrag.cancelBoardDrag(); // We don't want to allow dragging during a transition. } /** Starts a Zooming Transition. */ function startZoomTransition( tel1: ZoomTransition, tel2: ZoomTransition | undefined, ignoreHistory: boolean, ): void { onTransitionStart(); nextTransition = tel2; destinationCoords = tel1.destinationCoords; destinationScale = tel1.destinationScale; originE = bd.ln(originScale); // We're using base E destinationE = bd.ln(destinationScale); differenceE = destinationE - originE; isZoom = true; isZoomOut = bd.compare(destinationScale, originScale) < 0; // Determine world coordinates if (isZoomOut) { originWorldSpace = [0, 0]; destinationWorldSpace = space.convertCoordToWorldSpace( originCoords, destinationCoords, destinationScale, ); } else { // Is a zoom-in originWorldSpace = space.convertCoordToWorldSpace(destinationCoords); destinationWorldSpace = [0, 0]; } differenceWorldSpace = coordutil.subtractDoubleCoords(destinationWorldSpace, originWorldSpace); // Perspective duration multiplier const perspectiveMultiplier = perspective.getEnabled() ? ZOOM_TRANSITION_CONFIG.DURATION_PERSPECTIVE_MULTIPLIER : 1; const maxDuration = ZOOM_TRANSITION_CONFIG.MAX_DURATION * perspectiveMultiplier; const edgeAccel = ZOOM_TRANSITION_CONFIG.EDGE_ACCELERATION / perspectiveMultiplier; // Determine which model to use by checking each profile's // natural duration (excludes base duration or capping) in order. // C-infinity model natural duration if capped at our comfortable EDGE_ACCELERATION. const natural_duration_c_inf_millis = Math.sqrt(Math.abs((6 * differenceE) / edgeAccel)) * 1000; // C¹ 2-stage model natural duration, if capped at our comfortable EDGE_ACCELERATION. const natural_duration_c_one_millis = Math.sqrt(Math.abs(differenceE / edgeAccel)) * 2 * 1000; if (natural_duration_c_inf_millis <= maxDuration) setupCInfinityModel(natural_duration_c_inf_millis, maxDuration); else if (natural_duration_c_one_millis <= maxDuration) setupCOne2StageModel(natural_duration_c_one_millis, edgeAccel); else setupCOne3StageModel(edgeAccel, maxDuration); // Both other models would take too long. Use the fixed-duration 3-stage profile. // console.log("Duration: " + durationMillis + "ms"); if (!ignoreHistory) pushToTelHistory({ isZoom, destinationCoords: boardpos.getBoardPos(), destinationScale: boardpos.getBoardScale(), }); } /** Sets up the C-Infinity 1-Stage Model for the current zoom transition. */ function setupCInfinityModel(natural_duration_c_inf_millis: number, maxDuration: number): void { // console.log('Using C-Infinity 1-Stage Model'); zoomModel = 'C_INF'; // Add the base duration to the natural duration, and cap at the long zoom duration. durationMillis = Math.max(ZOOM_TRANSITION_CONFIG.MIN_DURATION, natural_duration_c_inf_millis); durationMillis = Math.min(durationMillis, maxDuration); const T = durationMillis / 1000; // Final duration in seconds // Based on this final duration, solve for the required initial acceleration and jerk. if (T > 0) { initial_accel_c_inf = (6 * differenceE) / (T * T); jerk_c_inf = (-2 * initial_accel_c_inf) / T; // Jerk is constant throughout } else { initial_accel_c_inf = 0; jerk_c_inf = 0; } } /** Sets up the C¹ 2-Stage Model for the current zoom transition. */ function setupCOne2StageModel(natural_duration_c_one_millis: number, edgeAccel: number): void { // --- CASE B: C¹ 2-STAGE MODEL (Velocity Continuous) --- // console.log('Using C¹ 2-Stage Model'); zoomModel = 'C_ONE_2_STAGE'; durationMillis = natural_duration_c_one_millis; accel_stage1 = Math.sign(differenceE) * edgeAccel; const t_half_secs = durationMillis / 2000; stageEndTimes = { stage1: t_half_secs * 1000, // Not used, but set for consistency stage2: durationMillis, stage3: durationMillis, }; // Pre-calculate boundary conditions for the handoff. v_at_stage1_end = accel_stage1 * t_half_secs; e_at_stage1_end = originE + 0.5 * accel_stage1 * t_half_secs * t_half_secs; } /** Sets up the C¹ 3-Stage Model for the current zoom transition. */ function setupCOne3StageModel(edgeAccel: number, maxDuration: number): void { // --- CASE C: 3-STAGE MODEL --- // console.log('Using C¹ 3-Stage Model'); zoomModel = 'C_ONE_3_STAGE'; durationMillis = maxDuration; const t1 = (durationMillis * ZOOM_TRANSITION_CONFIG.STAGE_SPLIT.ACCELERATE) / 1000; const t2 = (durationMillis * ZOOM_TRANSITION_CONFIG.STAGE_SPLIT.CRUISE) / 1000; const t_s2_half = t2 / 2; stageEndTimes = { stage1: t1 * 1000, stage2: (t1 + t2) * 1000, stage3: durationMillis, }; // Set Stage 1 acceleration and determine the distance it covers. // The direction of acceleration depends on the direction of the zoom. accel_stage1 = Math.sign(differenceE) * edgeAccel; // Distance covered in Stage 1 & 3 is determined by the fixed edge acceleration. // Using d = v₀t + 0.5at², where v₀=0 for stage 1. Stage 3 is symmetrical. const dist_stage1_and_3 = accel_stage1 * t1 * t1; // Calculate the remaining distance that must be covered in Stage 2. const remaining_dist = differenceE - dist_stage1_and_3; // Solve for the Stage 2 acceleration needed to cover that remaining distance. // We use the formula: d = v₀t + 0.5at² // For the first half of stage 2, v₀ is the velocity at the end of stage 1. v_at_stage1_end = accel_stage1 * t1; // The distance for the first half of stage 2 is remaining_dist / 2. // (remaining_dist / 2) = v_at_stage1_end * t_s2_half + 0.5 * a₂ * t_s2_half² // Rearranging to solve for a₂: accel_stage2 = (remaining_dist - 2 * v_at_stage1_end * t_s2_half) / (t_s2_half * t_s2_half); const edgeAccelPositive = Math.sign(differenceE) === 1; if ((edgeAccelPositive && accel_stage2 < 0) || (!edgeAccelPositive && accel_stage2 > 0)) { console.warn('Calculated stage 2 acceleration has the wrong sign: ' + accel_stage2); } // Pre-calculate all boundary conditions to use in the update loop. e_at_stage1_end = originE + 0.5 * dist_stage1_and_3; v_at_stage2_mid = v_at_stage1_end + accel_stage2 * t_s2_half; e_at_stage2_mid = e_at_stage1_end + v_at_stage1_end * t_s2_half + 0.5 * accel_stage2 * t_s2_half * t_s2_half; // By symmetry of the C¹ model within Stage 2, velocity at the end is guaranteed to match velocity at the start. v_at_stage2_end = v_at_stage1_end; e_at_stage2_end = e_at_stage1_end + remaining_dist; // By definition } /** Starts a Panning Transition. */ function startPanTransition(endCoord: BDCoords, ignoreHistory: boolean): void { onTransitionStart(); destinationCoords = endCoord; differenceCoords = coordutil.subtractBDCoords(destinationCoords, originCoords); destinationScale = originScale; isZoom = false; durationMillis = PAN_TRANSITION_CONFIG.DURATION_MILLIS; if (!ignoreHistory) pushToTelHistory({ isZoom, destinationCoords: boardpos.getBoardPos() }); } /** * Starts a Zooming Transition to an integer bounding box. * If an intermediate zoom-out is needed first, it will be done. */ function zoomToCoordsBox(box: BoundingBox): void { const boxFloating = meshes.expandTileBoundingBoxToEncompassWholeSquare(box); const thisArea = area.calculateFromUnpaddedBox(boxFloating); area.initTransitionFromArea(thisArea, false); } /** * Starts a Zooming Transition to a list of coordinates. * Will not incur an intermediate transition if all coords are not on screen originally. */ function singleZoomToCoordsList(coordsList: Coords[]): void { const transitionArea: Area = area.calculateFromCoordsList(coordsList); zoomTransitionToArea(transitionArea); } /** * Starts a Zooming Transition to floating point coords location. * Will not incur an intermediate transition if it is not on screen originally. */ function singleZoomToBDCoords(coords: BDCoords): void { const snapBoundingBox: BoundingBoxBD = { left: coords[0], right: coords[0], bottom: coords[1], top: coords[1], }; const boxFloating: BoundingBoxBD = meshes.expandTileBoundingBoxToEncompassWholeSquareBD(snapBoundingBox); const transitionArea: Area = area.calculateFromUnpaddedBox(boxFloating); zoomTransitionToArea(transitionArea); } /** * Starts a Zooming Transition to a predefined Area. * * Will not incur a following transition if the area is not on screen. */ function zoomTransitionToArea(theArea: Area): void { const trans: ZoomTransition = { destinationCoords: theArea.coords, destinationScale: theArea.scale, }; startZoomTransition(trans, undefined, false); } /** Appends the given transition to the history. */ function pushToTelHistory(trans: Transition): void { teleportHistory.push(trans); if (teleportHistory.length > HISTORY_CAP) teleportHistory.shift(); // Trim excess } /** Undos the last transition by transitioning to that transition's */ function undoTransition(): void { const previousTrans = teleportHistory.pop(); if (!previousTrans) return; // Nothing in history if (previousTrans.isZoom) { // Zooming Transition const thisArea: Area = { coords: previousTrans.destinationCoords, scale: previousTrans.destinationScale, boundingBox: boardtiles.getBoundingBoxOfBoard( previousTrans.destinationCoords, previousTrans.destinationScale, ), }; area.initTransitionFromArea(thisArea, true); } else { // Panning transition startPanTransition(previousTrans.destinationCoords, true); } } // Updating -------------------------------------------------------------------------------------- /** If we are currently transitioning, this updates the board position and scale. */ function update(): void { if (!isTransitioning) return; // Not transitioning const elapsedTime = Date.now() - startTime; if (elapsedTime >= durationMillis) { finishTransition(); return; } if (isZoom) { // Zooming Transition updateZoomingTransition(elapsedTime); } else { // Panning Transition const t = elapsedTime / durationMillis; // 0-1 elapsed time (t) value const easedT = math.easeInOut(t); updatePanningTransition(t, easedT, originCoords, destinationCoords, differenceCoords); } } /** * Handles the kinematic update logic for all zoom transitions. */ function updateZoomingTransition(elapsedTime: number): void { const t_sec = elapsedTime / 1000; let currentE: number; if (zoomModel === 'C_INF') currentE = updateCInfinityTransition(t_sec); else if (zoomModel === 'C_ONE_2_STAGE') currentE = updateCOne2StageTransition(t_sec, elapsedTime); else currentE = updateCOne3StageTransition(t_sec, elapsedTime); applyZoomState(currentE, elapsedTime); } /** Calculates the current "e" value for the current C-Infinity 1-Stage Model zoom transition. */ function updateCInfinityTransition(t_sec: number): number { // Position with constant jerk is given by the cubic formula: // e(t) = e₀ + v₀t + 0.5a₀t² + (1/6)jt³ // Since e₀ and v₀ are 0 relative to the start: return ( originE + 0.5 * initial_accel_c_inf * t_sec * t_sec + (1 / 6) * jerk_c_inf * t_sec * t_sec * t_sec ); } /** Calculates the current "e" value for the current C¹ 2-Stage Model zoom transition. */ function updateCOne2StageTransition(t_sec: number, elapsedTime: number): number { if (elapsedTime <= stageEndTimes.stage1) { // Stage 1: Accelerate const t = t_sec; return originE + 0.5 * accel_stage1 * t * t; } else { // Stage 2: Symmetrical Decelerate const t_s2 = t_sec - stageEndTimes.stage1 / 1000; return e_at_stage1_end + v_at_stage1_end * t_s2 - 0.5 * accel_stage1 * t_s2 * t_s2; } } /** Calculates the current "e" value for the current C¹ 3-Stage Model zoom transition. */ function updateCOne3StageTransition(t_sec: number, elapsedTime: number): number { if (elapsedTime <= stageEndTimes.stage1) { // STAGE 1: Constant positive acceleration // console.log("Stage 1"); const t = t_sec; return originE + 0.5 * accel_stage1 * t * t; } else if (elapsedTime <= stageEndTimes.stage2) { // STAGE 2: Higher acceleration, then symmetrical deceleration. // console.log("Stage 2"); const t_s2 = t_sec - stageEndTimes.stage1 / 1000; const t_s2_half = (stageEndTimes.stage2 - stageEndTimes.stage1) / 2000; if (t_s2 <= t_s2_half) { // First half of Stage 2: Constant acceleration return e_at_stage1_end + v_at_stage1_end * t_s2 + 0.5 * accel_stage2 * t_s2 * t_s2; } else { // Second half of Stage 2: Symmetrical constant deceleration const t_s2_b = t_s2 - t_s2_half; // Time into the second half return ( e_at_stage2_mid + v_at_stage2_mid * t_s2_b - 0.5 * accel_stage2 * t_s2_b * t_s2_b ); } } else { // STAGE 3: Constant negative acceleration (symmetrical to stage 1) // console.log("Stage 3"); const t_s3 = t_sec - stageEndTimes.stage2 / 1000; return e_at_stage2_end + v_at_stage2_end * t_s3 - 0.5 * accel_stage1 * t_s3 * t_s3; } } /** Applies the current board scale and position based on the given "e" value and focus point. */ function applyZoomState(currentE: number, elapsedTime: number): void { // This focus point location logic is identical for all models. const focus: BDCoords = isZoomOut ? originCoords : destinationCoords; let scaleProgress = 0; if (differenceE !== 0) { // Normal case: Tie focus point progress to the scale's kinematic progress. scaleProgress = (currentE - originE) / differenceE; } else { // If there is no scale change, this is a pure pan. // Tie focus point progress to time instead. // Fixes a bug where zooms with equal start and end scale don't pan the position. const t = elapsedTime / durationMillis; scaleProgress = math.easeInOut(t); // Use a standard ease-in-out for the pan. } // Apply the final scale and position to the board. const newScale = bd.exp(bd.fromNumber(currentE)); boardpos.setBoardScale(newScale); // Calculate and set the new board position, based on where the focus point should be. // SEE GRAPH ON DESMOS "World-space converted to boardPos" for my notes while writing this algorithm const worldX = bd.fromNumber(originWorldSpace[0] + differenceWorldSpace[0] * scaleProgress); const worldY = bd.fromNumber(originWorldSpace[1] + differenceWorldSpace[1] * scaleProgress); // Convert the world-space offset to a board-space offset const shiftX = bd.divideFloating(worldX, newScale); const shiftY = bd.divideFloating(worldY, newScale); // Apply the shift to the target coordinates to get the new board position const newX = bd.subtract(focus[0], shiftX); const newY = bd.subtract(focus[1], shiftY); boardpos.setBoardPos([newX, newY]); } /** Updates the board position and scale for the current PANNING Transition. */ function updatePanningTransition( t: number, easedT: number, originCoords: BDCoords, destinationCoords: BDCoords, differenceCoords: BDCoords, ): void { // What is the scale? // What is the maximum distance we should pan b4 teleporting to the other half? const boardScale = boardpos.getBoardScale(); const maxPanDist = bd.fromNumber(PAN_TRANSITION_CONFIG.MAX_PAN_DISTANCE); const maxDistSquares = bd.divideFloating(maxPanDist, boardScale); const transGreaterThanMaxDist = bd.compare(bd.abs(differenceCoords[0]), maxDistSquares) > 0 || bd.compare(bd.abs(differenceCoords[1]), maxDistSquares) > 0; let newX: BigDecimal; let newY: BigDecimal; const difference = coordutil.copyBDCoords(differenceCoords); const easedTBD = bd.fromNumber(easedT); if (!transGreaterThanMaxDist) { // No mid-transition teleport required to maintain constant duration. // Calculate new world-space const addX = bd.multiply(difference[0], easedTBD); const addY = bd.multiply(difference[1], easedTBD); // Convert to board position newX = bd.add(originCoords[0], addX); newY = bd.add(originCoords[1], addY); } else { // Mid-transition teleport REQUIRED to maintain constant duration. // 1st half or 2nd half? const firstHalf = t < 0.5; const neg = firstHalf ? ONE : NEGONE; const actualEasedT = bd.fromNumber(firstHalf ? easedT : 1 - easedT); // Create a new, shorter vector that points in the exact same direction, // but with a length that is visually manageable on screen. // To preserve the vector's direction, we must scale it based on its largest component. const absDiffX = bd.abs(difference[0]); const absDiffY = bd.abs(difference[1]); const maxComponent = bd.max(absDiffX, absDiffY); const ratio = bd.divideFloating(maxDistSquares, maxComponent); difference[0] = bd.multiplyFloating(difference[0], ratio); difference[1] = bd.multiplyFloating(difference[1], ratio); const target = firstHalf ? originCoords : destinationCoords; const addX = bd.multiplyFloating(bd.multiplyFloating(difference[0], actualEasedT), neg); const addY = bd.multiplyFloating(bd.multiplyFloating(difference[1], actualEasedT), neg); newX = bd.add(target[0], addX); newY = bd.add(target[1], addY); } boardpos.setBoardPos([newX, newY]); } /** Sets the board position & scale to the destination of the current transition, and ends the transition. */ function finishTransition(): void { // Called at the end of a teleport // Set the final coords and scale boardpos.setBoardPos(destinationCoords); boardpos.setBoardScale(destinationScale); if (nextTransition) startZoomTransition(nextTransition, undefined, true); // true to ignore history for the second part of a two-step zoom else isTransitioning = false; } // Utility ------------------------------------------------------------------------------ /** Whether we are currently transitioning. */ function areTransitioning(): boolean { return isTransitioning; } /** Erases teleport history. */ function eraseTelHist(): void { teleportHistory.length = 0; } /** Cancels the current transition. */ function terminate(): void { // Clear current transition state isTransitioning = false; nextTransition = undefined; } // Exports ------------------------------------------------------------------------------ export default { // Initiating Transitions areTransitioning, startZoomTransition, startPanTransition, zoomToCoordsBox, singleZoomToCoordsList, singleZoomToBDCoords, undoTransition, // Updating update, // Utility eraseTelHist, terminate, }; ================================================ FILE: src/client/scripts/esm/game/rendering/webgl.ts ================================================ // src/client/scripts/esm/game/rendering/webgl.ts import type { Vec3 } from '../../../../../shared/util/math/vectors.js'; import camera from './camera.js'; /** * This script stores our global WebGL rendering context, * and other utility methods. */ /** The WebGL rendering context. This is our web-based render engine. */ let gl: WebGL2RenderingContext; // The WebGL context. Is initiated in initGL() /** * The color the screen should be cleared to every frame. * This can be changed to give the sky a different color. */ let clearColor: Vec3 = [0.5, 0.5, 0.5]; // Grey /** * Specifies the condition under which a fragment passes the depth test, * determining whether it should be drawn based on its depth value * relative to the existing depth buffer values. * * By default, we want objects rendered to only be visible if they are closer * (less than) or equal to other objects already rendered this frame. The gl * depth function can be changed throughout the run, but we always reset it * back to this default afterward. * * Accepted values: `NEVER`, `LESS`, `EQUAL`, `LEQUAL`, `GREATER`, `NOTEQUAL`, `GEQUAL`, `ALWAYS` */ const defaultDepthFuncParam = 'LEQUAL'; /** * Whether to cull (skip) rendering back faces. * We can prevent the rasteurizer from calculating pixels on faces facing AWAY from us with backface culling. * * IF WE AREN'T CAREFUL about all vertices going into the same clockwise/counterclockwise * direction, then some objects will be invisible! */ const culling = false; /** * If true, whether a face is determined as a front face depends * on whether it's vertices move in a clockwise direction, otherwise counterclockwise. */ const frontFaceVerticesAreClockwise = true; /** * Sets the color the screen will be cleared to every frame. * * This is useful for changing the sky color. * @param newClearColor - The new clear color: `[r,g,b]` */ function setClearColor(newClearColor: Vec3): void { clearColor = newClearColor; } /** * Initiate the WebGL context. This is our web-based render engine. */ function init(): void { // Without alpha in the options, shading yields incorrect colors! This removes the alpha component of the back buffer. const newContext = camera.canvas.getContext('webgl2', { alpha: false, stencil: true, preserveDrawingBuffer: true, // Reduces likelihood of context lost? }); // Stencil required for masking world border stuff if (!newContext) { // WebGL2 not supported alert(translations.webgl_unsupported); throw new Error('WebGL2 not supported by browser.'); // gl = camera.canvas.getContext('webgl', { alpha: false }); } // if (!gl) { // Init WebGL experimental // console.log("Browser doesn't support WebGL-1, falling back on experiment-webgl."); // gl = camera.canvas.getContext('experimental-webgl', { alpha: false}); // } // if (!gl) { // Experimental also failed to init // alert(translations.webgl_unsupported); // throw new Error("WebGL not supported."); // } gl = newContext; gl.clearDepth(1.0); // Set the clear depth value clearScreen(); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl[defaultDepthFuncParam]); gl.enable(gl.BLEND); toggleNormalBlending(); if (culling) { gl.enable(gl.CULL_FACE); const dir = frontFaceVerticesAreClockwise ? gl.CW : gl.CCW; gl.frontFace(dir); // Specifies what faces are considered front, depending on their vertices direction. gl.cullFace(gl.BACK); // Skip rendering back faces. Alertnatively we could skip rendering FRONT faces. } gl.clearStencil(0); // Good practice, although 0 is the default } /** * Clears color buffer and depth buffers. * Needs to be called every frame. */ function clearScreen(): void { gl.clearColor(...clearColor, 1.0); gl.stencilMask(0xff); // Ensure all stencil bits are writable before clearing. gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); } /** * Toggles normal blending mode. Transparent objects will correctly have * their color shaded onto the color behind them. */ function toggleNormalBlending(): void { // Non-premultiplied alpha blending mode. (Pre-multiplied would be gl.ONE, gl.ONE_MINUS_SRC_ALPHA) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); } /** * Toggles inverse blending mode, which will negate any color currently in the buffer. * * This is useful for rendering crosshairs, because they will appear black on white backgrounds, * and white on black backgrounds. */ function enableBlending_Inverse(): void { gl.blendFunc(gl.ONE_MINUS_DST_COLOR, gl.ZERO); } /** * Executes a function (typically a render function) while the depth function paramter * is `ALWAYS`. Objects will be rendered no matter if they are behind or on top of other objects. * This is useful for preventing tearing when objects are on the same z-level in perspective. */ function executeWithDepthFunc_ALWAYS(func: Function): void { // This prevents tearing when rendering in the same z-level and in perspective. gl.depthFunc(gl.ALWAYS); // Temporary toggle the depth function to ALWAYS. func(); gl.depthFunc(gl[defaultDepthFuncParam]); // Return to the original blending. } /** * Executes a function (typically a render function) while inverse blending is enabled. * Objects rendered will take the opposite color of what's currently in the buffer. * * This is useful for rendering crosshairs, because they will appear black on white backgrounds, * and white on black backgrounds. */ function executeWithInverseBlending(func: Function): void { enableBlending_Inverse(); func(); toggleNormalBlending(); } // /** // * Queries common WebGL context values and logs them to the console. // * Each user device may have different supported values. // */ // function queryWebGLContextInfo() { // // Create a canvas and attempt to get WebGL 2 context, fallback to WebGL 1 if unavailable // const canvas = document.createElement('canvas'); // const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); // WebGL 2 if available, otherwise WebGL 1 // if (!gl) { // console.error('WebGL is not supported in this browser.'); // } else { // console.log(gl instanceof WebGL2RenderingContext ? 'WebGL 2 is supported' : 'WebGL 1 is supported'); // const params = [ // { name: 'MAX_TEXTURE_SIZE', desc: 'Maximum texture size', guaranteed: 64 }, // { name: 'MAX_CUBE_MAP_TEXTURE_SIZE', desc: 'Maximum cube map texture size', guaranteed: 16 }, // { name: 'MAX_RENDERBUFFER_SIZE', desc: 'Maximum renderbuffer size', guaranteed: 1 }, // { name: 'MAX_TEXTURE_IMAGE_UNITS', desc: 'Maximum texture units for fragment shader', guaranteed: 8 }, // { name: 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', desc: 'Maximum texture units for vertex shader', guaranteed: 0 }, // { name: 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', desc: 'Maximum combined texture units', guaranteed: 8 }, // { name: 'MAX_VERTEX_ATTRIBS', desc: 'Maximum vertex attributes', guaranteed: 8 }, // { name: 'MAX_VERTEX_UNIFORM_VECTORS', desc: 'Maximum vertex uniform vectors', guaranteed: 128 }, // { name: 'MAX_FRAGMENT_UNIFORM_VECTORS', desc: 'Maximum fragment uniform vectors', guaranteed: 16 }, // { name: 'MAX_VARYING_VECTORS', desc: 'Maximum varying vectors', guaranteed: 8 }, // { name: 'MAX_VIEWPORT_DIMS', desc: 'Maximum viewport dimensions', guaranteed: [0, 0] }, // { name: 'ALIASED_POINT_SIZE_RANGE', desc: 'Aliased point size range', guaranteed: [1, 1] }, // { name: 'ALIASED_LINE_WIDTH_RANGE', desc: 'Aliased line width range', guaranteed: [1, 1] }, // { name: 'MAX_VERTEX_UNIFORM_COMPONENTS', desc: 'Maximum vertex uniform components', guaranteed: 1024 }, // { name: 'MAX_FRAGMENT_UNIFORM_COMPONENTS', desc: 'Maximum fragment uniform components', guaranteed: 1024 }, // { name: 'MAX_VERTEX_OUTPUT_COMPONENTS', desc: 'Maximum vertex output components', guaranteed: 64 }, // { name: 'MAX_FRAGMENT_INPUT_COMPONENTS', desc: 'Maximum fragment input components', guaranteed: 60 }, // { name: 'MAX_DRAW_BUFFERS', desc: 'Maximum draw buffers', guaranteed: 4 }, // { name: 'MAX_COLOR_ATTACHMENTS', desc: 'Maximum color attachments', guaranteed: 4 }, // { name: 'MAX_SAMPLES', desc: 'Maximum samples', guaranteed: 4 } // ]; // // Output WebGL Context Information // console.log('WebGL Context Information:'); // params.forEach(param => { // try { // const value = gl.getParameter(gl[param.name]); // console.log(`${param.desc}:`, value, `(Guaranteed: ${param.guaranteed})`); // } catch (e) { // console.warn(`Error fetching ${param.name}:`, e.message); // } // }); // } // // Shortened version: // // Create a canvas and attempt to get WebGL 2 context, fallback to WebGL 1 if unavailable // // const canvas = document.createElement('canvas'); // // const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); // WebGL 2 if available, otherwise WebGL 1 // // if (!gl) { // // console.error('WebGL not supported.'); // // } else { // // console.log(gl instanceof WebGL2RenderingContext ? 'WebGL 2' : 'WebGL 1'); // // const params = [ // // { name: 'MAX_TEXTURE_SIZE', guaranteed: 64 }, // // { name: 'MAX_CUBE_MAP_TEXTURE_SIZE', guaranteed: 16 }, // // { name: 'MAX_RENDERBUFFER_SIZE', guaranteed: 1 }, // // { name: 'MAX_TEXTURE_IMAGE_UNITS', guaranteed: 8 }, // // { name: 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', guaranteed: 0 }, // // { name: 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', guaranteed: 8 }, // // { name: 'MAX_VERTEX_ATTRIBS', guaranteed: 8 }, // // { name: 'MAX_VERTEX_UNIFORM_VECTORS', guaranteed: 128 }, // // { name: 'MAX_FRAGMENT_UNIFORM_VECTORS', guaranteed: 16 }, // // { name: 'MAX_VARYING_VECTORS', guaranteed: 8 }, // // { name: 'MAX_VIEWPORT_DIMS', guaranteed: [0, 0] }, // // { name: 'ALIASED_POINT_SIZE_RANGE', guaranteed: [1, 1] }, // // { name: 'ALIASED_LINE_WIDTH_RANGE', guaranteed: [1, 1] }, // // { name: 'MAX_VERTEX_UNIFORM_COMPONENTS', guaranteed: 1024 }, // // { name: 'MAX_FRAGMENT_UNIFORM_COMPONENTS', guaranteed: 1024 }, // // { name: 'MAX_VERTEX_OUTPUT_COMPONENTS', guaranteed: 64 }, // // { name: 'MAX_FRAGMENT_INPUT_COMPONENTS', guaranteed: 60 }, // // { name: 'MAX_DRAW_BUFFERS', guaranteed: 4 }, // // { name: 'MAX_COLOR_ATTACHMENTS', guaranteed: 4 }, // // { name: 'MAX_SAMPLES', guaranteed: 4 } // // ]; // // params.forEach(param => { // // try { // // const value = gl.getParameter(gl[param.name]); // // console.log(`${param.name}: ${value}, G: ${param.guaranteed}`); // // } catch (e) { // // console.warn(`Error on ${param.name}`); // // } // // }); // // } // } /** * Enables depth testing in WebGL. * This will ensure that objects closer to the camera are drawn in front of objects farther away. */ function enableDepthTest(): void { gl.enable(gl.DEPTH_TEST); } /** * Disables depth testing in WebGL. * This will ensure that all objects are drawn regardless of their distance from the camera. * More efficient that setting the depth test condition to gl.ALWAYS */ function disableDepthTest(): void { gl.disable(gl.DEPTH_TEST); } export default { init, clearScreen, executeWithDepthFunc_ALWAYS, executeWithInverseBlending, setClearColor, // queryWebGLContextInfo, enableDepthTest, disableDepthTest, }; export { gl }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketclose.ts ================================================ // src/client/scripts/esm/game/websocket/socketclose.ts /** * Handles websocket close events and reconnection logic. * * Determines the appropriate response to different closure reasons, * including reconnection, timeout, and user notification. */ import wsutil from '../../../../../shared/util/wsutil.js'; import toast from '../gui/toast.js'; import config from '../config.js'; import invites from '../misc/invites.js'; import socketman from './socketman.js'; import socketsubs from './socketsubs.js'; import validatorama from '../../util/validatorama.js'; import socketmessages from './socketmessages.js'; // Constants ------------------------------------------------------------------- /** Time before attempting resub after too many requests. */ const timeToResubAfterTooManyRequestsMillis = 10000; /** Time before attempting resub after message too big. */ const timeToResubAfterMessageTooBigMillis = 5000; // Variables ------------------------------------------------------------------- let inTimeout = false; /** * The last time the server closed our socket connection request because * we were missing a browser-id cookie, in millis since the Unix Epoch. */ let lastTimeWeGotAuthorizationNeededMessage: number | undefined; /** Returns whether we're currently in a rate-limit timeout. */ function isInTimeout(): boolean { return inTimeout; } // Close Handler --------------------------------------------------------------- /** * Called when our open socket fires the 'close' event. * Cancels echo timers and on-reply functions, then handles reconnection * based on the closure reason. * @param event - The 'close' event fired. * @param socketWasDefined - Whether the socket was fully open before closing. */ function onclose(event: CloseEvent, socketWasDefined: boolean): void { if (config.DEV_BUILD) console.log('WebSocket connection closed:', event.code, event.reason); socketmessages.cancelAllEchoTimers(); socketmessages.cancelInactivityTimer(); socketmessages.resetOnreplyFuncs(); const trimmedReason = event.reason.trim(); const notByChoice = wsutil.wasSocketClosureNotByTheirChoice(event.code, trimmedReason); /** * True if we want to show the loading animation. * If closed not by our choice, but with no subscriptions, close the ping meter anyway. */ const detail = notByChoice && !socketsubs.zeroSubs(); document.dispatchEvent(new CustomEvent('socket-closed', { detail })); // Connection closed unexpectedly (network interrupted) or server is down. // We did nothing wrong on our part, it's okay to instantly try to reconnect! // But don't if the connection wasn't fully open or this creates spamming! if (event.code === 1006) { if (socketWasDefined) socketman.resubAll(); return; } switch (trimmedReason) { case 'Connection expired': socketman.resubAll(); break; case 'Connection closed by client': break; case 'Connection closed by client. Renew.': console.log('Closed web socket successfully. Renewing now..'); socketman.resubAll(); break; case 'Unable to identify client IP address': toast.show( `${translations.websocket.unable_to_identify_ip} ${translations.websocket.please_report_bug}`, { error: true, durationMultiplier: 100 }, ); invites.clearIfOnPlayPage(); break; case 'Authentication needed': onAuthenticationNeeded(); break; case 'Logged out': document.dispatchEvent(new CustomEvent('logout')); socketman.resubAll(); break; case 'Too Many Requests. Try again soon.': toast.show(translations.websocket.too_many_requests, { durationMillis: timeToResubAfterTooManyRequestsMillis, }); enterTimeout(timeToResubAfterTooManyRequestsMillis); break; case 'Message Too Big': toast.show( `${translations.websocket.message_too_big} ${translations.websocket.please_report_bug}`, { error: true, durationMultiplier: 3 }, ); enterTimeout(timeToResubAfterMessageTooBigMillis); break; case 'Too Many Sockets': toast.show( `${translations.websocket.too_many_sockets} ${translations.websocket.please_report_bug}`, { error: true, durationMultiplier: 3 }, ); window.setTimeout(() => socketman.resubAll(), timeToResubAfterTooManyRequestsMillis); break; case 'Origin Error': toast.show( `${translations.websocket.origin_error} ${translations.websocket.please_report_bug}`, { error: true, durationMultiplier: 3 }, ); invites.clearIfOnPlayPage(); enterTimeout(timeToResubAfterTooManyRequestsMillis); break; case 'No echo heard': socketman.dispatchLostConnectionCustomEvent(); socketman.resubAll(); break; default: toast.show( `${translations.websocket.connection_closed} "${trimmedReason}". Code: ${event.code}. ${translations.websocket.please_report_bug}`, { error: true, durationMultiplier: 100 }, ); console.error( 'Unknown reason why the WebSocket connection was closed. Not reopening or resubscribing.', ); } } // Timeout Management ---------------------------------------------------------- /** * Enters a rate-limit timeout period during which we won't reconnect. * @param timeMillis - The duration to remain in timeout, in milliseconds. */ function enterTimeout(timeMillis: number): void { if (timeMillis === undefined) return console.error('Cannot enter timeout for an undefined amount of time!'); if (inTimeout) return; inTimeout = true; window.setTimeout(() => leaveTimeout(), timeMillis); invites.clearIfOnPlayPage(); } /** Timeout from sending too many requests is over, try to reconnect. */ function leaveTimeout(): void { inTimeout = false; socketman.resubAll(); } // Authentication Handling ----------------------------------------------------- /** * Called when the server closes our websocket due to missing authentication. * Attempts to refresh the browser-id cookie and reconnect. */ async function onAuthenticationNeeded(): Promise { invites.clearIfOnPlayPage(); // If this is the second time we're getting this message, // that means that cookies aren't working on this browser. const now = Date.now(); if (lastTimeWeGotAuthorizationNeededMessage !== undefined) { const difference = now - lastTimeWeGotAuthorizationNeededMessage; // 24 hours if (difference < 1000 * 60 * 60 * 24) { toast.show(translations.websocket.online_play_disabled); lastTimeWeGotAuthorizationNeededMessage = now; return; } } lastTimeWeGotAuthorizationNeededMessage = now; await validatorama.refreshToken(); socketman.resubAll(); } // Exports ------------------------------------------------------------------- export default { onclose, isInTimeout, }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketman.ts ================================================ // src/client/scripts/esm/game/websocket/socketman.ts /** * Manages the websocket connection lifecycle: opening, closing, * reconnecting, and resubscribing after unexpected disconnections. * Also owns the socket instance and debug toggle. */ import toast from '../gui/toast.js'; import config from '../config.js'; import thread from '../../util/thread.js'; import invites from '../misc/invites.js'; import onlinegame from '../misc/onlinegame/onlinegame.js'; import socketsubs from './socketsubs.js'; import socketclose from './socketclose.js'; import validatorama from '../../util/validatorama.js'; import socketrouter from './socketrouter.js'; // Constants ------------------------------------------------------------------- /** Time to wait for HTTP connection before assuming lost connection. */ const TIME_TO_WAIT_FOR_HTTP_MILLIS = 5000; /** * Delays in milliseconds between reconnection attempts after network loss. * The first element is used before the first attempt (0 = instant), * and the last element repeats indefinitely. */ const RECONNECT_DELAY_MILLIS = [0, 2500, 5000] as const; // Variables ------------------------------------------------------------------- /** The websocket object used to communicate with the server. */ let socket: WebSocket | undefined; /** True if currently attempting to create a socket connection. */ let openingSocket = false; /** * The timeout ID of the timer to display lost connection * if we don't hear back after attempting to open a socket. */ let reqOut: false | number = false; /** * True if we are having trouble connecting. If true, and we reconnect, * we'll display "Reconnected." */ let noConnection = false; /** Enables simulated websocket latency and prints all sent and received messages. */ let DEBUG = false; // Initialization -------------------------------------------------------------- document.addEventListener('connection-lost', () => { // Displays a toast, notifying the user they lost connection. noConnection = true; toast.show(translations.websocket.no_connection, { durationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS, }); }); // Page navigation handling window.addEventListener('pageshow', function (event) { if (event.persisted) { console.log('Page was returned to using the back or forward button.'); resubAll(); } }); // Debug ----------------------------------------------------------------------- /** Returns whether debug mode is enabled. */ function isDebugEnabled(): boolean { return DEBUG; } /** Toggles debug mode on or off, showing a toast notification. */ function toggleDebug(): void { DEBUG = !DEBUG; toast.show(`Toggled websocket latency: ${DEBUG}`); } // Socket Access --------------------------------------------------------------- /** Returns the current websocket instance, or undefined if not connected. */ function getSocket(): WebSocket | undefined { return socket; } // Connection Events ----------------------------------------------------------- /** Dispatches a custom event indicating that websocket connection was lost. */ function dispatchLostConnectionCustomEvent(): void { document.dispatchEvent(new CustomEvent('connection-lost')); } // Socket Lifecycle ------------------------------------------------------------ /** * Repeatedly tries to open a websocket to the server until successful, * unless we are in timeout. Never opens more than one socket at a time. * @returns Whether a socket was successfully opened. */ async function establishSocket(): Promise { if (socketclose.isInTimeout()) return false; while (openingSocket || (socket && socket.readyState !== WebSocket.OPEN)) { if (config.DEV_BUILD) console.log('Waiting for the socket to be established or closed..'); await thread.sleep(100); } if (socket && socket.readyState === WebSocket.OPEN) return true; openingSocket = true; // Await validatorama because it may be refreshing our session cookies await validatorama.waitUntilInitialRequestBack(); let success = false; let attemptIndex = 0; // Always attempt at least once (even with zero subs), then retry while subs exist. do { const cappedAttemptIndex = Math.min(attemptIndex, RECONNECT_DELAY_MILLIS.length - 1); const delay = RECONNECT_DELAY_MILLIS[cappedAttemptIndex]!; if (attemptIndex > 0) { noConnection = true; toast.show(translations.websocket.no_connection, { durationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS, }); invites.clearIfOnPlayPage(); await thread.sleep(delay); } success = await openSocket(); attemptIndex++; } while (!success && !socketsubs.zeroSubs()); if (success && noConnection) toast.show(translations.websocket.reconnected, { durationMillis: 1000 }); noConnection = false; openingSocket = false; return success; } /** * Attempts to open our websocket to the server. * @returns Whether the socket was opened successfully. */ async function openSocket(): Promise { onSocketUpgradeReqLeave(); return new Promise((resolve, _reject) => { let url = `wss://${window.location.hostname}`; if (window.location.port !== '443') url += `:${window.location.port}`; const ws = new WebSocket(url); ws.onopen = () => { onReqBack(); socket = ws; resolve(true); }; ws.onerror = (_event) => { onReqBack(); resolve(false); }; ws.onmessage = (event: MessageEvent) => socketrouter.onmessage(event); ws.onclose = (event: CloseEvent) => { const wasFullyOpen = socket !== undefined; socket = undefined; socketclose.onclose(event, wasFullyOpen); }; }); } /** * Dispatches a socket-opening event and starts a timer * that assumes lost connection if no response arrives. */ function onSocketUpgradeReqLeave(): void { // Dispatches a custom event indicating that a socket connection is being opened. document.dispatchEvent(new CustomEvent('socket-opening')); reqOut = window.setTimeout(() => httpLostConnection(), TIME_TO_WAIT_FOR_HTTP_MILLIS); } /** Cancels the timer that assumes lost connection. */ function onReqBack(): void { if (typeof reqOut !== 'boolean') clearTimeout(reqOut); reqOut = false; } /** Displays "Lost connection" and keeps repeating until we successfully connect. */ function httpLostConnection(): void { noConnection = true; toast.show(translations.websocket.no_connection, { durationMillis: TIME_TO_WAIT_FOR_HTTP_MILLIS, }); reqOut = window.setTimeout(() => httpLostConnection(), TIME_TO_WAIT_FOR_HTTP_MILLIS); } /** Closes the socket. Called when it's no longer in use (no active subscriptions). */ function closeSocket(): void { if (!socket) return; if (socket.readyState !== WebSocket.OPEN) return console.error("Cannot close socket because it's not open! Yet socket is defined."); socket.close(1000, 'Connection closed by client'); } // Resubscription -------------------------------------------------------------- /** * Called when the socket unexpectedly closes. Reopens the socket * and resubscribes to everything that was previously subscribed. */ async function resubAll(): Promise { if (config.DEV_BUILD) console.log('Resubbing all..'); if (socketsubs.zeroSubs()) { noConnection = false; console.log('No subs to sub to.'); return; } else { if (!(await establishSocket())) return; } for (const sub of socketsubs.validSubs) { if (!socketsubs.areSubbedToSub(sub)) continue; switch (sub) { case 'invites': await invites.subscribeToInvites(true); break; case 'game': onlinegame.resyncToGame(); break; default: console.error( `Cannot resub to all subs after an unexpected socket closure with strange sub ${sub}!`, ); } } } // Exports -------------------------------------------------------------------- export default { getSocket, establishSocket, closeSocket, resubAll, toggleDebug, isDebugEnabled, dispatchLostConnectionCustomEvent, }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketmessages.ts ================================================ // src/client/scripts/esm/game/websocket/socketmessages.ts /** * Handles outgoing websocket messages, echo tracking, and on-reply functions. */ import uuid from '../../../../../shared/util/uuid.js'; import wsutil from '../../../../../shared/util/wsutil.js'; import toast from '../gui/toast.js'; import socketman from './socketman.js'; import socketsubs from './socketsubs.js'; // Types ----------------------------------------------------------------------- type MessageID = number; type WebsocketMessageValue = MessageEvent['data']; /** The shape of an outgoing websocket payload sent to the server. */ type OutgoingPayload = { route: string; contents: { action: string; value: WebsocketMessageValue; }; id?: number; }; // Constants ------------------------------------------------------------------- /** Time to wait for echo before assuming disconnection. */ const timeToWaitForEchoMillis = 5000; /** Time the websocket remains open without subscriptions. */ const cushionBeforeAutoCloseMillis = 10000; /** Simulated websocket latency in debug mode. */ const simulatedWebsocketLatencyMillis_Debug = 1000; /** Whether to also print incoming echos in debug mode. */ const alsoPrintIncomingEchos = false; // Variables ------------------------------------------------------------------- /** Echo timers for sent messages awaiting acknowledgement. */ let echoTimers: Record = {}; /** Functions to execute when we get a specific reply back. */ let onreplyFuncs: { [key: MessageID]: Function } = {}; /** The timeout ID that auto-closes the socket when we're not subscribed to anything. */ let timeoutIDToAutoClose: number; /** * The timeout ID for detecting server inactivity. * If no message is received within the expected window, the client * assumes the connection is dead and closes the socket. */ let inactivityTimerID: number | undefined; // Echo Tracking --------------------------------------------------------------- /** * Called when we hear a server echo. Cancels the timer that assumes * disconnection, and updates the ping display. */ function cancelTimerOfMessageID(ID: number): void { const echoTimer = echoTimers[ID]; if (!echoTimer) { console.error('Could not find echo timer for message.'); return; } // Update the Ping meter with the round-trip time const timeTaken = Date.now() - echoTimer.timeSent; document.dispatchEvent(new CustomEvent('ping', { detail: timeTaken })); clearTimeout(echoTimer.timeoutID); delete echoTimers[ID]; } /** * Closes the current websocket when an echo hasn't been heard. * Called a few seconds after not hearing a server echo. */ function renewConnection(messageID: MessageID): void { if (messageID) { delete echoTimers[messageID]; } const socket = socketman.getSocket(); if (!socket) return; console.log( `Renewing connection after we haven't received an echo for ${timeToWaitForEchoMillis} milliseconds...`, ); socketman.dispatchLostConnectionCustomEvent(); socket.close(1000, 'Connection closed by client. Renew.'); } /** * Cancels all timers that assume disconnection. * Called when the socket connection is terminated. */ function cancelAllEchoTimers(): void { for (const echoTimerEntry of Object.values(echoTimers)) { clearTimeout(echoTimerEntry.timeoutID); } echoTimers = {}; } // On-Reply Functions ---------------------------------------------------------- /** * Flags an outgoing message to execute a function when the server replies. * @param messageID - The ID of the outgoing message * @param onreplyFunc - The function to execute on reply */ function scheduleOnreplyFunc(messageID: MessageID, onreplyFunc?: () => void): void { if (!onreplyFunc) return; onreplyFuncs[messageID] = onreplyFunc; } /** * When we receive a message with the `replyto` property, * executes the on-reply function for that sent message. */ function executeOnreplyFunc(id: number | undefined): void { if (id === undefined) return; if (!onreplyFuncs[id]) return; onreplyFuncs[id](); delete onreplyFuncs[id]; } /** Erases all on-reply functions. Called when the socket is terminated. */ function resetOnreplyFuncs(): void { onreplyFuncs = {}; } // Timer Management ------------------------------------------------------------ /** If we have zero subscriptions, resets the timer to auto-close the socket. */ function resetTimerToCloseSocket(): void { clearTimeout(timeoutIDToAutoClose); if (socketsubs.zeroSubs()) { timeoutIDToAutoClose = window.setTimeout( () => socketman.closeSocket(), cushionBeforeAutoCloseMillis, ); } } // Inactivity Detection -------------------------------------------------------- /** * Reschedules the inactivity timer. Called on every incoming message. * If no message is received within a certain time frame, the client * assumes the connection is dead and closes the socket. */ function rescheduleInactivityTimer(): void { cancelInactivityTimer(); if (socketsubs.zeroSubs()) return; inactivityTimerID = window.setTimeout( onInactivityTimeout, wsutil.timeOfInactivityToRenewConnection + timeToWaitForEchoMillis, ); } /** Cancels the inactivity timer. Called when the socket closes. */ function cancelInactivityTimer(): void { if (inactivityTimerID !== undefined) { clearTimeout(inactivityTimerID); inactivityTimerID = undefined; } } /** * Called when no message has been received within the expected time frame. * Closes the socket and dispatches a lost connection event. */ function onInactivityTimeout(): void { inactivityTimerID = undefined; const socket = socketman.getSocket(); if (!socket) return; console.log( `No message received for ${wsutil.timeOfInactivityToRenewConnection + timeToWaitForEchoMillis}ms. Assuming connection lost.`, ); socketman.dispatchLostConnectionCustomEvent(); socket.close(1000, 'Connection closed by client. Renew.'); } // Sending Messages ------------------------------------------------------------ /** * Sends a message to the server with the provided route, action, and values. * @param route - Where the server needs to forward this to. general/invites/game * @param action - What action to take within the route. * @param value - The contents of the message * @param isUserAction - Whether this message is a direct result of a user action. Default: false * @param onreplyFunc - Optional function to execute when we receive the server's response. * @returns *true* if the message was able to send. */ async function send( route: string, action: string, value?: WebsocketMessageValue, isUserAction?: boolean, onreplyFunc?: () => void, ): Promise { if (!(await socketman.establishSocket())) { if (isUserAction) toast.show(translations.websocket.too_many_requests); if (onreplyFunc) onreplyFunc(); return false; } resetTimerToCloseSocket(); let payload: OutgoingPayload; if (action === 'echo') { payload = { route: 'echo', contents: value, }; } else { // Not an echo, attach an ID and expect an echo back. payload = { route, contents: { action, value, }, id: uuid.generateNumbID(10), }; if (socketman.isDebugEnabled()) console.log(`Sending: ${JSON.stringify(payload)}`); // Set a timer to assume disconnection if echo not received echoTimers[payload.id!] = { timeSent: Date.now(), timeoutID: window.setTimeout( () => renewConnection(payload.id!), timeToWaitForEchoMillis, ), }; scheduleOnreplyFunc(payload.id!, onreplyFunc); } const socket = socketman.getSocket(); if (!socket || socket.readyState !== WebSocket.OPEN) return false; // Closed state, can't send message. const stringifiedMessage = JSON.stringify(payload); if (socketman.isDebugEnabled()) { window.setTimeout( () => socket.send(stringifiedMessage), simulatedWebsocketLatencyMillis_Debug, ); } else socket.send(stringifiedMessage); // Send immediately return true; } // Exports -------------------------------------------------------------------- export default { send, cancelTimerOfMessageID, cancelAllEchoTimers, executeOnreplyFunc, resetOnreplyFuncs, rescheduleInactivityTimer, cancelInactivityTimer, alsoPrintIncomingEchos, }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketrouter.ts ================================================ // src/client/scripts/esm/game/websocket/socketrouter.ts /** * Routes incoming websocket messages to the appropriate handler * based on the subscription type. */ import type { GeneralMessage } from './socketschemas.js'; import * as z from 'zod'; import timeutil from '../../../../../shared/util/timeutil.js'; import { GAME_VERSION } from '../../../../../shared/game_version.js'; import toast from '../gui/toast.js'; import invites from '../misc/invites.js'; import socketman from './socketman.js'; import LocalStorage from '../../util/LocalStorage.js'; import socketmessages from './socketmessages.js'; import onlinegamerouter from '../misc/onlinegame/onlinegamerouter.js'; import { MasterSchema } from './socketschemas.js'; // Types ----------------------------------------------------------------------- /** Information about the last hard refresh we attempted. */ type HardRefreshInfo = { timeLastHardRefreshed: number; expectedVersion: string; refreshFailed?: boolean; }; // Routing --------------------------------------------------------------------- /** * Called when we receive an incoming server websocket message. * Validates it with Zod, sends an echo to the server, then routes the message. * @param serverMessage - The incoming server message event. */ function onmessage(serverMessage: MessageEvent): void { let parsedUnvalidatedMessage: any; try { parsedUnvalidatedMessage = JSON.parse(serverMessage.data); } catch (error) { return console.error('Error parsing incoming message as JSON:', error); } // Any incoming message proves the connection is alive. // Reschedule the inactivity timer that detects silent disconnections. socketmessages.rescheduleInactivityTimer(); const zod_result = MasterSchema.safeParse(parsedUnvalidatedMessage); if (!zod_result.success) { toast.show(translations.websocket.malformed_message, { error: true, durationMillis: 100000, }); console.error( 'Received malformed websocket message from the server:', parsedUnvalidatedMessage, ); console.error('Error:', z.prettifyError(zod_result.error)); return; } // Validation was a success! Message contains valid parameters. const message = zod_result.data; if (socketman.isDebugEnabled()) { if (message.route === 'echo') { if (socketmessages.alsoPrintIncomingEchos) console.log(`Incoming message: ${JSON.stringify(message)}`); } else console.log(`Incoming message: ${JSON.stringify(message)}`); } if (message.route === 'echo') return socketmessages.cancelTimerOfMessageID(message.contents); // Handle reply-only messages (no route property). // These exist only to execute on-reply functions. if (message.route === undefined) { // TEMPORARY. TO HELP DEBUG why zod errors are happening all the time on the server! if (message.id === undefined) { console.error( 'Received reply-only message without id field. This should not happen after Zod validation. Message:', JSON.stringify(message), ); } socketmessages.send('general', 'echo', message.id); socketmessages.executeOnreplyFunc(message.replyto); return; } // Not an echo or reply-only... // Send our echo — we always echo every message EXCEPT echos themselves // TEMPORARY. TO HELP DEBUG why zod errors are happening all the time on the server! if (message.id === undefined) { console.error( 'Received routed message without id field. This should not happen after Zod validation. Route:', message.route, 'Message:', JSON.stringify(message), ); } socketmessages.send('general', 'echo', message.id); // Execute any on-reply function socketmessages.executeOnreplyFunc(message.replyto); switch (message.route) { case 'general': ongeneralmessage(message.contents); break; case 'invites': invites.onmessage(message.contents); break; case 'game': onlinegamerouter.routeMessage(message.contents); break; default: console.error( // @ts-ignore `Unknown socket subscription "${message.route}" received from the server!`, ); break; } } /** * Handles incoming messages with route "general". * @param message - The validated general route message contents */ function ongeneralmessage(message: GeneralMessage): void { switch (message.action) { case 'notify': toast.show(message.value); break; case 'notifyerror': toast.show(message.value, { error: true, durationMultiplier: 2 }); break; case 'print': console.log(message.value); break; case 'printerror': console.error(message.value); break; case 'renewconnection': // Server sends this expecting an echo, to verify we're still connected. break; case 'gameversion': if (message.value !== GAME_VERSION) handleHardRefresh(message.value); break; default: // @ts-ignore console.log(`Unknown server action "${message.action}" in general route.`); break; } } /** * Attempts a hard refresh if the server reports a newer game version. * Prevents endless refreshing cycles for browsers that don't support hard refresh. * @param LATEST_GAME_VERSION - The game version the server is currently running. */ function handleHardRefresh(LATEST_GAME_VERSION: string): void { const reloadInfo = { timeLastHardRefreshed: Date.now(), expectedVersion: LATEST_GAME_VERSION, }; const preexistingHardRefreshInfo: HardRefreshInfo = LocalStorage.loadItem('hardrefreshinfo'); if (preexistingHardRefreshInfo?.expectedVersion === LATEST_GAME_VERSION) { if (!preexistingHardRefreshInfo.refreshFailed) console.warn( `location.reload(true) failed to hard refresh. Server version: ${LATEST_GAME_VERSION}. Still running: ${GAME_VERSION}`, ); preexistingHardRefreshInfo.refreshFailed = true; saveInfo(preexistingHardRefreshInfo); return; } saveInfo(reloadInfo); // @ts-expect-error This parameter does indeed exist -> https://developer.mozilla.org/en-US/docs/Web/API/Location/reload location.reload(true); function saveInfo(info: HardRefreshInfo): void { LocalStorage.saveItem('hardrefreshinfo', info, timeutil.getTotalMilliseconds({ hours: 4 })); // I think cloudflare caches scripts for 4 hours } } // Exports -------------------------------------------------------------------- export default { onmessage, }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketschemas.ts ================================================ // src/client/scripts/esm/game/websocket/socketschemas.ts /** * This script defines all Zod schemas for validating incoming server websocket messages. * * All schemas are centralized here to avoid circular dependency issues. * * Schemas are organized by route: general, invites, game, and a master schema * that combines them all together with echo and reply-only message handling. */ import * as z from 'zod'; import typeutil from '../../../../../shared/chess/util/typeutil.js'; import { ClockValuesSchema, DisconnectInfoSchema, GameUpdateMessageSchema, MetaDataSchema, OpponentsMoveMessageSchema, PlayerRatingChangeInfoSchema, RatingSchema, ServerUsernameContainerSchema, TimeControlSchema, } from '../../../../../shared/types.js'; // Common Helper Schemas --------------------------------------------------------------- /** The publicity of a game/invite. */ const PublicitySchema = z.enum(['public', 'private']); // Invite Helper Schemas --------------------------------------------------------------- /** The invite object. NOT an HTML object. */ export type Invite = z.infer; const InviteSchema = z.strictObject({ /** Who owns the invite. */ usernamecontainer: ServerUsernameContainerSchema, /** A unique identifier. */ id: z.string(), /** Used to verify if an invite is your own. */ tag: z.string().optional(), /** The name of the variant. */ variant: z.string(), /** The clock value. */ clock: TimeControlSchema, /** The player color (null = Random). */ color: z.union([typeutil.PlayerSchema, z.literal(null)]), /** Whether the game is public or private. */ publicity: PublicitySchema, /** Whether the game is rated or casual. */ rated: z.enum(['casual', 'rated']), }); // Game Helper Schemas --------------------------------------------------------------- /** Zod schema for the id of an online game. */ const GameIDSchema = z.number().int().nonnegative(); /** * Static information about an online game that is unchanging. * Only needed once, when we originally load the game, not on subsequent updates/resyncs. */ export type ServerGameInfo = z.infer; const ServerGameInfoSchema = z.strictObject({ /** The id of the online game. */ id: GameIDSchema, rated: z.boolean(), publicity: PublicitySchema, playerRatings: typeutil.GenPlayerGroupSchema(RatingSchema), }); /** * The message contents when we receive a server websocket `'joingame'` message. * Contains everything a {@link GameUpdateMessage} would have, and more! * * The extra stuff included here does not need to be specified when we're resyncing to * a game, or receiving a game update, as we already know it. */ export type JoinGameMessage = z.infer; const JoinGameMessageSchema = GameUpdateMessageSchema.extend({ gameInfo: ServerGameInfoSchema, /** The metadata of the game, including the TimeControl, player names, date, etc. */ metadata: MetaDataSchema, youAreColor: typeutil.PlayerSchema, }); // General Schema --------------------------------------------------------------- /** Represents all possible types an incoming 'general' route websocket message contents could be. */ export type GeneralMessage = z.infer; const GeneralSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('notify'), value: z.string() }), z.strictObject({ action: z.literal('notifyerror'), value: z.string() }), z.strictObject({ action: z.literal('print'), value: z.string() }), z.strictObject({ action: z.literal('printerror'), value: z.string() }), z.strictObject({ action: z.literal('renewconnection') }), z.strictObject({ action: z.literal('gameversion'), value: z.string() }), ]); // Invites Schema --------------------------------------------------------------- /** Represents all possible types an incoming 'invites' route websocket message contents could be. */ export type InvitesMessage = z.infer; const InvitesSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('inviteslist'), value: z.strictObject({ invitesList: z.array(InviteSchema), currentGameCount: z.number() }), }), z.strictObject({ action: z.literal('gamecount'), value: z.number() }), ]); // Game Schema --------------------------------------------------------------- /** All possible types an incoming 'game' route websocket message contents could be. */ export type GameMessage = z.infer; const GameSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('joingame'), value: JoinGameMessageSchema }), z.strictObject({ action: z.literal('logged-game-info'), value: z.strictObject({ game_id: GameIDSchema, rated: z.literal([0, 1]), private: z.literal([0, 1]), termination: z.string(), icn: z.string(), }), }), z.strictObject({ action: z.literal('move'), value: OpponentsMoveMessageSchema }), z.strictObject({ action: z.literal('clock'), value: ClockValuesSchema }), z.strictObject({ action: z.literal('gameupdate'), value: GameUpdateMessageSchema, }), z.strictObject({ action: z.literal('gameratingchange'), value: z.record(z.string(), PlayerRatingChangeInfoSchema), }), z.strictObject({ action: z.literal('unsub') }), z.strictObject({ action: z.literal('login') }), z.strictObject({ action: z.literal('nogame') }), z.strictObject({ action: z.literal('leavegame') }), z.strictObject({ action: z.literal('opponentafk'), value: z.strictObject({ millisUntilAutoAFKResign: z.number() }), }), z.strictObject({ action: z.literal('opponentafkreturn') }), z.strictObject({ action: z.literal('opponentdisconnect'), value: DisconnectInfoSchema, }), z.strictObject({ action: z.literal('opponentdisconnectreturn') }), z.strictObject({ action: z.literal('drawoffer') }), z.strictObject({ action: z.literal('declinedraw') }), ]); // Master Schema --------------------------------------------------------------- /** The schema for validating all incoming websocket messages. */ const MasterSchema = z.discriminatedUnion('route', [ // Echo messages z.strictObject({ route: z.literal('echo'), contents: z.number(), }), // Reply-only messages (no route property, only exist to execute on-reply functions) z.strictObject({ id: z.number(), route: z.undefined(), replyto: z.number(), }), // Routed messages z.strictObject({ id: z.number(), route: z.literal('general'), contents: GeneralSchema, replyto: z.number().optional(), }), z.strictObject({ id: z.number(), route: z.literal('invites'), contents: InvitesSchema, replyto: z.number().optional(), }), z.strictObject({ id: z.number(), route: z.literal('game'), contents: GameSchema, replyto: z.number().optional(), }), ]); // Exports --------------------------------------------------------------- export { MasterSchema }; ================================================ FILE: src/client/scripts/esm/game/websocket/socketsubs.ts ================================================ // src/client/scripts/esm/game/websocket/socketsubs.ts /** * Manages subscription state for the client websocket system. * * Tracks which subscriptions (e.g. 'invites', 'game') are currently active, * and provides methods to add, remove, and query subscriptions. */ import socketmessages from './socketmessages.js'; const validSubs = ['invites', 'game'] as const; type Sub = (typeof validSubs)[number]; const subs: Record = { invites: false, game: false, }; /** Returns true if we're currently not subscribed to anything. */ function zeroSubs(): boolean { for (const sub of validSubs) if (subs[sub] === true) return false; return true; } /** * Whether we are subbed to the given subscription list. * @param sub - The name of the sub */ function areSubbedToSub(sub: Sub): boolean { return subs[sub] !== false; } /** * Marks ourself as subscribed to a subscription list. * @param sub - The name of the sub to add */ function addSub(sub: Sub): void { subs[sub] = true; } /** * Marks ourself as no longer subscribed to a subscription list. * * If our websocket happens to close unexpectedly, we won't re-subscribe to it. * @param sub - The name of the sub to delete */ function deleteSub(sub: Sub): void { subs[sub] = false; } /** * Unsubs from the provided subscription list, * informing the server we no longer want updates. * @param sub - The name of the sub to unsubscribe from */ function unsubFromSub(sub: Sub): void { if (!areSubbedToSub(sub)) return; // Already unsubbed. deleteSub(sub); // Tell the server we no longer want updates. socketmessages.send('general', 'unsub', sub); } // Exports -------------------------------------------------------------------- export default { validSubs, zeroSubs, areSubbedToSub, addSub, deleteSub, unsubFromSub, }; ================================================ FILE: src/client/scripts/esm/util/ImageLoader.ts ================================================ // src/client/scripts/esm/util/ImageLoader.ts import { retryFetch, RetryFetchOptions } from './httputils'; class ImageLoader { /** Default retry options if none are provided. */ private static defaultRetryOptions: RetryFetchOptions = { maxAttempts: 1 }; // No retries by default /** * Requests an image from the server with retry logic and returns a promise * that resolves to an HTMLImageElement. * @param url The URL of the image to request. * @param retryOptions Optional configuration for the retry behavior. * @returns A promise that resolves with the loaded HTMLImageElement. */ public static loadImage( url: string, retryOptions: RetryFetchOptions = this.defaultRetryOptions, ): Promise { return new Promise((resolve, reject) => { retryFetch(url, undefined, retryOptions) .then((response) => { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response.blob(); }) .then((blob) => { const image = new Image(); const objectURL = URL.createObjectURL(blob); image.onload = () => { // Revoke the object URL after the image has been loaded to free up memory URL.revokeObjectURL(objectURL); resolve(image); }; image.onerror = () => { // Revoke the object URL on error as well URL.revokeObjectURL(objectURL); reject(new Error(`Failed to load image at ${url}`)); }; image.src = objectURL; }) .catch((error) => { reject(error); }); }); } } export default ImageLoader; ================================================ FILE: src/client/scripts/esm/util/IndexedDB.ts ================================================ // src/client/scripts/esm/util/IndexedDB.ts /** * This script handles reading, saving, and deleting browser IndexedDB data. * * IndexedDB provides persistent large-scale storage beyond localStorage's limitations. */ /** An entry in IndexedDB storage */ interface Entry { /** The actual value of the entry */ value: any; /** The timestamp the entry will become stale, at which point it should be deleted. */ expires?: number; } const DB_NAME = 'infinitechess'; const DB_VERSION = 1; const STORE_NAME = 'entries'; let dbInstance: IDBDatabase | null = null; let dbInitPromise: Promise | null = null; // Do this on load every time eraseExpiredItems().catch((error: unknown) => { // Can happen in testing environment where IndexedDB is not available const msg = error instanceof Error ? error.message : String(error); console.error('Error erasing expired IndexedDB items on init:', msg); }); /** * Initializes the IndexedDB database. * Returns a promise that resolves to the database instance. */ function initDB(): Promise { if (dbInstance) return Promise.resolve(dbInstance); if (dbInitPromise) return dbInitPromise; dbInitPromise = new Promise((resolve, reject) => { // Check if IndexedDB is available const idb = (globalThis as any).indexedDB; if (!idb) { reject(new Error('IndexedDB is not supported in this browser')); return; } const request = idb.open(DB_NAME, DB_VERSION); request.onblocked = () => { console.warn('IndexedDB upgrade blocked: another tab/session holds the DB open'); }; request.onerror = () => { dbInitPromise = null; // Allow future calls to retry opening the DB reject(new Error('Failed to open IndexedDB database')); }; request.onsuccess = () => { dbInstance = request.result; if (dbInstance) { dbInstance.onversionchange = () => dbInstance?.close(); resolve(dbInstance); } else { reject(new Error('Failed to initialize IndexedDB database')); } }; request.onupgradeneeded = (event: IDBVersionChangeEvent) => { const db = (event.target as IDBOpenDBRequest).result; // Create object store if it doesn't exist if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }; }); return dbInitPromise; } /** Run a readonly transaction and return the request result. */ async function withRead(op: (_store: IDBObjectStore) => IDBRequest): Promise { const db = await initDB(); return new Promise((resolve, reject) => { // Open a readonly transaction on the object store const tx = db.transaction([STORE_NAME], 'readonly'); const store = tx.objectStore(STORE_NAME); // Execute caller-provided operation (e.g., store.get(key)) const req = op(store); req.onsuccess = () => resolve(req.result as T); // Reject on transaction or request errors tx.onerror = () => reject(tx.error || new Error('Transaction error')); req.onerror = () => reject(req.error || new Error('Request error')); }); } /** Run a readwrite transaction. Resolves when the transaction completes. */ async function withWrite(op: (_store: IDBObjectStore) => IDBRequest): Promise { const db = await initDB(); return new Promise((resolve, reject) => { // Open a readwrite transaction to modify data const tx = db.transaction([STORE_NAME], 'readwrite'); const store = tx.objectStore(STORE_NAME); // Execute caller-provided operation (e.g., store.put(...), store.delete(...)) const req = op(store); // Resolve only after the entire transaction finishes tx.oncomplete = () => resolve(); // Reject on transaction or request errors tx.onerror = () => reject(tx.error || new Error('Transaction error')); req.onerror = () => reject(req.error || new Error('Request error')); }); } /** * Saves an item in browser IndexedDB storage * @param key - The key-name to give this entry. * @param value - What to save * @param [expiryMillis] How long until this entry should be auto-deleted for being stale. Leave undefined to never expire. * @returns A promise that resolves when the item is saved */ async function saveItem(key: string, value: T, expiryMillis?: number): Promise { const timeExpires = expiryMillis !== undefined ? Date.now() + expiryMillis : undefined; const save: Entry = { value, expires: timeExpires }; return withWrite((store) => store.put(save, key)); } /** * Loads an item from browser IndexedDB storage * @param key - The name/key of the item in storage * @returns A promise that resolves to the entry value, or undefined if not found */ async function loadItem(key: string): Promise { const save = await withRead((store) => store.get(key)); if (save === undefined) return undefined; // Check if the item has expired or is in old format if (hasItemExpired(save)) { await deleteItem(key); return undefined; } // Not expired, return the value return save.value as T; } /** * Deletes an item from browser IndexedDB storage * @param key The name/key of the item in storage * @returns A promise that resolves when the item is deleted */ async function deleteItem(key: string): Promise { return withWrite((store) => store.delete(key)); } /** * Checks if an entry has expired * @param save - The entry to check. The latest format is { value: any, expires: number } * @returns True if the entry has expired */ function hasItemExpired(save: unknown): boolean { // Verify it's an object, and the `expires` property is present. // Checking in this way will allow typescript afterward to know it has that property. if ( typeof save !== 'object' || save === null || // This is true EVEN if the property is present but set to undefined! !('expires' in save) || (save.expires !== undefined && typeof save.expires !== 'number') ) { console.log(`IndexedDB item was in an old format. Deleting it...`); return true; } if (save.expires === undefined) return false; // Never expires return Date.now() >= save.expires; } /** * Erases all expired items from IndexedDB storage * @returns A promise that resolves when all expired items are deleted */ async function eraseExpiredItems(): Promise { const db = await initDB(); const keysToDelete: string[] = []; // Use a cursor to iterate through entries and check expiry without deserializing values await new Promise((resolve, reject) => { const tx = db.transaction([STORE_NAME], 'readonly'); const store = tx.objectStore(STORE_NAME); const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (cursor) { const entry = cursor.value as Entry | any; // Check if entry has expired or is in old format using hasItemExpired if (hasItemExpired(entry)) { keysToDelete.push(cursor.key as string); } cursor.continue(); } }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error('Transaction error')); request.onerror = () => reject(request.error || new Error('Request error')); }); // Delete all expired items in a single transaction if (keysToDelete.length > 0) { await new Promise((resolve, reject) => { const tx = db.transaction([STORE_NAME], 'readwrite'); const store = tx.objectStore(STORE_NAME); for (const key of keysToDelete) { store.delete(key); } tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error('Transaction error')); }); } } /** * Gets all keys present in the IndexedDB storage * @returns A promise that resolves to an array of all keys */ async function getAllKeys(): Promise { const keys = await withRead((store) => store.getAllKeys()); return keys as string[]; } /** * Erases all items from IndexedDB storage * @returns A promise that resolves when all items are deleted */ async function eraseAll(): Promise { return withWrite((store) => store.clear()); } /** Reset the cached DB instance (close if open) so the next call to initDB() re-initializes. */ function resetDBInstance(): void { // Close the existing database connection if it’s open (ignore any close errors) try { dbInstance?.close(); } catch { // Ignore } // Null out cached references so initDB() will run fresh dbInstance = null; dbInitPromise = null; } export default { saveItem, loadItem, deleteItem, getAllKeys, eraseExpiredItems, eraseAll, resetDBInstance, // Unit test constants DB_NAME, DB_VERSION, STORE_NAME, }; ================================================ FILE: src/client/scripts/esm/util/LocalStorage.ts ================================================ // src/client/scripts/esm/util/LocalStorage.ts /** * This script handles reading, saving, and deleting expired * browser local storage data for us! * Without it, things we save NEVER expire or are deleted. * (unless the user clears their browser cache) */ import jsutil from '../../../../shared/util/jsutil.js'; /** An entry in local storage */ interface Entry { /** The actual value of the entry */ value: any; /** The timestamp the entry will become stale, at which point it should be deleted. */ expires: number; } /** For debugging. This prints to the console all save and delete operations. */ const printSavesAndDeletes = false; const defaultExpiryTimeMillis = 1000 * 60 * 60 * 24; // 24 hours // const defaultExpiryTimeMillis = 1000 * 20; // 20 seconds // Do this on load every time eraseExpiredItems(); /** * Saves an item in browser local storage * @param key - The key-name to give this entry. * @param value - What to save * @param [expiryMillis] How long until this entry should be auto-deleted for being stale */ function saveItem(key: string, value: any, expiryMillis: number = defaultExpiryTimeMillis): void { if (printSavesAndDeletes) console.log(`Saving key to local storage: ${key}`); const timeExpires = Date.now() + expiryMillis; const save: Entry = { value, expires: timeExpires }; const stringifiedSave = JSON.stringify(save, jsutil.stringifyReplacer); localStorage.setItem(key, stringifiedSave); } /** * Loads an item from browser local storage * @param key - The name/key of the item in storage * @returns The entry */ function loadItem(key: string): any { const stringifiedSave: string | null = localStorage.getItem(key); // "{ value, expiry }" if (stringifiedSave === null) return; let save: Entry | any; try { save = JSON.parse(stringifiedSave, jsutil.parseReviver); // { value, expires } } catch (_e) { // Value wasn't in json format, just delete it. They have to be in json because we always store the 'expiry' property. deleteItem(key); return; } if (hasItemExpired(save)) { deleteItem(key); return; } // Not expired... // console.log(`Fetched key ${key} from local storage:`); // console.log(save); return save.value; } /** * Deletes an item from browser local storage * @param key The name/key of the item in storage */ function deleteItem(key: string): void { if (printSavesAndDeletes) console.log(`Deleting local storage item with key '${key}!'`); localStorage.removeItem(key); } function hasItemExpired(save: Entry | any): boolean { if (save.expires === undefined) { console.log(`Local storage item was in an old format. Deleting it...`); return true; } return Date.now() >= save.expires; } function eraseExpiredItems(): void { const keys = Object.keys(localStorage); // if (keys.length > 0) console.log(`Items in local storage: ${JSON.stringify(keys)}`); for (const key of keys) { loadItem(key); // Auto-deletes expired items } } function eraseAll(): void { console.log('Erasing ALL items in local storage...'); const keys = Object.keys(localStorage); for (const key of keys) { deleteItem(key); // Auto-deletes expired items } } export default { saveItem, loadItem, deleteItem, eraseExpiredItems, eraseAll, }; ================================================ FILE: src/client/scripts/esm/util/PerlinNoise.ts ================================================ // src/client/scripts/esm/util/PerlinNoise.ts /** * A factory for creating a tileable (periodic) 1D Perlin-style noise generator. */ /** * A pre-shuffled array of numbers from 0-255. * This is a standard permutation table used in many noise algorithms. * We double it to avoid needing extra modulo operations inside the noise function. */ const p = [ 151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180, ]; const perm = [...p, ...p]; /** A simple linear interpolation function. */ function lerp(a: number, b: number, t: number): number { return a + t * (b - a); } /** A smoothing function (quintic curve) to avoid artifacts in the noise. */ function fade(t: number): number { return t * t * t * (t * (t * 6 - 15) + 10); } /** * Creates and returns a new 1D noise function that is periodic (tileable). * @param period The interval after which the noise pattern should repeat. Must be an integer. * @returns A function that takes a number `t` and returns a noise value between -1 and 1. */ function create1DNoiseGenerator(period: number): (_t: number) => number { if (period > 256) throw Error('Period must be 256 or less.'); // Pre-calculate random gradients for the length of the period. // For 1D noise, a "gradient" is just a random number, either 1 or -1. const gradients = new Array(period); for (let i = 0; i < period; i++) { gradients[i] = perm[i]! % 2 === 0 ? 1 : -1; } return (t: number) => { // Find the integer grid points surrounding t const x0 = Math.floor(t); const x1 = x0 + 1; // Get the fractional part of t const t0 = t - x0; // This is the magic for making the noise tileable. // We use the modulo operator to wrap the grid coordinates around the period. // So, the gradient for point `period` will be the same as for point `0`. const g0 = gradients[x0 % period]!; const g1 = gradients[x1 % period]!; // Calculate the contribution of each gradient at point t const n0 = g0 * t0; const n1 = g1 * (t0 - 1); // Apply the fade curve to the fractional part for smooth interpolation const fadeT = fade(t0); // Interpolate between the two contributions and scale the output // to be consistently within the approximate range of -1 to 1. return lerp(n0, n1, fadeT) * 2.2; }; } export default { create1DNoiseGenerator, }; ================================================ FILE: src/client/scripts/esm/util/compression.ts ================================================ // src/client/scripts/esm/util/compression.ts /** * General-purpose string compression utilities using the Web Streams * CompressionStream / DecompressionStream APIs. * * Compressed output is base64-encoded so it can be safely stored and * transmitted as a plain string. */ // Types ----------------------------------------------------------------------- /** The compression algorithm used when storing a compressed string. */ export type CompressionMode = 'none' | 'deflate-raw'; // Constants ----------------------------------------------------------------------- /** * Set to `true` to enable verbose compression/decompression diagnostics: * - `console.time` timing for every compress/decompress call. * - After compression: before/after character counts, bytes saved, and ratio. */ const DEBUG_COMPRESSION = false; // Helpers --------------------------------------------------------------------- /** Reads all chunks from a ReadableStream into a single Uint8Array. */ async function readAllChunks(readable: ReadableStream): Promise { const chunks: Uint8Array[] = []; const reader = readable.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); const combined = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return combined; } /** Base64-encodes a Uint8Array in fixed-size chunks to avoid stack overflow on large payloads. */ function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = ''; const chunkSize = 8192; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length))); } return btoa(binary); } /** * Decompresses a base64-encoded string that was previously compressed with * `CompressionStream('deflate-raw')`. * @throws If `DecompressionStream` is unavailable or decompression fails. */ async function decompressStringBase64(compressedBase64: string): Promise { const binary = atob(compressedBase64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const stream = new DecompressionStream('deflate-raw'); const writer = stream.writable.getWriter(); writer.write(bytes); writer.close(); const decompressed = await readAllChunks(stream.readable); return new TextDecoder().decode(decompressed); } // API --------------------------------------------------------------------- /** * Attempts to compress a string using `CompressionStream('deflate-raw')`. * * The compressed output is base64-encoded so it can be stored as a plain string. * Falls back gracefully to `'none'` if: * - `CompressionStream` is not available in the current environment, or * - Compression does not actually reduce the string length, or * - An unexpected error occurs during compression. * * @returns An object with `data` (the compressed-and-base64-encoded string, or * the original string when compression is `'none'`) and `compression` * indicating which mode was used. */ async function compressString( str: string, ): Promise<{ data: string; compression: CompressionMode }> { if (typeof CompressionStream === 'undefined') { return { data: str, compression: 'none' }; } const label = `Compressed ${str.length} characters`; if (DEBUG_COMPRESSION) console.time(label); try { const encoded = new TextEncoder().encode(str); const stream = new CompressionStream('deflate-raw'); const writer = stream.writable.getWriter(); writer.write(encoded); writer.close(); const compressed = await readAllChunks(stream.readable); const base64 = uint8ArrayToBase64(compressed); if (DEBUG_COMPRESSION) { console.timeEnd(label); const ratio = ((base64.length * 100) / str.length).toFixed(1); console.log( `Before: ${str.length} characters. After: ${base64.length} characters. (${ratio}% of original)`, ); } // Only use compression if it actually reduces size if (base64.length < str.length) { return { data: base64, compression: 'deflate-raw' }; } } catch (err) { if (DEBUG_COMPRESSION) console.timeEnd(label); console.warn('Compression failed, falling back to uncompressed:', err); } // Fallback to uncompressed if compression is unavailable, fails, or doesn't reduce size return { data: str, compression: 'none' }; } /** * Decompresses a string according to its stored compression mode. * - `'none'`: returns `data` unchanged. * - `'deflate-raw'`: base64-decodes then inflates the data. * * @throws If the mode is `'deflate-raw'` and `DecompressionStream` is not * available in the current environment, or if decompression fails. */ async function decompressString(data: string, mode: CompressionMode): Promise { if (mode === 'none') return data; if (mode === 'deflate-raw') { if (typeof DecompressionStream === 'undefined') { throw new Error('Browser does not support DecompressionStream.'); } const label = `Decompressed ${data.length} characters`; if (DEBUG_COMPRESSION) console.time(label); const result = await decompressStringBase64(data); if (DEBUG_COMPRESSION) console.timeEnd(label); return result; } throw new Error(`Unsupported compression mode: "${mode}"`); } // Exports --------------------------------------------------------------------- export default { compressString, decompressString, }; ================================================ FILE: src/client/scripts/esm/util/docutil.ts ================================================ // src/client/scripts/esm/util/docutil.ts /** * This script contains utility methods for the document/window objects, or the page. * * ZERO dependancies. */ /** * Determines if the current page is running on a local environment (localhost or local IP). * @returns *true* if the page is running locally, *false* otherwise. */ function isLocalEnvironment(): boolean { const hostname = window.location.hostname; // Check for common localhost hostnames and local IP ranges return ( hostname === 'localhost' || // Localhost hostname === '127.0.0.1' || // Loopback IP address hostname.startsWith('192.168.') || // Private IPv4 address space hostname.startsWith('10.') || // Private IPv4 address space (hostname.startsWith('172.') && parseInt(hostname.split('.')[1]!, 10) >= 16 && parseInt(hostname.split('.')[1]!, 10) <= 31) // Private IPv4 address space ); } /** * Copies the provided text to the operating system's clipboard. * @param text - The text to copy */ function copyToClipboard(text: string): void { navigator.clipboard .writeText(text) .then(() => { console.log('Copied to clipboard'); }) .catch((error) => { console.error('Failed to copy to clipboard', error); }); } /** * Returns true if the current device has a mouse pointer. */ function isMouseSupported(): boolean { // "pointer: coarse" are devices will less pointer accuracy (not "fine" like a mouse) // See W3 documentation: https://www.w3.org/TR/mediaqueries-4/#mf-interaction // USING "any-pointer" CAUSES false positives on mobile devices! return window.matchMedia('(pointer: fine)').matches; } /** * Returns true if the current device supports touch events. */ function isTouchSupported(): boolean { // "pointer: coarse" are devices will less pointer accuracy (not "fine" like a mouse) return ( 'ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches ); } /** * Gets the last segment of the current URL without query parameters. * "/member/jacob?lng=en-US" ==> "jacob" */ function getLastSegmentOfURL(): string { const url = new URL(window.location.href); const pathname = url.pathname; const segments = pathname.split('/').filter(Boolean); // Remove empty segments caused by leading/trailing slashes return segments[segments.length - 1] ?? ''; // Fallback to an empty string if no segment exists } /** * Extracts the pathname from a given href. * (e.g. "https://www.infinitechess.org/news?lng=en-US" ==> "/news") * @param href - The href to extract the pathname from. Can be a relative or absolute URL. * @returns The pathname of the href (e.g., '/news'). */ function getPathnameFromHref(href: string): string { const url = new URL(href, window.location.origin); return url.pathname; } /** * Searches the document for the specified cookie, and returns it if found. * @param cookieName The name of the cookie you would like to retrieve. * @returns The cookie, if it exists, otherwise, undefined. */ function getCookieValue(cookieName: string): string | undefined { const cookieArray = document.cookie.split('; '); for (let i = 0; i < cookieArray.length; i++) { const cookiePair = cookieArray[i]!.split('='); if (cookiePair[0] === cookieName) return cookiePair[1]; } return; // Typescript is angry without this } /** * Sets a cookie in the document * @param cookieName - The name of the cookie * @param value - The value of the cookie * @param days - How many days until the cookie should expire. */ function updateCookie(cookieName: string, value: string, days: number): void { let expires = ''; if (days) { const date = new Date(); date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); expires = '; expires=' + date.toUTCString(); } document.cookie = cookieName + '=' + (value || '') + expires + '; path=/'; } /** * Deletes a document cookie. * @param cookieName - The name of the cookie you would like to delete. */ function deleteCookie(cookieName: string): void { document.cookie = cookieName + '=; Max-Age=-99999999;'; } /** * Parse an SVG string into a live SVGElement. * @param svgText — a string containing valid `` markup * @returns the newly created SVG element */ function createSvgElementFromString(svgText: string): SVGElement { const parser = new DOMParser(); const doc = parser.parseFromString(svgText, 'image/svg+xml'); const svg = doc.querySelector('svg'); if (!svg) throw new Error('Failed to parse SVG string.'); return svg; } export default { isLocalEnvironment, copyToClipboard, isMouseSupported, isTouchSupported, getLastSegmentOfURL, getPathnameFromHref, getCookieValue, updateCookie, deleteCookie, createSvgElementFromString, }; ================================================ FILE: src/client/scripts/esm/util/httputils.ts ================================================ // src/client/scripts/esm/util/httputils.ts /** * This script contains http/fetch utility methods. */ /** Options for {@link retryFetch} */ interface RetryFetchOptions { /** * Maximum number of fetch attempts (e.g., 1 means one attempt, no retries). * Assumed to be 1 or greater. Defaults to 3. */ maxAttempts?: number; /** * Initial delay in milliseconds *before the first retry* (i.e., after the first attempt fails). * Defaults to 1000ms. */ initialDelayMs?: number; /** * Factor by which the delay increases after each retry (e.g., 2 for exponential, 1 for linear). * Defaults to 2. */ backoffFactor?: number; } /** Default options for {@link retryFetch} */ const defaultRetryFetchOptions: Required = { maxAttempts: 3, initialDelayMs: 1000, backoffFactor: 2, }; /** * A wrapper around fetch that provides retry logic. * Retries on network errors and 5xx server errors. * Terminates on client errors 4xx. * @param url The URL to fetch. Can be a string, URL object, or Request object. * @param fetchInit The init object for the fetch call (method, headers, body, etc.). * @param retryOptions Configuration for the retry behavior. * @returns A Promise that resolves with the Response if: * - The request is successful (e.g., 2xx). * - The request results in a non-retryable error (e.g., 4xx). * - Retries are exhausted, and the last attempt resulted in a retryable server error (5xx response). * @throws An Error if: * - Retries are exhausted, and the last attempt resulted in a network error. */ async function retryFetch( url: string | URL | Request, fetchInit?: RequestInit, retryOptions?: RetryFetchOptions, ): Promise { const options: Required = { ...defaultRetryFetchOptions, ...retryOptions, }; let currentDelayMs = options.initialDelayMs; // Helper for logging the URL const getUrlString = (targetUrl: typeof url): string => { if (typeof targetUrl === 'string') return targetUrl; if (targetUrl instanceof URL) return targetUrl.href; if (targetUrl instanceof Request) return targetUrl.url; return 'Unknown URL'; }; const urlString = getUrlString(url); for (let attempt = 1; attempt <= options.maxAttempts; attempt++) { const isLastAttempt = attempt === options.maxAttempts; try { // console.log(`retryFetch: Attempt ${attempt}/${options.maxAttempts} for ${urlString}...`); const response = await fetch(url, fetchInit); // Check for retryable server errors (5xx) if (response.status >= 500 && response.status <= 599) { if (isLastAttempt) { // console.warn(`retryFetch: Max attempts reached. Last attempt for ${urlString} resulted in status ${response.status}.`); return response; // Return the final 5xx response } // Not the last attempt, so log and prepare for retry // console.warn(`retryFetch: Attempt ${attempt} for ${urlString} failed with status ${response.status}. Retrying...`); // Fall through to wait and retry } else { // Not a 5xx error. Could be 2xx (success), 4xx (client error), or other. // No retry for these based on the hardcoded logic. return response; } } catch (error) { // Network error occurred if (isLastAttempt) { // console.error(`retryFetch: Max attempts reached. Last attempt for ${urlString} failed with network error:`, error); throw error; // Re-throw the final network error } // Not the last attempt, so log and prepare for retry // console.warn(`retryFetch: Attempt ${attempt} for ${urlString} failed with network error: ${(error as Error).message}. Retrying...`); // Fall through to wait and retry } // If we reach here, a retry is scheduled (and it's not the last attempt) await new Promise((resolve) => setTimeout(resolve, currentDelayMs)); currentDelayMs *= options.backoffFactor; } // This line should be theoretically unreachable if options.maxAttempts >= 1, // as the loop will always return or throw on its final iteration. // It's included for defensive programming in case of unexpected state. throw new Error( `retryFetch: Exited retry loop unexpectedly for ${urlString}. This should not happen if maxAttempts >= 1.`, ); } export { retryFetch }; export type { RetryFetchOptions }; ================================================ FILE: src/client/scripts/esm/util/indexeddb.unit.test.ts ================================================ // src/client/scripts/esm/util/indexeddb.unit.test.ts /** * Functional tests for the IndexedDB storage module using a simulated IDB. * Uses fake-indexeddb and the module's resetDBInstance() for isolation. */ import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; import { describe, it, expect, beforeEach } from 'vitest'; import indexeddb from './IndexedDB.js'; beforeEach(() => { // Fresh fake IndexedDB and key range per test (globalThis as any).indexedDB = new IDBFactory(); (globalThis as any).IDBKeyRange = IDBKeyRange; // Ensure module will open a brand-new DB for this test indexeddb.resetDBInstance(); }); describe('IndexedDB storage functional behavior', () => { it('getAllKeys returns [] initially', async () => { expect(await indexeddb.getAllKeys()).toEqual([]); }); it('saves and loads an item', async () => { await indexeddb.saveItem('pos:1', { fen: 'start' }); const value = await indexeddb.loadItem<{ fen: string }>('pos:1'); expect(value).toEqual({ fen: 'start' }); }); it('overwrites an existing item with the same key', async () => { await indexeddb.saveItem('k', 'one'); await indexeddb.saveItem('k', 'two'); const value = await indexeddb.loadItem('k'); expect(value).toBe('two'); }); it('returns undefined for a missing key', async () => { const value = await indexeddb.loadItem('missing'); expect(value).toBeUndefined(); }); it('deletes an item', async () => { await indexeddb.saveItem('x', 123); await indexeddb.deleteItem('x'); const value = await indexeddb.loadItem('x'); expect(value).toBeUndefined(); }); it('delete of a missing key resolves (no error)', async () => { await expect(indexeddb.deleteItem('nope')).resolves.toBeUndefined(); }); it('getAllKeys returns the current keys only', async () => { await indexeddb.saveItem('a', 1); await indexeddb.saveItem('b', 2); await indexeddb.deleteItem('a'); const keys = await indexeddb.getAllKeys(); expect(keys.sort()).toEqual(['b']); }); it('eraseAll clears all items', async () => { await indexeddb.saveItem('a', 1); await indexeddb.saveItem('b', 2); await indexeddb.eraseAll(); expect(await indexeddb.getAllKeys()).toEqual([]); }); it('handles concurrent writes and reads', async () => { const writes = Array.from({ length: 50 }, (_, i) => indexeddb.saveItem(`k${i}`, { v: i })); await Promise.all(writes); const keys = await indexeddb.getAllKeys(); const numericSorted = [...keys].sort( (a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10), ); expect(numericSorted).toEqual(Array.from({ length: 50 }, (_, i) => `k${i}`)); const reads = await Promise.all([ indexeddb.loadItem('k0'), indexeddb.loadItem('k25'), indexeddb.loadItem('k49'), ]); expect(reads).toEqual([{ v: 0 }, { v: 25 }, { v: 49 }]); }); it('resetDBInstance causes a fresh database (previous keys gone)', async () => { await indexeddb.saveItem('temp', 42); expect(await indexeddb.getAllKeys()).toEqual(['temp']); // Simulate a fresh environment indexeddb.resetDBInstance(); (globalThis as any).indexedDB = new IDBFactory(); (globalThis as any).IDBKeyRange = IDBKeyRange; // New open should yield empty store expect(await indexeddb.getAllKeys()).toEqual([]); }); it('saves an item with custom expiry time', async () => { const expiryMillis = 10000; // 10 seconds await indexeddb.saveItem('k', 'value', expiryMillis); const value = await indexeddb.loadItem('k'); expect(value).toBe('value'); }); it('auto-deletes expired items on load', async () => { const shortExpiry = 1; // 1 millisecond await indexeddb.saveItem('expiring', 'test', shortExpiry); // Wait for expiry await new Promise((resolve) => setTimeout(resolve, 10)); // loadItem should delete the expired item and return undefined const value = await indexeddb.loadItem('expiring'); expect(value).toBeUndefined(); // Key should be deleted const keys = await indexeddb.getAllKeys(); expect(keys).not.toContain('expiring'); }); it('eraseExpiredItems removes only expired items', async () => { const shortExpiry = 1; // 1 millisecond const longExpiry = 60000; // 60 seconds await indexeddb.saveItem('expired1', 'test1', shortExpiry); await indexeddb.saveItem('expired2', 'test2', shortExpiry); await indexeddb.saveItem('valid', 'test3', longExpiry); // Wait for short-lived items to expire await new Promise((resolve) => setTimeout(resolve, 10)); await indexeddb.eraseExpiredItems(); const keys = await indexeddb.getAllKeys(); expect(keys).toEqual(['valid']); const validValue = await indexeddb.loadItem('valid'); expect(validValue).toBe('test3'); }); it('handles items saved without expiry (old format)', async () => { // First save an item normally to ensure DB is initialized await indexeddb.saveItem('temp', 'temp'); // Manually save an item in the old format (without expiry) by directly accessing IDB await new Promise((resolve, reject) => { const request = (globalThis as any).indexedDB.open( indexeddb.DB_NAME, indexeddb.DB_VERSION, ); request.onsuccess = () => { const db = request.result; const tx = db.transaction([indexeddb.STORE_NAME], 'readwrite'); const store = tx.objectStore(indexeddb.STORE_NAME); // Save old format: just the value, no wrapper object store.put('old-value', 'old-key'); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); }; request.onerror = () => reject(request.error); }); // Reset to get fresh connection indexeddb.resetDBInstance(); // loadItem should delete the old format item and return undefined const value = await indexeddb.loadItem('old-key'); expect(value).toBeUndefined(); // Verify it was deleted const keys = await indexeddb.getAllKeys(); expect(keys).not.toContain('old-key'); }); }); ================================================ FILE: src/client/scripts/esm/util/mouse.ts ================================================ // src/client/scripts/esm/util/mouse.ts /** * This script contains several wrappers for getting the * mouse position, world space, or coordinates, * reading the correct listener depending on whether we're in perspective mode or not. */ import type { BDCoords, Coords, DoubleCoords } from '../../../../shared/chess/util/coordutil.js'; import space from '../game/misc/space.js'; import camera from '../game/rendering/camera.js'; import perspective from '../game/rendering/perspective.js'; import { listener_document, listener_overlay } from '../game/chess/game.js'; import input, { InputListener, Mouse, MouseButton } from '../game/input.js'; /** * This is capable of getting the mouse position, EVEN IF * it is off screen! Only the document's event listener is capable * of receiving 'mousemove' events when the mouse is off screen. * * If another pointer id is used, such as a touch event, we cannot * detect the mouse position when it is off screen. * * ONLY WORKS IF WE LEFT-CLICK-DRAG off the screen. NOT if we right-click-drag! */ function getPhysicalPointerPosition_Offscreen(physicalPointerId: string): DoubleCoords | undefined { if (physicalPointerId === 'mouse') { // The mouse on the document is sensitive to 'mousemove' events even when the mouse is outside the element/window. // This allows us to continue dragging the board/piece even when the mouse is outside the window. const mousePos = listener_document.getPhysicalPointerPos(physicalPointerId); if (!mousePos) return undefined; // Make the coordinates relative to the element instead of the document. return input.getRelativeMousePosition(mousePos, listener_overlay.element); } else { return listener_overlay.getPhysicalPointerPos(physicalPointerId); } } /** * Returns the world space coordinates of the mouse pointer, * or the crosshair if the mouse is locked (in perspective mode). */ function getMouseWorld(button: MouseButton = Mouse.LEFT): DoubleCoords | undefined { if (!perspective.getEnabled()) { const physicalPointerId = listener_overlay.getMousePhysicalId(button); if (!physicalPointerId) return undefined; let mousePos = getPhysicalPointerPosition_Offscreen(physicalPointerId); if (!mousePos) { // Pointer likely doesn't exist anymore (touch event lifted). // This will return its last known position. mousePos = listener_overlay.getMousePosition(button); } if (!mousePos) return undefined; return convertMousePositionToWorldSpace(mousePos, listener_overlay.element); } else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead. } /** * Returns the world space coordinates of the given pointer, * or the crosshair if in perspective mode. */ function getPointerWorld(pointerId: string): DoubleCoords | undefined { if (!perspective.getEnabled()) { const physicalPointerId = listener_overlay.getPhysicalPointerIdOfPointer(pointerId); if (!physicalPointerId) return undefined; const pointerPos = getPhysicalPointerPosition_Offscreen(physicalPointerId); if (!pointerPos) return undefined; return convertMousePositionToWorldSpace(pointerPos, listener_overlay.element); } else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead. } /** * Returns the world space coordinates of a PHYSICAL pointer, * or the crosshair if in perspective mode. */ function getPhysicalPointerWorld(physicalPointerId: string): DoubleCoords | undefined { if (!perspective.getEnabled()) { const pointerPos = getPhysicalPointerPosition_Offscreen(physicalPointerId); if (!pointerPos) return undefined; return convertMousePositionToWorldSpace(pointerPos, listener_overlay.element); } else return getCrossHairWorld(); // Mouse is locked, we must be in perspective mode. Calculate the mouse world according to the crosshair location instead. } /** * Returns the world position of the crosshair, dependant on perspective mode rotations. * May only return undefined in the case we're looking into the sky. */ function getCrossHairWorld(): DoubleCoords | undefined { if (perspective.isLookingUp()) return; const rotX = (Math.PI / 180) * perspective.getRotX(); const rotZ = (Math.PI / 180) * perspective.getRotZ(); // Calculate intersection point const hyp = -Math.tan(rotX) * camera.getPosition()[2]; // x^2 + y^2 = hyp^2 // hyp = sqrt( x^2 + y^2 ) const mouseWorld: DoubleCoords = [hyp * Math.sin(rotZ), hyp * Math.cos(rotZ)]; // console.log(mouseWorld); return mouseWorld; } function convertMousePositionToWorldSpace( mouse: DoubleCoords, element: HTMLElement | typeof document, ): DoubleCoords { const mouseCopy: DoubleCoords = [...mouse]; const screenBox = camera.getScreenBoundingBox(); const screenWidth = screenBox.right - screenBox.left; const screenHeight = screenBox.top - screenBox.bottom; const clientWidth = element instanceof HTMLElement ? element.clientWidth : window.innerWidth; const clientHeight = element instanceof HTMLElement ? element.clientHeight : window.innerHeight; // The world space coordinates are sensitive to whether we're viewing white's or black's perspective. // prettier-ignore const mouseWorldSpace: DoubleCoords = perspective.getIsViewingBlackPerspective() ? [ screenBox.right - (mouseCopy[0] / clientWidth) * screenWidth, // [0,0] is the top LEFT corner of the screen, according to mouse coordinates. screenBox.bottom + (mouseCopy[1] / clientHeight) * screenHeight, ] : [ screenBox.left + (mouseCopy[0] / clientWidth) * screenWidth, // [0,0] is the top LEFT corner of the screen, according to mouse coordinates. screenBox.top - (mouseCopy[1] / clientHeight) * screenHeight, ]; return mouseWorldSpace; } function getTileMouseOver_Float(button: MouseButton = Mouse.LEFT): BDCoords | undefined { const mouseWorld = getMouseWorld(button); if (!mouseWorld) return undefined; return space.convertWorldSpaceToCoords(mouseWorld); } function getTileMouseOver_Integer(button: MouseButton = Mouse.LEFT): Coords | undefined { const mouseWorld = getMouseWorld(button); if (!mouseWorld) return undefined; return space.convertWorldSpaceToCoords_Rounded(mouseWorld); } /** Returns the floating point tile the given LOGICAL pointer is over. */ function getTilePointerOver_Float(pointerId: string): BDCoords | undefined { const physicalPointerId = listener_overlay.getPhysicalPointerIdOfPointer(pointerId); if (!physicalPointerId) return; // const pointerCoords = listener_overlay.getPointerPos(pointerId)!; const pointerCoords = getPhysicalPointerPosition_Offscreen(physicalPointerId); if (!pointerCoords) return undefined; const pointerWorld = convertMousePositionToWorldSpace(pointerCoords, listener_overlay.element); return space.convertWorldSpaceToCoords(pointerWorld); } /** Gets the given pointer's current coordinates being hovered over, rounded to the integer square. */ function getTilePointerOver_Integer(pointerId: string): Coords | undefined { const pointerWorld: DoubleCoords | undefined = getPointerWorld(pointerId); if (!pointerWorld) return undefined; return space.convertWorldSpaceToCoords_Rounded(pointerWorld); } /** * Wrapper for reading the correct listener for whether the mouse button is down, * depending on whether we're in perspective mode or not. */ function isMouseDown(button: MouseButton): boolean { if (perspective.isMouseLocked()) return listener_document.isMouseDown(button); else return listener_overlay.isMouseDown(button); } /** * Wrapper for reading the correct listener for whether the mouse button is held, * depending on whether we're in perspective mode or not. */ function isMouseHeld(button: MouseButton): boolean { if (perspective.isMouseLocked()) return listener_document.isMouseHeld(button); else return listener_overlay.isMouseHeld(button); } /** * Wrapper for reading the correct listener for whether the mouse button was click simulated, * depending on whether we're in perspective mode or not. */ function isMouseClicked(button: MouseButton): boolean { if (perspective.isMouseLocked()) return listener_document.isMouseClicked(button); else return listener_overlay.isMouseClicked(button); } /** * Wrapper for reading the correct listener for whether the mouse button was double-click simulated, * depending on whether we're in perspective mode or not. */ function isMouseDoubleClickDragged(button: MouseButton): boolean { if (perspective.isMouseLocked()) return listener_document.isMouseDoubleClickDragged(button); else return listener_overlay.isMouseDoubleClickDragged(button); } // /** // * Wrapper for reading the correct listener for if the most recent // * pointer for a specific mouse button action is a touch (not mouse), // * depending on whether we're in perspective mode or not. // */ // function isMouseTouch(button: MouseButton): boolean { // if (perspective.isMouseLocked()) return listener_document.isMouseTouch(button); // else return listener_overlay.isMouseTouch(button); // } /** * Wrapper for reading the correct listener for the mouse wheel delta, * depending on whether the mouse is locked or not (perspective mode). */ function getWheelDelta(): number { if (perspective.isMouseLocked()) return listener_document.getWheelDelta(); else return listener_overlay.getWheelDelta(); } /** * Wrapper for reading the correct listener for claiming the mouse down event, * depending on whether we're in perspective mode or not. */ function claimMouseDown(button: MouseButton): void { if (perspective.isMouseLocked()) listener_document.claimMouseDown(button); else listener_overlay.claimMouseDown(button); } /** * Wrapper for reading the correct listener for claiming the mouse click event, * depending on whether we're in perspective mode or not. */ function claimMouseClick(button: MouseButton): void { if (perspective.isMouseLocked()) listener_document.claimMouseClick(button); else listener_overlay.claimMouseClick(button); } /** * Wrapper for reading the correct listener for canceling the mouse click event, * depending on whether we're in perspective mode or not. */ function cancelMouseClick(button: MouseButton): void { if (perspective.isMouseLocked()) listener_document.cancelMouseClick(button); else listener_overlay.cancelMouseClick(button); } /** * Wrapper for reading the correct listener for getting the mose recent mouse id * that performed the specified action, depending on whether we're in perspective mode or not. */ function getMouseId(button: MouseButton): string | undefined { if (perspective.isMouseLocked()) return listener_document.getMouseId(button); else return listener_overlay.getMouseId(button); } /** * Returns the relevant listener for the mouse events, * depending on whether we're in perspective mode or not. */ function getRelevantListener(): InputListener { if (perspective.getEnabled()) return listener_document; else return listener_overlay; } /** * Returns all the existing PHYSICAL pointers' world * coordinates, depending on the relevant listener. */ function getAllPointerWorlds(): DoubleCoords[] { const allPhysicalPointerIds = getRelevantListener().getAllPhysicalPointers(); const pointerWorlds: DoubleCoords[] = []; for (const id of allPhysicalPointerIds) { const world = getPhysicalPointerWorld(id); // Only push them if their world coordinates exist (won't if looking into sky) if (world) pointerWorlds.push(world); } return pointerWorlds; } export default { getMouseWorld, getPointerWorld, getPhysicalPointerWorld, convertMousePositionToWorldSpace, getTileMouseOver_Float, getTileMouseOver_Integer, getTilePointerOver_Float, getTilePointerOver_Integer, isMouseDown, isMouseHeld, isMouseClicked, isMouseDoubleClickDragged, // isMouseTouch, getWheelDelta, claimMouseDown, claimMouseClick, cancelMouseClick, getMouseId, getRelevantListener, getAllPointerWorlds, }; ================================================ FILE: src/client/scripts/esm/util/pingManager.ts ================================================ // src/client/scripts/esm/util/pingManager.ts /** * PingManager * Manages the current ping value and handles events related to ping updates and socket closures. * * This script is only used for subtracting the ping value from the clock values the server reported. */ // Variables ------------------------------------------------------------- let currentPing: number = 0; // Stores the current ping value const MAX_PING_HISTORY: number = 3; // Maximum number of ping history entries to store const pingHistory: number[] = []; // Stores the last 'MAX_PING_HISTORY' ping values // Functions ------------------------------------------------------------- // Initialize event listeners for ping and socket-closed events (function init(): void { document.addEventListener('ping', handlePingUpdate); document.addEventListener('socket-closed', handleSocketClosed); })(); /** * Event handler for the 'ping' event. * Updates the current ping value and appends it to the history. * @param event - The 'ping' event with the new ping value in event.detail. */ function handlePingUpdate(event: CustomEvent): void { currentPing = event.detail; updatePingHistory(currentPing); } /** * Event handler for the 'socket-closed' event. * Resets the current ping value without clearing the ping history. * @param event - The 'socket-closed' event. */ function handleSocketClosed(_event: CustomEvent): void { currentPing = 0; } /** * Updates the ping history with the latest ping value. * Ensures that only the last 'MAX_PING_HISTORY' ping values are kept in the history. * @param ping - The latest ping value. */ function updatePingHistory(ping: number): void { pingHistory.push(ping); if (pingHistory.length > MAX_PING_HISTORY) pingHistory.shift(); // Remove the oldest value if history exceeds MAX_PING_HISTORY } /** * Getter for the current ping value. * @returns The current ping value or 0 if no ping is stored. */ function getPing(): number { return currentPing; } /** * Returns half the current ping value. This will approximately * be the time it takes for a one-way websocket message. * @returns The current ping value or 0 if no ping is stored. */ function getHalfPing(): number { return currentPing / 2; } /** * Getter for the average ping value over the last 'MAX_PING_HISTORY' pings. * @returns The average ping value or 0 if there is no history. */ function getAveragePing(): number { if (pingHistory.length === 0) return 0; const sum = pingHistory.reduce((acc, ping) => acc + ping, 0); return sum / pingHistory.length; } // --------------------------------------------------------------------- export default { getPing, getHalfPing, getAveragePing, }; ================================================ FILE: src/client/scripts/esm/util/splines.ts ================================================ // src/client/scripts/esm/util/splines.ts /** * This script contains utility methods for working with splines. */ import type { Color } from '../../../../shared/util/math/math.js'; import type { BDCoords, Coords, DoubleCoords } from '../../../../shared/chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import space from '../game/misc/space.js'; import boardpos from '../game/rendering/boardpos.js'; import { createRenderable } from '../webgl/Renderable.js'; // Constants ------------------------------------------------------ const ZERO = bd.fromBigInt(0n); const ONE = bd.fromBigInt(1n); const TWO = bd.fromBigInt(2n); const THREE = bd.fromBigInt(3n); const FOUR = bd.fromBigInt(4n); // Functions --------------------------------------------------------------- /** * Computes a natural cubic spline for a given set of points. * @param points - Array of y-values representing the points to interpolate. * @returns Array of spline coefficients (a, b, c, d) for each segment. */ function generateCubicSplineCoefficients( points: bigint[], ): { a: BigDecimal; b: BigDecimal; c: BigDecimal; d: BigDecimal }[] { const n = points.length; if (n < 2) return []; const a: BigDecimal[] = points.slice(0, -1).map((p) => bd.fromBigInt(p)); const b: BigDecimal[] = new Array(n - 1).fill(ZERO); const c: BigDecimal[] = new Array(n).fill(ZERO); const d: BigDecimal[] = new Array(n - 1).fill(ZERO); if (n === 2) { b[0] = bd.fromBigInt(points[1]! - points[0]!); return [{ a: a[0]!, b: b[0]!, c: c[0]!, d: d[0]! }]; } // Setup tridiagonal system const rhs: BigDecimal[] = []; for (let i = 0; i < n - 2; i++) { rhs.push(bd.fromBigInt(3n * (points[i]! + points[i + 2]! - 2n * points[i + 1]!))); } const subDiag: BigDecimal[] = new Array(n - 3).fill(ONE); const mainDiag: BigDecimal[] = new Array(n - 2).fill(FOUR); const superDiag: BigDecimal[] = new Array(n - 3).fill(ONE); const cSolution = thomasAlgorithm(subDiag, mainDiag, superDiag, rhs); for (let i = 1; i <= n - 2; i++) c[i] = cSolution[i - 1]!; // Compute d and b coefficients for (let i = 0; i < n - 1; i++) { d[i] = bd.divide(bd.subtract(c[i + 1]!, c[i]!), THREE); // d[i] = (c[i + 1] - c[i]) / 3; // (points[i + 1]! - points[i]!) - (2 * c[i]! + c[i + 1]!) / 3 const b_subtrahend = bd.fromBigInt(points[i + 1]! - points[i]!); // points[i + 1]! - points[i]! const dividend = bd.add(bd.multiply(c[i]!, TWO), c[i + 1]!); // 2 * c[i]! + c[i + 1]! const quotient = bd.divide(dividend, THREE); // (2 * c[i]! + c[i + 1]!) / 3 b[i] = bd.subtract(b_subtrahend, quotient); // // (points[i + 1]! - points[i]!) - (2 * c[i]! + c[i + 1]!) / 3 } return a.map((aVal, i) => ({ a: aVal, b: b[i]!, c: c[i]!, d: d[i]! })); } /** * Solves a tridiagonal system using the Thomas algorithm. * @param a - Sub-diagonal coefficients. * @param b - Main diagonal coefficients. * @param c - Super-diagonal coefficients. * @param d - Right-hand side values. * @returns Solution array. */ function thomasAlgorithm( a: BigDecimal[], b: BigDecimal[], c: BigDecimal[], d: BigDecimal[], ): BigDecimal[] { const n = d.length; if (n === 0) return []; // Handle the 1x1 system case, which occurs when there are 3 control points. // In this case, 'a' and 'c' are empty, and 'b' and 'd' have one element. // The system is simply b[0]*x[0] = d[0], so x[0] = d[0]/b[0]. // Without this, a crash happens if you move the rose 2 hops in one move. if (n === 1) return [bd.divide(d[0]!, b[0]!)]; const cp: BigDecimal[] = [...c]; const dp: BigDecimal[] = [...d]; cp[0] = bd.divide(cp[0]!, b[0]!); dp[0] = bd.divide(dp[0]!, b[0]!); for (let i = 1; i < n; i++) { const m_denominator = bd.subtract(b[i]!, bd.multiply(a[i - 1]!, cp[i - 1]!)); // (b[i]! - a[i - 1]! * cp[i - 1]!) const m = bd.divide(ONE, m_denominator); // 1 / (b[i]! - a[i - 1]! * cp[i - 1]!) const c_i = c[i] || ZERO; // Handle case where c might be shorter cp[i] = bd.multiply(c_i, m); // (c[i] || 0) * m const dp_subtrahend = bd.multiply(a[i - 1]!, dp[i - 1]!); const dp_term = bd.subtract(d[i]!, dp_subtrahend); dp[i] = bd.multiply(dp_term, m); } for (let i = n - 2; i >= 0; i--) { const subtractor = bd.multiply(cp[i]!, dp[i + 1]!); dp[i] = bd.subtract(dp[i]!, subtractor); } return dp; } /** * Evaluates the cubic spline at a given parameter t. * @param t - Parameter value. * @param coefficients - Array of spline coefficients. * @returns Interpolated value. */ function evaluateSplineAt( t: number, coefficients: { a: BigDecimal; b: BigDecimal; c: BigDecimal; d: BigDecimal }[], ): BigDecimal { const i = Math.max(0, Math.min(coefficients.length - 1, Math.floor(t))); const { a, b, c, d } = coefficients[i]!; // Convert dt to a BigDecimal for high-precision calculations const dt = bd.fromNumber(t - i); const dt2 = bd.multiply(dt, dt); const dt3 = bd.multiply(dt2, dt); // Evaluate polynomial: a + b*dt + c*dt^2 + d*dt^3 const termB = bd.multiply(b, dt); const termC = bd.multiply(c, dt2); const termD = bd.multiply(d, dt3); return bd.add(a, bd.add(termB, bd.add(termC, termD))); } /** * Computes an interpolated trajectory along a cubic spline, generating a smooth path through given control points. * @param controlPoints - Array of 2D coordinate points defining the spline. The points of a spline are often called "knots" or "control points". * @param resolution - Number of interpolated points between each pair of control points. * @returns An array of interpolated points along the spline. */ function generateSplinePath(controlPoints: Coords[], resolution: number): BDCoords[] { // A straight line already has infinite precision if (controlPoints.length < 3) return controlPoints.map(([x, y]) => [bd.fromBigInt(x), bd.fromBigInt(y)]); // Extract the bigint x and y components into separate arrays. const xPoints = controlPoints.map((point) => point[0]); const yPoints = controlPoints.map((point) => point[1]); // Generate the spline coefficients for each axis. const xSpline = generateCubicSplineCoefficients(xPoints); const ySpline = generateCubicSplineCoefficients(yPoints); const path: BDCoords[] = []; const totalSegments = controlPoints.length - 1; // Loop through each segment of the spline. for (let i = 0; i < totalSegments; i++) { const isLastSegment = i === totalSegments - 1; // Interpolate points within the current segment. for (let k = 0; k <= resolution; k++) { // To avoid duplicating points, skip the end of a segment if it's not the final one. if (!isLastSegment && k === resolution) continue; // 't' is the parameter for spline evaluation, ranging from 0 to n-1. const t = i + k / resolution; let x: BigDecimal; let y: BigDecimal; /** * For the very last point, use the exact control point value to guarantee * it matches the input, avoiding any potential floating-point drift from 't'. * * A bug is created when the animation manager * expects there to be a piece at the last waypoint, but the last * waypoint isn't an integer because of floating point imprecision. * * This hasn't been tested again since converting to BigDecimals. */ if (isLastSegment && k === resolution) { const finalPoint = controlPoints[controlPoints.length - 1]!; x = bd.fromBigInt(finalPoint[0]); y = bd.fromBigInt(finalPoint[1]); } else { // Evaluate the spline at parameter 't' to get the interpolated coordinates. x = evaluateSplineAt(t, xSpline); y = evaluateSplineAt(t, ySpline); } path.push([x, y]); } } return path; } /** * Renders a debug visualization of the spline. * All geometric calculations are done in world space for rendering efficiency. * @param controlPoints - The spline waypoints as high-precision square coordinates. * @param width - The ribbon's desired width, specified in square units. * @param color - RGBA color for the ribbon. */ function renderSplineDebug(controlPoints: BDCoords[], width: number, color: Color): void { if (controlPoints.length < 2) throw Error('Spline requires at least 2 waypoints to render.'); // Convert all high-precision square coordinates to world-space // floating-point coordinates immediately so we can perform double arithmetic. const worldControlPoints: DoubleCoords[] = controlPoints.map((p) => space.convertCoordToWorldSpace(p), ); // Convert the desired width from square units to world units by applying the board scale. const scale = boardpos.getBoardScaleAsNumber(); const halfWorldWidth = (width * scale) / 2; const vertexData: number[] = []; const leftPoints: DoubleCoords[] = []; const rightPoints: DoubleCoords[] = []; // Compute left/right offsets per vertex using standard float math in world space. for (let i = 0; i < worldControlPoints.length; i++) { const point = worldControlPoints[i]!; let tangent: DoubleCoords; if (i === 0) { const next = worldControlPoints[i + 1]!; tangent = [next[0] - point[0], next[1] - point[1]]; } else if (i === worldControlPoints.length - 1) { const prev = worldControlPoints[i - 1]!; tangent = [point[0] - prev[0], point[1] - prev[1]]; } else { const prev = worldControlPoints[i - 1]!; const next = worldControlPoints[i + 1]!; tangent = [next[0] - prev[0], next[1] - prev[1]]; } // Normalize the tangent vector. const tLen = Math.hypot(tangent[0], tangent[1]); if (tLen !== 0) { tangent = [tangent[0] / tLen, tangent[1] / tLen]; } else { tangent = [0, 0]; } // Compute the perpendicular normal vector. const normal: DoubleCoords = [-tangent[1], tangent[0]]; // Offset positions in world space to find the ribbon edges. leftPoints.push([ point[0] + normal[0] * halfWorldWidth, point[1] + normal[1] * halfWorldWidth, ]); rightPoints.push([ point[0] - normal[0] * halfWorldWidth, point[1] - normal[1] * halfWorldWidth, ]); } // Build triangles for each segment. for (let i = 0; i < worldControlPoints.length - 1; i++) { const left0 = leftPoints[i]!; const right0 = rightPoints[i]!; const left1 = leftPoints[i + 1]!; const right1 = rightPoints[i + 1]!; // Triangle 1: left0, right0, left1 vertexData.push(...left0, ...color); vertexData.push(...right0, ...color); vertexData.push(...left1, ...color); // Triangle 2: left1, right0, right1 vertexData.push(...left1, ...color); vertexData.push(...right0, ...color); vertexData.push(...right1, ...color); } // Create and render the debug model. createRenderable(vertexData, 2, 'TRIANGLES', 'color', true).render(); } // Exports ----------------------------------------------------------------------------------------------------- export default { generateCubicSplineCoefficients, evaluateSplineAt, generateSplinePath, renderSplineDebug, }; ================================================ FILE: src/client/scripts/esm/util/svgtoimageconverter.ts ================================================ // src/client/scripts/esm/util/svgtoimageconverter.ts /** * This script can convert SVG elements into HTMLImageElements. * * It also can normalize the pixel data of an image by drawing it onto a canvas and re-serializing it. */ // Functions -------------------------------------------------------------------------- /** Converts a list of SVGs into a list of HTMLImageElements. Does this in parallel. */ async function convertSVGsToImages(svgElements: SVGElement[]): Promise { try { // Create an array of promises, where each promise resolves to an HTMLImageElement const conversionPromises = svgElements.map((svgElement) => svgToImage(svgElement)); // Wait for all the conversion promises to resolve concurrently const readyImages = await Promise.all(conversionPromises); // Optional: Append the images to the doc for debugging // for (const img of readyImages) { // document.body.appendChild(img); // } return readyImages; } catch (e) { // Although we assume individual svgToImage calls resolve, Promise.all itself // could theoretically encounter an issue, or svgToImage might throw a sync error. console.error('Error caught during conversion of SVGs to Images:', e); return []; // Return an empty array in case of unexpected errors } } /** * Converts an SVG element to an Image element by serializing the SVG and creating a data URL. * The image does NOT have a specified width or height. * @param svgElement - The SVG element to convert into an image. * @returns A promise that resolves with the created image element. */ function svgToImage(svgElement: SVGElement): Promise { const svgID = svgElement.id; // 'pawnsW' // Serialize the SVG element back to a string const svgString = new XMLSerializer().serializeToString(svgElement); // Log the SVG string for debugging purposes // console.log("SVG String: ", svgString); // Create a new image element const img = new Image(); // Convert SVG string to a data URL using encodeURIComponent for better encoding const svgData = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgString)}`; img.src = svgData; img.id = svgID; // Set its ID here so its easy to find it in the document later return new Promise((resolve, reject): void => { img.onload = (): void => { // Append the image to the document for debugging // document.body.appendChild(img); resolve(img); }; img.onerror = (err): void => { console.error(`Error loading image with ID "${svgID}"`, err); reject(new Error(`Failed to load image with ID "${svgID}"`)); }; }); } /** * Normalizes the pixel data of an image by drawing it onto a canvas and re-serializing it. * This used for patching a Firefox bug where it unintentionally darkens the image by double-multiplying the RGB channels by the alpha channel. * * We don't have to do this for the spritesheet images, because the spritesheet generator ALREADY * draws the images onto a large canvas and re-serializes them. * @param img - The image to normalize. * @returns A promise that resolves with the normalized image. */ async function normalizeImagePixelData(img: HTMLImageElement): Promise { /** The image width each piece type's image should be. */ const IMG_SIZE = 512; // High to retain as much resolution as possible during the drawing and re-serialization. // Proceed with canvas creation const canvas = document.createElement('canvas'); canvas.width = IMG_SIZE; canvas.height = IMG_SIZE; const ctx = canvas.getContext('2d'); if (ctx === null) throw new Error('2D context null.'); // Draw original image ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // Return as standardized image const processedImg = new Image(); processedImg.src = canvas.toDataURL(); processedImg.id = img.id; // Give it the same ID as the original // Wait for the image to load await processedImg.decode(); // Append the image to the document for debugging // document.body.appendChild(img); return processedImg; } // Exports ------------------------------------------------------------------------- export default { convertSVGsToImages, svgToImage, normalizeImagePixelData, }; ================================================ FILE: src/client/scripts/esm/util/thread.ts ================================================ // src/client/scripts/esm/util/thread.ts /** * This script contains a sleep method for the javascript thread. * * Javascript is single-threated, when we sleep, we don't actually * sleep the thread, but we delay the execution of the current function, * to allow other functions on the call stack to be executed before we continue. * * ZERO dependancies */ /** * Pauses the current function execution for the given amount of time, allowing * other functions in the call stack to execute before it resumes. * * This function returns a promise that resolves after the specified number of milliseconds. * @param ms - The number of milliseconds to sleep before continuing execution. * @returns A promise that resolves after the specified delay. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export default { sleep, }; ================================================ FILE: src/client/scripts/esm/util/tooltips.ts ================================================ // src/client/scripts/esm/util/tooltips.ts /** * JS-based tooltip system using event delegation. A single fixed div is appended to document.body * when the user hovers a tooltip element, avoiding any clipping issues from parent containers. * * A single set of listeners on `document.body` handles all tooltip elements, * new elements with tooltips can be added to the document at any time. * * Tooltip direction is determined by the element's class: * tooltip-d – below, centered * tooltip-dl – below, right-aligned to element * tooltip-dr – below, left-aligned to element * tooltip-u – above, centered * tooltip-ul – above, right-aligned to element * tooltip-ur – above, left-aligned to element * * Tooltip text comes from the element's data-tooltip attribute. */ import docutil from './docutil.js'; // Variables ---------------------------------------------------------------------------- const tooltipClasses: string[] = [ 'tooltip-dl', 'tooltip-d', 'tooltip-dr', 'tooltip-u', 'tooltip-ul', 'tooltip-ur', ]; /** CSS selector matching any element that is a tooltip target (has both a direction class and data-tooltip). */ const TOOLTIP_SELECTOR = tooltipClasses.map((cls) => `.${cls}[data-tooltip]`).join(', '); /** Pixels between the target element edge and the tooltip box. */ const TOOLTIP_GAP = 8; /** * Half the CSS border-width used for the arrow (px). Full arrow size = 2 × ARROW_HALF. * MUST match the `border-width` value on `#tooltip-arrow` in header.css. */ const ARROW_HALF = 5; /** Duration (ms) to wait after fading out before removing the tooltip from the DOM. * Should be slightly longer than the CSS opacity transition (0.1 s = 100 ms). */ const FADE_OUT_REMOVE_DELAY_MS = 150; /** The delay before a tooltip appears on hover. */ const TOOLTIP_DELAY_MILLIS: number = 500; /** Time after a click before the tooltip can reappear while still hovering. */ const SUPPRESS_COOLDOWN_MILLIS: number = 2000; /** If no new tooltip is viewed within this window, fast-transition mode turns off. */ const FAST_TRANSITION_COOLDOWN_MILLIS: number = 750; // State --------------------------------------------------------------------------------- /** Per-element hover/click state, lazily created on first interaction. */ interface TooltipState { isHovering: boolean; isHolding: boolean; tooltipVisible: boolean; /** Timer to show the tooltip after the hover delay. */ hoveringTimer: number | undefined; /** Timer after which tooltip suppression (from a click) is cleared. */ suppressTimer: number | undefined; /** True while the tooltip is temporarily suppressed due to a click. */ suppressed: boolean; } /** Per-element state map. WeakMap ensures GC when elements are removed from the DOM. */ const elementStates = new WeakMap(); /** If true, tooltips appear immediately without the hover delay. */ let fastTransitionMode = false; /** Timer ID for turning off fast-transition mode after the cooldown. */ let fastTransitionTimeoutID: number | undefined; /** The shared tooltip box element, created once and reused. */ let tooltipDiv: HTMLDivElement | null = null; /** The shared arrow element, created once and reused. */ let arrowDiv: HTMLDivElement | null = null; /** Timer to remove the tooltip elements from the DOM after they fade out. */ let hideTimer: number | undefined; /** The rAF id for the position-tracking loop, or undefined when not running. */ let positionLoopId: number | undefined; // Functions ---------------------------------------------------------------------------- /** Returns or creates the per-element state for a tooltip target. */ function getOrCreateState(el: Element): TooltipState { let state = elementStates.get(el); if (!state) { state = { isHovering: false, isHolding: false, tooltipVisible: false, hoveringTimer: undefined, suppressTimer: undefined, suppressed: false, }; elementStates.set(el, state); } return state; } /** * Returns the nearest ancestor (or self) of `el` that is a tooltip target, * or null if none exists. Uses the browser-optimized `Element.closest()`. */ function findTooltipAncestor(el: Element | null): HTMLElement | null { return el?.closest(TOOLTIP_SELECTOR) ?? null; } /** * Returns the tooltip direction class of an element (e.g. 'tooltip-d'), * or null if the element has none. */ function getTooltipClass(element: Element): string | null { return tooltipClasses.find((cls) => element.classList.contains(cls)) ?? null; } /** Creates the singleton tooltip box and arrow elements (called once on first use). */ function createTooltipElements(): void { tooltipDiv = document.createElement('div'); tooltipDiv.id = 'tooltip-popup'; arrowDiv = document.createElement('div'); arrowDiv.id = 'tooltip-arrow'; } /** * Shrinks the tooltip box to the minimum width that still fits the wrapped text, * removing excess horizontal padding caused by short last lines. * When text fits on a single line, this is a no-op. * The element must already be in the DOM. */ function shrinkWrapTooltip(el: HTMLDivElement): void { // Reset any width set by a previous call so CSS takes over. el.style.width = ''; // Measure height at CSS max-width (text may already be wrapped). const wrappedHeight = el.offsetHeight; // Temporarily remove the max-width cap to measure the natural (single-line) width. el.style.maxWidth = 'none'; el.style.width = 'max-content'; const naturalWidth = el.offsetWidth; // Restore CSS constraints and re-read the capped width. el.style.width = ''; el.style.maxWidth = ''; const cappedWidth = el.offsetWidth; // = min(naturalWidth, CSS max-width) if (naturalWidth <= cappedWidth) return; // no wrapping; nothing to shrink // Text wraps. Binary-search for the narrowest box that keeps the same // rendered height (i.e., the same number of wrapped lines). let lo = 0; let hi = cappedWidth; while (hi - lo > 1) { const mid = Math.ceil((lo + hi) / 2); el.style.width = `${mid}px`; if (el.offsetHeight <= wrappedHeight) hi = mid; else lo = mid; } el.style.width = `${hi}px`; } /** Enables fast-transition mode so the next tooltip appears without delay. */ function enableFastTransition(): void { if (fastTransitionMode) return; // Already on! // console.log("Enabled fast transition"); fastTransitionMode = true; } /** Cancels the timer that would exit fast-transition mode. */ function cancelFastTransitionExpiryTimer(): void { clearTimeout(fastTransitionTimeoutID); fastTransitionTimeoutID = undefined; } /** Disables fast-transition mode. */ function disableFastTransition(): void { if (!fastTransitionMode) return; // console.log("Disabled fast transition"); fastTransitionTimeoutID = undefined; fastTransitionMode = false; } /** * Positions and shows the tooltip for the given target element. * @param target - The element with the tooltip class and data-tooltip attribute. * @param direction - The tooltip direction class (e.g. 'tooltip-d'). */ function showTooltipFor(target: HTMLElement, direction: string): void { const text = target.dataset['tooltip']; if (!text) return; if (!tooltipDiv || !arrowDiv) createTooltipElements(); const tip = tooltipDiv!; const arrow = arrowDiv!; // Cancel any pending DOM removal so we can reuse the elements. clearTimeout(hideTimer); hideTimer = undefined; // Set text and make invisible for measurement. tip.textContent = text; tip.style.opacity = '0'; // Ensure elements are in the DOM so we can measure them. if (!tip.isConnected) document.body.appendChild(tip); if (!arrow.isConnected) document.body.appendChild(arrow); // Shrink the box width to the minimum needed for the wrapped text. shrinkWrapTooltip(tip); // Force a layout reflow to get accurate dimensions. const tipWidth = tip.offsetWidth; const tipHeight = tip.offsetHeight; const isDown = direction === 'tooltip-d' || direction === 'tooltip-dl' || direction === 'tooltip-dr'; /** Recomputes and applies the tooltip position relative to the current target rect. */ const updatePosition = (): void => { const targetRect = target.getBoundingClientRect(); // Vertical positioning. let tipTop: number; let arrowTop: number; if (isDown) { tipTop = targetRect.bottom + TOOLTIP_GAP; // Arrow bottom aligns exactly with tooltip box top, filling the gap cleanly. arrowTop = targetRect.bottom + TOOLTIP_GAP - ARROW_HALF * 2; arrow.className = 'tooltip-arrow-down'; } else { tipTop = targetRect.top - TOOLTIP_GAP - tipHeight; // Arrow top aligns exactly with tooltip box bottom, filling the gap cleanly. arrowTop = targetRect.top - TOOLTIP_GAP; arrow.className = 'tooltip-arrow-up'; } // Horizontal positioning of the tooltip box. let tipLeft: number; if (direction === 'tooltip-d' || direction === 'tooltip-u') { // Centered on the target. tipLeft = targetRect.left + targetRect.width / 2 - tipWidth / 2; } else if (direction === 'tooltip-dl' || direction === 'tooltip-ul') { // Right edge of tooltip aligns with right edge of target. tipLeft = targetRect.right - tipWidth; } else { // tooltip-dr: left edge of tooltip aligns with left edge of target. tipLeft = targetRect.left; } // Arrow always centered horizontally on the target. const arrowLeft = targetRect.left + targetRect.width / 2 - ARROW_HALF; // Apply computed positions. tip.style.top = `${tipTop}px`; tip.style.left = `${tipLeft}px`; arrow.style.top = `${arrowTop}px`; arrow.style.left = `${arrowLeft}px`; }; updatePosition(); // Keep the tooltip in sync with the target element every frame in case it moves. if (positionLoopId !== undefined) cancelAnimationFrame(positionLoopId); const loop = (): void => { if (!tip.isConnected) return; updatePosition(); positionLoopId = requestAnimationFrame(loop); }; positionLoopId = requestAnimationFrame(loop); arrow.style.opacity = '0'; // Two rAF frames ensure the browser has committed the opacity:0 paint before // animating to opacity:1, so the CSS transition fires correctly from 0 → 1. requestAnimationFrame(() => { requestAnimationFrame(() => { tip.style.opacity = '1'; arrow.style.opacity = '1'; }); }); } /** Fades out the tooltip and removes it from the DOM once the transition ends. */ function hideTooltipDiv(): void { if (!tooltipDiv || !arrowDiv) return; tooltipDiv.style.opacity = '0'; arrowDiv.style.opacity = '0'; clearTimeout(hideTimer); hideTimer = window.setTimeout(() => { tooltipDiv?.remove(); arrowDiv?.remove(); }, FADE_OUT_REMOVE_DELAY_MS); } /** Shows the tooltip if conditions allow. */ function tryShow(target: HTMLElement, state: TooltipState, direction: string): void { if (!state.isHovering || state.isHolding || state.suppressed) return; // If the element is no longer in the DOM, don't show the tooltip. if (!target.isConnected) return; state.tooltipVisible = true; showTooltipFor(target, direction); } /** Schedules (or immediately triggers) showing the tooltip. */ function scheduleShow(target: HTMLElement, state: TooltipState, direction: string): void { clearTimeout(state.hoveringTimer); if (fastTransitionMode) { tryShow(target, state, direction); } else { state.hoveringTimer = window.setTimeout( () => tryShow(target, state, direction), TOOLTIP_DELAY_MILLIS, ); } } /** Hides the tooltip and suppresses it temporarily (used on click). */ function suppress(state: TooltipState): void { state.suppressed = true; state.tooltipVisible = false; clearTimeout(state.hoveringTimer); state.hoveringTimer = undefined; hideTooltipDiv(); disableFastTransition(); } /** Schedules the end of the click-suppression window. */ function resetSuppressTimer(target: HTMLElement, state: TooltipState, direction: string): void { clearTimeout(state.suppressTimer); state.suppressTimer = window.setTimeout(() => { state.suppressed = false; if (state.isHovering && !state.isHolding) tryShow(target, state, direction); }, SUPPRESS_COOLDOWN_MILLIS); } // Delegated event listeners ------------------------------------------------------------ if (docutil.isMouseSupported()) { // mouseover/mouseout bubble, letting us simulate mouseenter/mouseleave via delegation. document.body.addEventListener('mouseover', (e: MouseEvent) => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; // Only fire "enter" when arriving from outside the tooltip element. const from = e.relatedTarget as Element | null; if (from && target.contains(from)) return; const state = getOrCreateState(target); const direction = getTooltipClass(target)!; state.isHovering = true; cancelFastTransitionExpiryTimer(); scheduleShow(target, state, direction); }); document.body.addEventListener('mouseout', (e: MouseEvent) => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; // Only fire "leave" when moving to outside the tooltip element. const to = e.relatedTarget as Element | null; if (to && target.contains(to)) return; const state = getOrCreateState(target); state.isHovering = false; state.isHolding = false; clearTimeout(state.hoveringTimer); // Immediately clear suppression so re-hovering works normally. state.suppressed = false; clearTimeout(state.suppressTimer); state.suppressTimer = undefined; if (state.tooltipVisible) { enableFastTransition(); fastTransitionTimeoutID = window.setTimeout( () => disableFastTransition(), FAST_TRANSITION_COOLDOWN_MILLIS, ); } state.tooltipVisible = false; hideTooltipDiv(); }); document.body.addEventListener('mousedown', (e: MouseEvent) => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; const state = getOrCreateState(target); const direction = getTooltipClass(target)!; state.isHolding = true; suppress(state); resetSuppressTimer(target, state, direction); }); document.body.addEventListener('mouseup', (e: MouseEvent) => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; const state = getOrCreateState(target); const direction = getTooltipClass(target)!; state.isHolding = false; suppress(state); resetSuppressTimer(target, state, direction); }); } else { // Touch devices: show tooltip on press, hide on release/cancel. document.body.addEventListener('touchstart', (e: TouchEvent) => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; const state = getOrCreateState(target); const direction = getTooltipClass(target)!; state.isHovering = true; state.hoveringTimer = window.setTimeout( () => tryShow(target, state, direction), TOOLTIP_DELAY_MILLIS, ); }); const onTouchEnd = (e: TouchEvent): void => { const target = findTooltipAncestor(e.target as Element | null); if (!target) return; const state = getOrCreateState(target); state.isHovering = false; clearTimeout(state.hoveringTimer); state.tooltipVisible = false; hideTooltipDiv(); }; document.body.addEventListener('touchend', onTouchEnd); document.body.addEventListener('touchcancel', onTouchEnd); } ================================================ FILE: src/client/scripts/esm/util/usernamecontainer.ts ================================================ // src/client/scripts/esm/util/usernamecontainer.ts /** * This script provides functionalities for the username container that contains the players' username, elo etc. */ import type { Rating, ServerUsernameContainer } from '../../../../shared/types.js'; import metadatautil from '../../../../shared/chess/util/metadatautil.js'; import docutil from './docutil.js'; import languagedropdown from '../components/header/dropdowns/languagedropdown.js'; // Types ---------------------------------------------------------------------------------------- /** * Such an object contains all display information for a given user */ type UsernameContainer = { properties: UsernameContainerProperties; /** A reference to the documant element container. */ element: HTMLDivElement; /** Cancel functions for any running `animateNumber` calls. */ animationCancels: Function[]; }; /** * Settings for creating HTML elements out of username containers */ type UsernameContainerProperties = { /** * Player => Clickable hyperlink to the user's profile * Guest => No clickable hyperlink * Engine => No clickable hyperlink, AND a unique SVG icon */ type: UsernameContainerType; username: UsernameItem; rating?: { value: number; confident: boolean; change?: number; }; }; type UsernameContainerType = 'player' | 'guest' | 'engine'; type UsernameItem = { /** The actual username. */ value: string; /** * Whether clicking the username should open their profile in a new window or not. * IGNORED IF TYPE === 'engine' or 'guest'. */ openInNewWindow: boolean; }; type RatingItem = { /** The actual rating */ value: number; /** Whether the rating is confident or not (low RD). If not confident, a question mark "?" is shown. */ confident: boolean; /** The change in rating of the current match, if available. */ change?: number; }; // Variables ---------------------------------------------------------------------------------------- const profileSVGSource = ''; const engineSVGSource = ''; // General functions ---------------------------------------------------------------------------------------- /** * Creates an HTML Div Element containing all information to be shown about a UsernameContainer * @param usernamecontainer - contains information for a given user * @param options - settings for how to display information * @returns HTMLDivElement */ function createUsernameContainer( type: UsernameContainerType, username: UsernameItem, rating?: RatingItem, ): UsernameContainer { const containerDiv = document.createElement('div'); // Profile SVG element const svgSource = type === 'engine' ? engineSVGSource : profileSVGSource; const svgElement = docutil.createSvgElementFromString(svgSource); containerDiv.appendChild(svgElement); if (type === 'player') { // Hyperlink const usernameHyper = document.createElement('a'); usernameHyper.href = languagedropdown.addLngQueryParamToLink( `/member/${username.value.toLowerCase()}`, ); usernameHyper.textContent = username.value; if (username.openInNewWindow) usernameHyper.target = '_blank'; usernameHyper.classList.add('username'); usernameHyper.setAttribute('user-type', type); // Alows this container's properties to be reconstructed by other scripts from just the HTML element containerDiv.appendChild(usernameHyper); } else { // No hyperlink const usernameDiv = document.createElement('div'); usernameDiv.textContent = username.value; usernameDiv.classList.add('username'); usernameDiv.setAttribute('user-type', type); // Alows this container's properties to be reconstructed by other scripts from just the HTML element containerDiv.appendChild(usernameDiv); } // rating element if (rating) { const eloDiv = document.createElement('div'); eloDiv.classList.add('elo'); containerDiv.appendChild(eloDiv); // Rating change element if (rating.change !== undefined) { const eloChangeDiv = document.createElement('div'); eloChangeDiv.classList.add('eloChange'); containerDiv.appendChild(eloChangeDiv); } } containerDiv.classList.add('username-embed'); // Construct the UsernameContainer object const properties: UsernameContainerProperties = { type, username, }; if (rating) properties.rating = rating; // Build the container object const usernameContainer: UsernameContainer = { properties, element: containerDiv, animationCancels: [], }; updateUsernameContainerRatingTextContent(usernameContainer); // If we have a rating change, animate that text if (rating?.change !== undefined) { const oldValue = rating.value - rating.change; animateRatingChange( usernameContainer, oldValue, rating.value, rating.change, rating.confident, ); } return usernameContainer; } /** * Extracts the UsernameContainerProperties from a physical html element username container. * @param containerDiv - the HTMLDivElement to extract information from * @returns a freshly created UsernameContainer or undefined, if this failed */ function extractPropertiesFromUsernameContainerElement( containerDiv: HTMLDivElement, ): ServerUsernameContainer { if (!containerDiv.classList.contains('username-embed')) throw Error('Cannot extract username container from element that is not a username embed!'); // Reconstruct type and username const usernameElem = containerDiv.querySelector('.username')!; const type = usernameElem.getAttribute('user-type') as 'player' | 'guest'; if (!type) throw Error( 'Cannot extract username container from element that does not have a user-type attribute!', ); const result: ServerUsernameContainer = { type, username: usernameElem.textContent!, }; // Reconstruct rating const eloElem = containerDiv.querySelector('.elo'); if (eloElem) result.rating = JSON.parse(eloElem.getAttribute('rating')!) as RatingItem; return result; } /** * Set child_element as the only content of parent_element, with the same classes and styling * @param child_element * @param parent_element */ function embedUsernameContainerDisplayIntoParent( child_element: HTMLDivElement, parent_element: HTMLElement, ): void { // First clear all other content of parent_element while (parent_element.firstChild) { parent_element.removeChild(parent_element.firstChild); } // Append child to parent parent_element.appendChild(child_element); } /** * Test's if the mouse click event was inside a username embed. * @param event * @returns The nearest .username-embed element, or null if the click was outside */ function wasEventClickInsideUsernameContainer(event: MouseEvent): boolean { const targetNode = event.target as Node; const el = targetNode instanceof Element ? targetNode : targetNode.parentElement; return el?.closest('.username-embed') !== null; } /** Adds the elo change div to an existing username container. */ function createEloChangeItem( usernamecontainer: UsernameContainer, newRating: Rating, ratingChange: number, ): void { if (!usernamecontainer.properties.rating) throw Error('Cannot create elo change item for usernamecontainer without rating!'); // Previous rating value const oldValue = usernamecontainer.properties.rating.value; // Update rating in usernamecontainer usernamecontainer.properties.rating = { value: newRating.value, confident: newRating.confident, change: ratingChange, }; // rating change element const eloChangeDiv = document.createElement('div'); eloChangeDiv.classList.add('eloChange'); usernamecontainer.element.appendChild(eloChangeDiv); updateUsernameContainerRatingTextContent(usernamecontainer); // Animate... animateRatingChange( usernamecontainer, oldValue, newRating.value, ratingChange, newRating.confident, ); } /** * Updates the text contents of each of the username container element's rating elements, * according to the values in the usernamecontainer properties.. */ function updateUsernameContainerRatingTextContent(usernamecontainer: UsernameContainer): void { const element = usernamecontainer.element; // Update the rating if (usernamecontainer.properties.rating) { const eloElem = element.querySelector('.elo') as HTMLDivElement; const displayRating = metadatautil.getFormattedElo(usernamecontainer.properties.rating); eloElem.textContent = `(${displayRating})`; eloElem.setAttribute('rating', JSON.stringify(usernamecontainer.properties.rating)); // Allows this container's properties to be reconstructed by other scripts from just the HTML element // Update the rating change, if available if (usernamecontainer.properties.rating.change !== undefined) { const eloChangeDiv = element.querySelector('.eloChange')!; eloChangeDiv.textContent = metadatautil.getWhiteBlackRatingDiff( usernamecontainer.properties.rating.change, ); // Color the ratingchange green or red, depending on its positivity if (usernamecontainer.properties.rating.change >= 0) { eloChangeDiv.classList.add('positive'); eloChangeDiv.classList.remove('negative'); } else { eloChangeDiv.classList.add('negative'); eloChangeDiv.classList.remove('positive'); } } } } // Animating Elo Changes ---------------------------------------------------------------------------------------- /** * Returns a function that formats an elo value into a string for going into the `.elo` element's textContent. * This function goes into the {@link animateNumber} as the `valueFormatter` parameter. * @param confident - Whether the new rating is confident or not. * @returns A function that takes a numeric value and returns the formatted text content for the elo rating. */ function createEloFormatter(confident: boolean): (_value: number) => string { // Create a text content generator return (value: number): string => { const rating: Rating = { value, confident }; const displayRating = metadatautil.getFormattedElo(rating); return `(${displayRating})`; }; } /** * Animate both the main Elo and its Δ for a given container. * @param container — the UsernameContainer whose elements we’ll animate * @param oldValue — rating before the change * @param newValue — rating after the change * @param change — the Δ to display (can be positive or negative) * @param confident — whether the rating is “confident” (for formatting) */ function animateRatingChange( container: UsernameContainer, oldValue: number, newValue: number, change: number, confident: boolean, ): void { const DURATION = 1000; // ms for both animations // find our two elements const eloElem = container.element.querySelector('.elo')! as HTMLElement; const deltaElem = container.element.querySelector('.eloChange')! as HTMLElement; // tween the main rating const mainAnim = animateNumber( eloElem, oldValue, newValue, DURATION, undefined, createEloFormatter(confident), ); container.animationCancels.push(mainAnim.cancel); // tween the change Δ const changeAnim = animateNumber( deltaElem, 0, change, DURATION, undefined, metadatautil.getWhiteBlackRatingDiff, ); container.animationCancels.push(changeAnim.cancel); } /** * Animate a numeric text value in an element from `start` to `end` over `duration` ms, * using a custom easing function and optional text content formatter. * @param element — the element whose `.textContent` will be updated * @param start — starting number * @param end — ending number * @param durationMillis — total time, in milliseconds, for the animation * @param easingFn — easing function (t from 0→1); defaults to an ease-out curve * @param valueFormatter — optional function that receives the current numeric value * and returns the string to set as textContent; defaults to `v => v.toLocaleString()` * @returns An object with a `cancel()` method to stop the animation early. */ function animateNumber( element: HTMLElement, start: number, end: number, durationMillis: number, easingFn: (_t: number) => number = (t) => 1 - Math.pow(1 - t, 2), // Default: ease-out valueFormatter: (_value: number) => string = (v) => v.toLocaleString(), ): { cancel(): void } { let frameId: number | null = null; let cancelled = false; const range = end - start; const startTime = performance.now(); /** * Internal step function for requestAnimationFrame * @param now — high-resolution timestamp passed by rAF */ function step(now: DOMHighResTimeStamp): void { if (cancelled) return; const elapsed = now - startTime; const progress = Math.min(elapsed / durationMillis, 1); const eased = easingFn(progress); const current = Math.round(start + range * eased); element.textContent = valueFormatter(current); if (progress < 1) frameId = requestAnimationFrame(step); } frameId = requestAnimationFrame(step); return { /** Cancel the animation at its next opportunity */ cancel(): void { cancelled = true; if (frameId !== null) cancelAnimationFrame(frameId); }, }; } // Exports ---------------------------------------------------------------------------------------- export default { createUsernameContainer, extractPropertiesFromUsernameContainerElement, embedUsernameContainerDisplayIntoParent, wasEventClickInsideUsernameContainer, createEloChangeItem, }; export type { UsernameContainer, UsernameItem, RatingItem }; ================================================ FILE: src/client/scripts/esm/util/validatorama.ts ================================================ // src/client/scripts/esm/util/validatorama.ts // I called it validatorama because "validator" was already something // in the Node environment or somewhere and so jsdoc wasn't auto suggesting the right one /* * Fetches an access token and our username if we are logged in. * * If we are not logged in, the server will give us a browser-id * cookie to validate our identity in future requests. */ import tokenConfig from '../../../../shared/util/tokenConfig.js'; import docutil from './docutil.js'; // Variables ---------------------------------------------------------------------------- /** Cushion time in milliseconds before the access token expires, when we'll fetch a new one. */ const ACCESS_TOKEN_CUSHION_MILLIS: number = 10_000; let reqIsOut: boolean = false; const resolvers: (() => void)[] = []; /** The timeout ID for the timer to check session expiry. */ let sessionExpiryTimer: number | undefined; let memberInfo: { signedIn: boolean; user_id?: number; username?: string; issued?: number; expires?: number; } = { signedIn: false, user_id: undefined, username: undefined, issued: undefined, expires: undefined, }; let tokenInfo: { /** Access token for authentication, if we are logged in AND have requested one! */ accessToken?: string; /** Last refresh time of the access token, in milliseconds. */ lastRefreshTime?: number; } = { accessToken: undefined, lastRefreshTime: undefined, }; // Functions ---------------------------------------------------------------------------- (function init(): void { initListeners(); // Sets our memberInfo properties if we are logged in readMemberInfoCookie(); // Most of the time we don't need an immediate access token // refreshToken(); })(); function initListeners(): void { document.addEventListener('logout', resetMemberInfo); document.addEventListener('logout', onLogout); window.addEventListener('pageshow', readMemberInfoCookie); // Fired on initial page load AND when hitting the back button to return. } /** * Checks if the access token is expired or near-expiring. * If expired, it calls `refreshToken()` to get a new one. * * If we're not signed in, the server will give/renew us a browser-id cookie for validating our identity. * @returns Resolves with the access token, or undefined if not logged in. */ async function getAccessToken(): Promise { if (reqIsOut) await waitUntilInitialRequestBack(); if (!memberInfo.signedIn) return; const timeSinceLastRefresh = Date.now() - (tokenInfo.lastRefreshTime || 0); // Check if token is expired or near expiring if ( !tokenInfo.accessToken || timeSinceLastRefresh > tokenConfig.ACCESS_TOKEN_EXPIRY_MILLIS - ACCESS_TOKEN_CUSHION_MILLIS ) { await refreshToken(); } return tokenInfo.accessToken; } /** * Inits the access token and our username if we are logged in. * * Reads the `memberInfo` cookie to get the member details (username). * If not signed in, the server will renew the browser-id cookie. * * @returns Resolves when the token refresh process is complete. */ async function refreshToken(): Promise { reqIsOut = true; try { const response = await fetch('/api/get-access-token', { method: 'POST', // Ensure it's a POST request headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, }); const result = await response.json(); if (response.ok) { // Session token (refresh token cookie) is valid! const accessToken = docutil.getCookieValue('token'); // Read access token from cookie if (!accessToken) throw new Error('Token cookie not found!'); tokenInfo = { accessToken, lastRefreshTime: Date.now() }; // Delete the token cookie after reading it docutil.deleteCookie('token'); // It's possible the server renewed our session. Let's read the memberInfo cookie again! readMemberInfoCookie(); // Dispatch event to inform other parts of the app that we are logged in. // document.dispatchEvent(new CustomEvent('login')); } else { // 403 or 500 error Likely not signed in! Our session token (refresh token cookie) was invalid or not present. console.log(`Server: ${result.message}`); deleteMemberInfoCookie(); // Dispatch a custom logout event so our header code knows to update the navigation links document.dispatchEvent(new CustomEvent('logout')); } } catch (error) { console.error('Error occurred during token refresh:', error); readMemberInfoCookie(); } finally { reqIsOut = false; // Resolve all pending promises while (resolvers.length > 0) { resolvers.shift()!(); // Get the first resolver and resolve it } } } /** * Read the memberInfo cookie, which will be present * if we have a refreshed token cookie, to grab our * username and user_id properties if we are signed in. */ function readMemberInfoCookie(): void { resetMemberInfo(); // Read the member info from the cookie // Get the URL-encoded cookie value // JSON objects can't be stringified into cookies because cookies can't hold special characters const encodedMemberInfo = docutil.getCookieValue('memberInfo'); if (!encodedMemberInfo) return; // No cookie, not signed in. // Decode the URL-encoded string const memberInfoStringified = decodeURIComponent(encodedMemberInfo); memberInfo = JSON.parse(memberInfoStringified); // { user_id, username, issued (timestamp), expires (timestamp) } memberInfo.signedIn = true; scheduleSessionLogout(); } /** Resets our member info variables as if we were logged out. */ function resetMemberInfo(): void { clearTimeout(sessionExpiryTimer); // Prevent ghost logout events after we've manually reset memberInfo = { signedIn: false }; } /** Calculates time until session expiry and sets a timer to check session status. */ function scheduleSessionLogout(): void { clearTimeout(sessionExpiryTimer); if (!memberInfo.signedIn || !memberInfo.expires) return; const timeUntilExpiry = memberInfo.expires - Date.now(); sessionExpiryTimer = window.setTimeout(() => checkSessionExpiry(), timeUntilExpiry); } /** * Callback for the session expiry timer. * Re-verifies cookie existence/expiry before deciding to dispatch logout event or reschedule. */ function checkSessionExpiry(): void { // If a refresh request is currently out, trust that logic to handle the outcome instead of forcing a logout here. if (reqIsOut) return; const encodedMemberInfo = docutil.getCookieValue('memberInfo'); // If cookie is gone, or we can't parse it, we are definitely logged out. if (!encodedMemberInfo) { // Only dispatch logout if we thought we were signed in if (memberInfo.signedIn) { console.log('Detected session expired. Dispatching logout event. - 1'); document.dispatchEvent(new CustomEvent('logout')); } return; } const info = JSON.parse(decodeURIComponent(encodedMemberInfo)); // Final check: Is it actually in the future? (has since been renewed) if (info.expires && info.expires > Date.now()) { // It was renewed! Update our local state and reschedule. readMemberInfoCookie(); } else { // Still expired. Dispatch logout. console.log('Detected session expired. Dispatching logout event. - 2'); document.dispatchEvent(new CustomEvent('logout')); } } function deleteMemberInfoCookie(): void { docutil.deleteCookie('memberInfo'); resetMemberInfo(); } function onLogout(): void { deleteMemberInfoCookie(); tokenInfo = {}; } /** * Waits until the initial request for an access token is completed. */ async function waitUntilInitialRequestBack(): Promise { if (!reqIsOut) return; // If no request is out, resolve immediately // console.log("Waiting until initial request for an access token is completed... (Delete later)"); // Create a promise that resolves when the request is completed return new Promise((resolve): void => { resolvers.push(resolve); // Add this resolver to the list }); } /** * Whether we are logged in based on whether the memberInfo cookie is present. */ function areWeLoggedIn(): boolean { return memberInfo.signedIn; } /** * Retrieves our username if we are logged in. * @returns The username, or undefined if not logged in. */ function getOurUsername(): string | undefined { return memberInfo.signedIn ? memberInfo.username : undefined; } /** * Retrieves our user_id (base 10) if we are logged in. * @returns The user_id, or undefined if not logged in. */ function getOurUserId(): number | undefined { return memberInfo.signedIn ? memberInfo.user_id : undefined; } // -------------------------------------------------------------------------------- export default { waitUntilInitialRequestBack, areWeLoggedIn, getOurUsername, getOurUserId, getAccessToken, refreshToken, }; ================================================ FILE: src/client/scripts/esm/views/admin.ts ================================================ // src/client/scripts/esm/views/admin.ts const commandInput = document.getElementById('commandInput')! as HTMLInputElement; const commandHistory = document.getElementById('commandHistory')! as HTMLTextAreaElement; const sendCommandButton = document.getElementById('sendButton')! as HTMLButtonElement; async function sendCommand(): Promise { const commandString: string = commandInput.value; if (commandString.length === 0) return; // Don't send command if the input box is empty commandInput.value = ''; const response = await fetch('command/' + commandString); commandHistory.textContent += commandString + '\n' + (await response.text()) + '\n\n'; scrollToBottom(commandHistory); } function clickSubmitIfReturnPressed(event: any): void { // 13 is the key code for Enter key if (event.keyCode === 13) sendCommandButton.click(); } /** * Automatically scrolls to the bottom of the container. * @param container - The container to scroll. */ function scrollToBottom(container: HTMLElement): void { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth', }); } sendCommandButton.addEventListener('click', sendCommand); commandInput.addEventListener('keyup', clickSubmitIfReturnPressed); ================================================ FILE: src/client/scripts/esm/views/createaccount.ts ================================================ // src/client/scripts/esm/views/createaccount.ts // The script on the createaccount page import validators from '../../../../shared/util/validators.js'; import languagedropdown from '../components/header/dropdowns/languagedropdown.js'; const element_usernameInput = document.getElementById('username') as HTMLInputElement; const element_emailInput = document.getElementById('email') as HTMLInputElement; const element_passwordInput = document.getElementById('password') as HTMLInputElement; const element_submitButton = document.getElementById('submit') as HTMLButtonElement; /** Default fetch options */ const fetchOptions: RequestInit = { headers: { 'is-fetch-request': 'true', // Custom header }, }; let usernameHasError = false; element_usernameInput.addEventListener('input', () => { // When username field changes... // Test if the value of the username input field won't be accepted. // 3-25 characters in length. // Accepted characters: A-Z 0-9 // Doesn't contain existing/reserved usernames. // Doesn't contain profain words. let usernameError = document.getElementById('usernameerror')!; // Does an error already exist? const result = validators.validateUsername(element_usernameInput.value); // If ANY error, make sure errorElement is created if (result !== validators.UsernameValidationResult.Ok) { if (!usernameError) { // Create empty errorElement usernameHasError = true; createErrorElement('usernameerror', 'username-input-line'); // Change input box to red outline element_usernameInput.style.outline = 'solid 1px red'; // Reset variable because it now exists. usernameError = document.getElementById('usernameerror')!; } const errorTranslation = validators.getUsernameErrorTranslation(result); if (errorTranslation) usernameError.textContent = translations[errorTranslation]; else usernameError.textContent = 'Invalid username (BUG, please report!)'; // Fallback message if no translation is available for this error } else if (usernameError) { // No errors, delete that error element if it exists usernameHasError = false; usernameError.remove(); element_usernameInput.removeAttribute('style'); } updateSubmitButton(); }); element_usernameInput.addEventListener('focusout', () => { // Check username availability... if (element_usernameInput.value.length === 0 || usernameHasError) return; fetch(`/createaccount/username/${element_usernameInput.value}`, fetchOptions) .then((response) => response.json()) .then((result) => { // { allowed, reason } // We've got the result back from the server, // Is this username available to use? if (result.allowed === true) return; // Not in use // ERROR! In use! usernameHasError = true; createErrorElement('usernameerror', 'username-input-line'); // Change input box to red outline element_usernameInput.style.outline = 'solid 1px red'; // Reset variable because it now exists. const usernameError = document.getElementById('usernameerror')!; // translate the message from the server if a translation is available let result_message = result.reason; // @ts-ignore if (translations[result_message]) result_message = translations[result_message]; usernameError.textContent = result_message; updateSubmitButton(); }); }); let emailHasError = false; element_emailInput.addEventListener('input', () => { // When email field changes... // Test if the email is a valid email format let emailError = document.getElementById('emailerror'); // Does an error already exist? const result = validators.validateEmail(element_emailInput.value); // If ANY error, make sure errorElement is created if (result !== validators.EmailValidationResult.Ok) { if (!emailError) { // Create empty errorElement emailHasError = true; createErrorElement('emailerror', 'emailinputline'); // Change input box to red outline element_emailInput.style.outline = 'solid 1px red'; // Reset variable because it now exists. emailError = document.getElementById('emailerror')!; } emailError.textContent = translations[validators.getEmailErrorTranslation(result)!]; } else if (emailError) { // No errors, delete that error element if it exists emailHasError = false; emailError.remove(); element_emailInput.removeAttribute('style'); } updateSubmitButton(); }); element_emailInput.addEventListener('focusout', () => { // Check email availability and functionality... // If it's blank, all the server would send back is the createaccount.html again.. if (element_emailInput.value.length > 1 && !emailHasError) { fetch(`/createaccount/email/${element_emailInput.value}`, fetchOptions) .then((response) => response.json()) .then((result) => { // We've got the result back from the server, // Is anything wrong? if (result.valid === false) { // There has been an error emailHasError = true; // We create the error text createErrorElement('emailerror', 'emailinputline'); // Change input box to red outline element_emailInput.style.outline = 'solid 1px red'; // Reset variable because it now exists. const emailError = document.getElementById('emailerror')!; // The error message from the server is already language-localized emailError.textContent = result.reason; updateSubmitButton(); } else { emailHasError = false; updateSubmitButton(); } }); } }); let passwordHasError = false; element_passwordInput.addEventListener('input', () => { // When password field changes... let passwordError = document.getElementById('passworderror'); const result = validators.validatePassword(element_passwordInput.value); if (result !== validators.PasswordValidationResult.Ok) { passwordHasError = true; if (!passwordError) { passwordError = createErrorElement('passworderror', 'password-input-line'); element_passwordInput.style.outline = 'solid 1px red'; } passwordError.textContent = translations[validators.getPasswordErrorTranslation(result)!]; } else { passwordHasError = false; if (passwordError) { passwordError.remove(); } element_passwordInput.removeAttribute('style'); } updateSubmitButton(); }); element_submitButton.addEventListener('click', (event) => { event.preventDefault(); if ( !usernameHasError && !emailHasError && !passwordHasError && element_usernameInput.value && element_emailInput.value && element_passwordInput.value ) sendForm( element_usernameInput.value, element_emailInput.value, element_passwordInput.value, ); }); /** Sends our form data to the createaccount route. */ function sendForm(username: string, email: string, password: string): void { // Disable the button and set its class to unavailable immediately. element_submitButton.disabled = true; element_submitButton.className = 'unavailable'; let OK = false; const config: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, credentials: 'same-origin', // Allows cookie to be set from this request body: JSON.stringify({ username, email, password }), }; fetch('/createaccount', config) .then((response) => { if (response.ok) OK = true; return response.json(); }) .then((_result) => { if (OK) { // Account created! // We also received the refresh token cookie to start a session. // token = docutil.getCookieValue('token') // Cookie expires in 60s window.location.href = languagedropdown.addLngQueryParamToLink( `/member/${username.toLowerCase()}`, ); } else { // Conflict, unable to make account. 409 CONFLICT window.location.href = languagedropdown.addLngQueryParamToLink('/409'); } }) // Re-enable the button after the fetch is done. // CURRENTLY ONLY RUNS WHEN a network error occurs, as for all server responses we redirect the page. .finally(() => { element_submitButton.disabled = false; // Call updateSubmitButton() to correctly set the class to 'ready' or 'unavailable' // based on the current state of the form fields. updateSubmitButton(); }); } function createErrorElement(id: string, insertAfter: string): HTMLElement { const errElement = document.createElement('div'); errElement.className = 'error'; errElement.id = id; // The element now looks like this: //
document.getElementById(insertAfter)!.insertAdjacentElement('afterend', errElement); return errElement; // Return the created element } // Greys-out submit button if there's any errors. // The click-prevention is taken care of in the submit event listener. function updateSubmitButton(): void { if ( usernameHasError || emailHasError || passwordHasError || !element_usernameInput.value || !element_emailInput.value || !element_passwordInput.value ) { element_submitButton.className = 'unavailable'; } else { // No Errors element_submitButton.className = 'ready'; } } ================================================ FILE: src/client/scripts/esm/views/guide.ts ================================================ // src/client/scripts/esm/views/guide.ts /** * This script handles the Guide page fairy piece carousel. */ /** The element that holds all fairy images and their descriptions. */ const element_FairyImg = document.getElementById('fairy-pieces')!; /** The element that holds all fairy descriptions. */ const element_FairyCard = document.getElementById('fairy-card')!; const element_FairyBack = document.getElementById('fairy-back')!; const element_FairyForward = document.getElementById('fairy-forward')!; let fairyIndex: number = 0; const maxFairyIndex = element_FairyImg.querySelectorAll('picture').length - 1; function initListeners(): void { element_FairyBack.addEventListener('click', callback_FairyBack); element_FairyForward.addEventListener('click', callback_FairyForward); } function callback_FairyBack(_event: Event): void { if (fairyIndex === 0) return; hideCurrentFairy(); fairyIndex--; revealCurrentFairy(); updateArrowTransparency(); } function callback_FairyForward(_event: Event): void { if (fairyIndex === maxFairyIndex) return; hideCurrentFairy(); fairyIndex++; revealCurrentFairy(); updateArrowTransparency(); } function hideCurrentFairy(): void { const allFairyImgs = element_FairyImg.querySelectorAll('picture'); const targetFairyImg = allFairyImgs[fairyIndex]!; targetFairyImg.classList.add('hidden'); const allFairyCards = element_FairyCard.querySelectorAll('.fairy-card-desc'); const targetFairyCard = allFairyCards[fairyIndex]!; targetFairyCard.classList.add('hidden'); } function revealCurrentFairy(): void { const allFairyImgs = element_FairyImg.querySelectorAll('picture'); const targetFairyImg = allFairyImgs[fairyIndex]!; targetFairyImg.classList.remove('hidden'); const allFairyCards = element_FairyCard.querySelectorAll('.fairy-card-desc'); const targetFairyCard = allFairyCards[fairyIndex]!; targetFairyCard.classList.remove('hidden'); } function updateArrowTransparency(): void { if (fairyIndex === 0) element_FairyBack.classList.add('opacity-0_25'); else element_FairyBack.classList.remove('opacity-0_25'); if (fairyIndex === maxFairyIndex) element_FairyForward.classList.add('opacity-0_25'); else element_FairyForward.classList.remove('opacity-0_25'); } // Initialize on page load initListeners(); ================================================ FILE: src/client/scripts/esm/views/icnvalidator.ts ================================================ // src/client/scripts/esm/views/icnvalidator.ts import * as z from 'zod'; interface VariantStats { total: number; icn: number; formulator: number; illegal: number; termination: number; } interface ValidationResults { total: number; successful: number; icnconverterErrors: number; formulatorErrors: number; illegalMoveErrors: number; terminationMismatchErrors: number; errors: ValidationError[]; variantErrors: Record; } interface ValidationError { gameIndex: number; phase: string; error: string; variant?: string; icn: string; termination?: string; result?: string; gameConclusion?: string; } /** Result message from the ICN validator worker. */ interface WorkerResult { type: 'done'; chunkId: number; results: { success: boolean; successfulCount: number; icnconverterErrors: number; formulatorErrors: number; illegalMoveErrors: number; terminationMismatchErrors: number; errors: any[]; variantErrors: Record; }; } /** * Progress message from the ICN validator worker. */ interface WorkerProgressMessage { type: 'progress'; chunkId: number; count: number; } type WorkerMessage = WorkerResult | WorkerProgressMessage; type LogType = 'info' | 'success' | 'warning' | 'error'; const SPRTGamesSchema = z.array(z.string()); let gamesData: z.infer | null = null; // Used for cancelling ongoing validation when a new file is selected let currentValidationId = 0; // Track active workers to terminate them if user cancels let activeWorkers: Worker[] = []; // File upload handling const fileInput = document.getElementById('file-input')! as HTMLInputElement; const fileName = document.getElementById('file-name')! as HTMLParagraphElement; const uploadSection = document.getElementById('upload-section')! as HTMLDivElement; const progressSection = document.getElementById('progress-section')! as HTMLDivElement; const progressFill = document.getElementById('progress-fill')! as HTMLDivElement; const progressText = document.getElementById('progress-text')! as HTMLParagraphElement; // Event Listeners fileInput.addEventListener('change', handleFileSelect); uploadSection.addEventListener('dragover', (e) => { e.preventDefault(); uploadSection.classList.add('drag-over'); }); uploadSection.addEventListener('dragleave', () => { uploadSection.classList.remove('drag-over'); }); uploadSection.addEventListener('drop', (e) => { e.preventDefault(); uploadSection.classList.remove('drag-over'); if (e.dataTransfer?.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(); } }); function handleFileSelect(): void { const file = fileInput.files?.[0]; // Reset the input so the 'change' event fires even if the same file is selected again fileInput.value = ''; if (file) { // Cancel any existing validation loop immediately currentValidationId++; terminateWorkers(); // Kill any running threads // Reset UI: Hide progress bar and results from any previous run progressSection.style.display = 'none'; document.getElementById('summary-section')!.style.display = 'none'; document.getElementById('variant-section')!.style.display = 'none'; document.getElementById('errors-section')!.style.display = 'none'; fileName.textContent = `Selected: ${file.name}`; fileName.style.color = 'var(--accent-color)'; addLog(`File selected: ${file.name}`, 'info'); // Read File const reader = new FileReader(); reader.onload = (e) => { let unvalidatedJSON: any; try { const result = e.target?.result; if (typeof result !== 'string') throw new Error('Failed to read file'); unvalidatedJSON = JSON.parse(result); } catch (error) { const message = error instanceof Error ? error.message : String(error); addLog(`✗ Error parsing JSON: ${message}`, 'error'); fileName.textContent = `❌ INVALID JSON SYNTAX: ${file.name}`; fileName.style.color = 'var(--danger-color)'; gamesData = null; return; } const parseResult = SPRTGamesSchema.safeParse(unvalidatedJSON); if (!parseResult.success) { addLog('✗ JSON schema validation failed', 'error'); const issues = parseResult.error.issues.map((i) => i.message).join(', '); addLog(`Details: ${issues}`, 'error'); fileName.textContent = `❌ INVALID SCHEMA: ${file.name}`; fileName.style.color = 'var(--danger-color)'; gamesData = null; return; } gamesData = parseResult.data; addLog(`✓ Loaded ${gamesData.length} game notation(s)`, 'success'); validateGames(); }; reader.readAsText(file); } } function terminateWorkers(): void { activeWorkers.forEach((w) => w.terminate()); activeWorkers = []; } async function validateGames(): Promise { const runId = currentValidationId; if (!gamesData) return; // -- Parallelization Setup -- // Use hardware concurrency (logic cores), default to 4 if unavailable const threadCount = navigator.hardwareConcurrency || 4; const totalGames = gamesData.length; // Initialize Result Container const globalResults: ValidationResults = { total: totalGames, successful: 0, icnconverterErrors: 0, formulatorErrors: 0, illegalMoveErrors: 0, terminationMismatchErrors: 0, errors: [], variantErrors: {}, }; // Reset UI displays document.getElementById('summary-section')!.style.display = 'none'; document.getElementById('variant-section')!.style.display = 'none'; document.getElementById('variant-stats')!.innerHTML = ''; document.getElementById('errors-section')!.style.display = 'none'; document.getElementById('error-list')!.innerHTML = ''; // Reset progress UI immediately progressFill.style.width = '0%'; progressFill.textContent = '0%'; progressText.textContent = `Processed 0 / ${totalGames}`; progressSection.style.display = 'block'; addLog(`Starting parallel validation with ${threadCount} workers...`, 'info'); let gamesProcessed = 0; let workersDone = 0; // Determine chunk size const chunkSize = Math.ceil(totalGames / threadCount); for (let i = 0; i < threadCount; i++) { // Stop if cancelled during spawn loop if (runId !== currentValidationId) return; const start = i * chunkSize; const end = Math.min(start + chunkSize, totalGames); // If we ran out of games (e.g., 3 games, 4 threads), skip if (start >= totalGames) { workersDone++; // Count as done so we don't hang continue; } // Prepare data slice (Add index so we know which game is which) const slice = gamesData.slice(start, end).map((game, idx) => ({ index: start + idx + 1, // 1-based index for UI icn: game, })); // Spawn Worker const worker = new Worker('scripts/esm/workers/icnvalidator.worker.js', { type: 'module' }); activeWorkers.push(worker); // Handle Worker Loading Errors (e.g., 404, script syntax error) worker.onerror = (error) => { if (runId !== currentValidationId) return; const msg = error.message || 'Failed to load worker script'; addLog(`✗ System Error: Worker failed to start - ${msg}`, 'error'); // Update UI to show critical failure fileName.textContent = `❌ SYSTEM ERROR: Worker Script Failed`; fileName.style.color = 'var(--danger-color)'; // Abort the entire run terminateWorkers(); currentValidationId++; // Invalidate runId to stop loop/other callbacks progressSection.style.display = 'none'; }; // Track progress specific to this worker to avoid double-counting at the end let itemsProcessedInChunk = 0; // Handle Messages worker.onmessage = (e: MessageEvent) => { if (e.data.type === 'progress') { // Update counters const count = e.data.count; itemsProcessedInChunk += count; gamesProcessed += count; // Update UI immediately const pct = ((gamesProcessed / totalGames) * 100).toFixed(1); progressFill.style.width = pct + '%'; progressFill.textContent = pct + '%'; progressText.textContent = `Processed ${gamesProcessed} / ${totalGames}`; } else if (e.data.type === 'done') { // Worker finished its batch const { results } = e.data; // Merge Counts globalResults.successful += results.successfulCount; globalResults.icnconverterErrors += results.icnconverterErrors; globalResults.formulatorErrors += results.formulatorErrors; globalResults.illegalMoveErrors += results.illegalMoveErrors; globalResults.terminationMismatchErrors += results.terminationMismatchErrors; // Merge Arrays/Objects globalResults.errors.push(...results.errors); // Merge Variant Stats for (const [variant, stats] of Object.entries( results.variantErrors as Record, )) { if (!globalResults.variantErrors[variant]) { globalResults.variantErrors[variant] = { ...stats }; } else { const existing = globalResults.variantErrors[variant]!; existing.total += stats.total; existing.icn += stats.icn; existing.formulator += stats.formulator; existing.illegal += stats.illegal; existing.termination += stats.termination; } } // Calculate remaining items (errors or final batch < 50) that weren't reported in progress const chunkTotal = end - start; const remainder = chunkTotal - itemsProcessedInChunk; gamesProcessed += remainder; workersDone++; // Final UI update for this chunk const pct = ((gamesProcessed / totalGames) * 100).toFixed(1); progressFill.style.width = pct + '%'; progressFill.textContent = pct + '%'; progressText.textContent = `Processed ${gamesProcessed} / ${totalGames}`; // Check completion if (workersDone === threadCount) { // Sort errors by index so they appear in order globalResults.errors.sort((a, b) => a.gameIndex - b.gameIndex); finishValidation(globalResults, runId); } } }; // Start the worker worker.postMessage({ chunkId: i, games: slice }); } } function finishValidation(results: ValidationResults, runId: number): void { if (runId !== currentValidationId) return; progressSection.style.display = 'none'; displayResults(results); const pct = results.total > 0 ? (results.successful / results.total) * 100 : 0; let logType: LogType = 'error'; if (results.successful === results.total) logType = 'success'; else if (pct >= 90) logType = 'warning'; addLog(`✓ Validation complete: ${results.successful}/${results.total} successful`, logType); terminateWorkers(); // Clean up } // --- Display Logic --- function displayResults(results: ValidationResults): void { // Percentage Calculation const percentage = results.total > 0 ? (results.successful / results.total) * 100 : 0; const percentageStr = Number.isInteger(percentage) ? percentage.toString() + '%' : percentage.toFixed(1) + '%'; // Hero Stats const ratioEl = document.getElementById('pass-ratio')!; const percentEl = document.getElementById('pass-percentage')!; ratioEl.textContent = `${results.successful} / ${results.total}`; percentEl.textContent = percentageStr; // Set colors based on score ratioEl.className = 'hero-value'; percentEl.className = 'hero-value'; if (results.successful === results.total && results.total > 0) { ratioEl.classList.add('perfect'); percentEl.classList.add('perfect'); } else if (percentage >= 90) { ratioEl.classList.add('good'); percentEl.classList.add('good'); } else if (percentage >= 80) { ratioEl.classList.add('bad'); percentEl.classList.add('bad'); } else { ratioEl.classList.add('terrible'); percentEl.classList.add('terrible'); } // Update Grid const updateStat = (id: string, count: number): void => { const el = document.getElementById(id)!; el.textContent = String(count); el.className = 'stat-value'; if (count === 0) el.classList.add('success'); else if (count < 10) el.classList.add('warning'); else el.classList.add('error'); }; updateStat('icnconverter-errors', results.icnconverterErrors); updateStat('formulator-errors', results.formulatorErrors); updateStat('illegal-move-errors', results.illegalMoveErrors); updateStat('termination-mismatch-errors', results.terminationMismatchErrors); document.getElementById('summary-section')!.style.display = 'block'; // Variant Stats if (Object.keys(results.variantErrors).length > 0) { const variantStats = document.getElementById('variant-stats')!; variantStats.innerHTML = ''; const sortedVariants = Object.entries(results.variantErrors).sort( (a, b) => b[1].total - a[1].total, ); for (const [variant, stats] of sortedVariants) { const variantItem = document.createElement('div'); variantItem.className = 'variant-item'; const buildStat = ( label: string, count: number, isAlwaysWarn: boolean = false, ): string => { if (count === 0) return ''; let type = 'warn'; if (!isAlwaysWarn && count > 3) type = 'err'; return `
${count} ${label}
`; }; const totalClass = stats.total > 4 ? 'err' : 'warn'; variantItem.innerHTML = `
${variant} ${stats.total} total error(s)
${buildStat('ICN', stats.icn, true)} ${buildStat('Formulator', stats.formulator)} ${buildStat('Illegal', stats.illegal)} ${buildStat('Mismatch', stats.termination)}
`; variantStats.appendChild(variantItem); } document.getElementById('variant-section')!.style.display = 'block'; } // Error List if (results.errors.length > 0) { const errorList = document.getElementById('error-list')!; errorList.innerHTML = ''; for (const error of results.errors) { const errorItem = document.createElement('div'); errorItem.className = `error-item ${error.phase}`; let metadataHtml = ''; if (error.phase === 'termination-mismatch') { metadataHtml = `
Termination: ${error.termination || 'undefined'}
Result: ${error.result || 'undefined'}
Game Conclusion: ${JSON.stringify(error.gameConclusion) || 'undefined'}
`; } errorItem.innerHTML = `
Game #${error.gameIndex}${error.variant ? ` - ${error.variant}` : ''} ${error.phase}
${error.error}
${metadataHtml}
View ICN snippet
${error.icn}
`; errorList.appendChild(errorItem); } document.getElementById('errors-section')!.style.display = 'block'; } } function addLog(message: string, type: LogType = 'info'): void { const logOutput = document.getElementById('log-output')!; const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logOutput.appendChild(entry); logOutput.scrollTop = logOutput.scrollHeight; } ================================================ FILE: src/client/scripts/esm/views/index.ts ================================================ // src/client/scripts/esm/views/index.ts /** * Type definition for a contributor object. */ interface Contributor { name: string; contributionCount: number; linkUrl: string; iconUrl: string; } /** * Fetches GitHub contributors and appends them to the document. */ (async function fetchGitHubContributors(): Promise { try { const githubContributors = document.querySelector('.github-container'); if (!githubContributors) { console.warn('GitHub contributors container not found.'); return; } const response = await fetch('/api/contributors'); if (!response.ok) { throw new Error(`Failed to fetch contributors: ${response.statusText}`); } const contributors: Contributor[] = await response.json(); const fragment = document.createDocumentFragment(); contributors.forEach((contributor) => { const link = document.createElement('a'); link.href = contributor.linkUrl; const iconImg = document.createElement('img'); iconImg.src = contributor.iconUrl; const githubStatsContainer = document.createElement('div'); githubStatsContainer.classList.add('github-stats'); const name = document.createElement('p'); name.classList.add('name'); name.innerText = contributor.name; const paragraph = document.createElement('p'); paragraph.classList.add('contribution-count'); const contributionCountTranslationName = contributor.contributionCount === 1 ? 'contribution_count_singular' : 'contribution_count_plural'; paragraph.innerText = `${translations[contributionCountTranslationName]?.[0] || ''}${contributor.contributionCount}${translations[contributionCountTranslationName]?.[1] || ''}`; githubStatsContainer.appendChild(name); githubStatsContainer.appendChild(paragraph); link.appendChild(iconImg); link.appendChild(githubStatsContainer); fragment.appendChild(link); }); githubContributors.appendChild(fragment); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); console.error(`Error during loading of contributor list: ${errMsg}`); } })(); ================================================ FILE: src/client/scripts/esm/views/leaderboard.ts ================================================ // src/client/scripts/esm/views/leaderboard.ts /* * This script: * * * Fetches the data of the leaderboard page we're viewing * so we can display that info. */ import type { VariantCode } from '../../../../shared/chess/variants/variantdictionary.js'; import type { UsernameItem } from '../util/usernamecontainer.js'; import { Leaderboards, VariantLeaderboards, } from '../../../../shared/chess/variants/validleaderboard.js'; import validatorama from '../util/validatorama.js'; import usernamecontainer from '../util/usernamecontainer.js'; // --- DOM Element Selection --- const element_LeaderboardContainer = document.getElementById('leaderboard-table')!; const element_supportedVariants = document.getElementById('supported-variants')!; const element_ShowMoreButton: HTMLButtonElement = document.getElementById( 'show_more_button', )! as HTMLButtonElement; const element_UserRankingText = document.getElementById('user_ranking_text')!; const element_UserRanking = document.getElementById('user_ranking')!; // --- Variables --- /** Number of players to be shown on leaderboard page load */ const LEADERBOARD_LENGTH_ON_LOAD = 50; /** Number of players to be added on show more button press */ const LEADERBOARD_SHOW_MORE_BUTTON_INCREMENT = 50; /** Leaderboard to be displayed */ const leaderboard_id = Leaderboards.INFINITY; /** Body of leaderboard table, as created in createEmptyLeaderboardTable() */ let element_LeaderboardTableBody: HTMLTableSectionElement; /** Running start rank: highest leaderboard position not shown on leaderboard yet */ let running_start_rank = 1; /** * Username of the player, if he is logged in, else undefined, * AT THE TIME OF THE initial request for our world ranking. */ let loggedInAs: string | undefined; /** Whether the page has already been initialized once */ let initialized = false; // --- Initialization --- (async function loadLeaderboardData(): Promise { setSupportedVariantsDisplay(); createEmptyLeaderboardTable(); // On page load, we wait for validatorama to renew our session if needed, // as the server reads our session info to know who to return a global ranking for. await validatorama.waitUntilInitialRequestBack(); loggedInAs = validatorama.getOurUsername(); await populateTable(LEADERBOARD_LENGTH_ON_LOAD); initialized = true; element_ShowMoreButton.addEventListener('click', showMorePlayers); })(); // --- Functions --- /** * Set the text below the leaderboard table, explaining which variants belong to it */ function setSupportedVariantsDisplay(): void { const variantslist: string[] = []; Object.entries(VariantLeaderboards).forEach(([variant, leaderboard]) => { if (leaderboard !== leaderboard_id) return; variantslist.push(variant in translations ? translations[variant as VariantCode] : variant); }); element_supportedVariants.textContent = `${translations.supported_variants} ${variantslist.join(', ')}.`; } /** * Create an empty leaderboard table upon page initialization */ function createEmptyLeaderboardTable(): void { // Create table const table = document.createElement('table'); // Create header of table const thead = document.createElement('thead'); thead.innerHTML = ` ${translations.rank} ${translations.player} ${translations.rating} `; table.appendChild(thead); // Create body of table element_LeaderboardTableBody = document.createElement('tbody'); table.appendChild(element_LeaderboardTableBody); element_LeaderboardContainer.appendChild(table); } /** * Populate the leaderboard table for the chosen leaderboard by adding the next top n players. * If initialized === false, then this function also populates the "global ranking" element at the top * @param n_players - number of players to add to table */ async function populateTable(n_players: number): Promise { const config: RequestInit = { method: 'GET', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, }; try { // Make server request // We need to fetch n_players + 1 and only display n_players in order to know whether the "Show more" button needs to be hidden // If initialized === false and the player is logged in, we also set find_requester_rank to 1, if possible, in order to request his rank from the server on the first page load const find_requester_rank = !initialized && loggedInAs !== undefined ? 1 : 0; const response = await fetch( `/leaderboard/top/${leaderboard_id}/${running_start_rank}/${n_players + 1}/${find_requester_rank}`, config, ); if (response.status === 404 || response.status === 500 || !response.ok) { console.error( 'Failed to fetch leaderboard data:', response.status, response.statusText, ); return; } const results = await response.json(); console.log(results); // Now populate the "your global rank" text at the top if possible if (!initialized && results.requesterData?.rank_string !== undefined) { element_UserRankingText.classList.remove('hidden'); element_UserRanking.textContent = results.requesterData.rank_string; } // Iterate through all results.leaderboardData and add a row to the table body for each of them let rank = running_start_rank; results.leaderboardData.forEach((player: { username: string; elo: string }) => { if (rank >= running_start_rank + n_players) return; const row = document.createElement('tr'); // Create and append for rank const rankCell = document.createElement('td'); rankCell.textContent = `${rank}`; row.appendChild(rankCell); // Create and append for username const usernameCell = document.createElement('td'); const username_item: UsernameItem = { value: player.username, openInNewWindow: false }; const usernameContainer = usernamecontainer.createUsernameContainer( 'player', username_item, ); usernamecontainer.embedUsernameContainerDisplayIntoParent( usernameContainer.element, usernameCell, ); usernameCell.classList.add('fade-element'); // Usernames fade out instead of overflowing their container row.appendChild(usernameCell); // Create and append for elo const eloCell = document.createElement('td'); eloCell.textContent = player.elo; row.appendChild(eloCell); // Append the completed row to the table body element_LeaderboardTableBody.appendChild(row); // Color row of logged in user if (loggedInAs === player.username) row.classList.add('logged_in_user_entry'); rank++; }); // Update running_start_rank running_start_rank += n_players; // Hide "show more" button if not enough players were returned by server if (results.leaderboardData.length < n_players + 1) element_ShowMoreButton.classList.add('hidden'); else element_ShowMoreButton.classList.remove('hidden'); } catch (error) { console.error('Error loading leaderboard data:', error); } } /** * Populate the leaderboard table with the next highest rated players */ async function showMorePlayers(): Promise { // disable the button so it can’t be clicked again while we’re fetching element_ShowMoreButton.disabled = true; try { await populateTable(LEADERBOARD_SHOW_MORE_BUTTON_INCREMENT); } finally { // re-enable regardless of success or failure element_ShowMoreButton.disabled = false; } } ================================================ FILE: src/client/scripts/esm/views/login.ts ================================================ // src/client/scripts/esm/views/login.ts /** * This script handles the client-side logic for the login and forgot-password forms. */ // --- Element Selectors --- const element_usernameInput = document.getElementById('username') as HTMLInputElement; const element_passwordInput = document.getElementById('password') as HTMLInputElement; const element_submitButton = document.getElementById('submit') as HTMLInputElement; const element_forgotLink = document.getElementById('forgot-link') as HTMLAnchorElement; const element_backToLoginLink = document.getElementById('back-to-login-link') as HTMLAnchorElement; const element_forgotEmailInput = document.getElementById('forgot-email') as HTMLInputElement; const element_forgotSubmitButton = document.getElementById('forgot-submit') as HTMLInputElement; const element_loginForm = document.getElementById('login-form') as HTMLFormElement; const element_forgotPasswordForm = document.getElementById( 'forgot-password-form', ) as HTMLFormElement; let messageElement: HTMLElement | undefined = undefined; // --- Utility Functions --- /** * Reads a query‐param from the current URL. * @param name - The name of the parameter to read. * @returns The parameter's value, or null if not present. */ function getQueryParam(name: string): string | null { const urlParams = new URLSearchParams(window.location.search); return urlParams.get(name); } /** * Toggles `.ready` vs `.unavailable` on a button depending on `isReady`. * @param btn - The button element to toggle classes on. * @param isReady - Boolean indicating if the button should be in a 'ready' state. */ function toggleButtonState(btn: HTMLElement, isReady: boolean): void { btn.classList.toggle('ready', isReady); btn.classList.toggle('unavailable', !isReady); } // --- Core Logic --- /** * Creates a message
below a target element, with given classes. * Removes any existing message with the same ID first. * Sets ARIA attributes for accessibility. * @param id - The ID to assign to the new message element. * @param insertAfterId - The ID of an existing element to insert after. * @param initialClass - The CSS class to apply initially ('error' | 'success'). * @returns The created HTMLElement or undefined on failure. */ function createMessageElement( id: string, insertAfterId: string, initialClass: 'error' | 'success', message: string, ): HTMLElement | undefined { const existingMsg = document.getElementById(id); if (existingMsg) existingMsg.remove(); if (messageElement && messageElement.id === id) messageElement = undefined; const el = document.createElement('div'); el.id = id; el.className = initialClass; el.setAttribute('role', 'alert'); el.setAttribute('aria-live', initialClass === 'error' ? 'assertive' : 'polite'); el.textContent = message; const anchorElement = document.getElementById(insertAfterId); if (anchorElement && anchorElement.parentNode) { anchorElement.parentNode.insertBefore(el, anchorElement.nextSibling); } else { console.error( `[DOM Error] Anchor element with ID '${insertAfterId}' not found for message insertion.`, ); const visibleForm = element_loginForm && !element_loginForm.classList.contains('hidden') ? element_loginForm : element_forgotPasswordForm; visibleForm.appendChild(el); } return el; } /** * Clears any currently displayed message element from the DOM. */ function clearMessage(): void { if (messageElement) { messageElement.remove(); messageElement = undefined; } } /** * Updates the login submit button's state (ready/unavailable). */ function updateSubmitButton(): void { const isMessageBlocking = messageElement && messageElement.id === 'login-error-message'; const isReady = !!( element_usernameInput.value.trim() && element_passwordInput.value.trim() && !isMessageBlocking ); toggleButtonState(element_submitButton, isReady); } /** * Updates the forgot-password submit button's state (ready/unavailable). */ function updateForgotSubmitButton(): void { const isMessageBlocking = messageElement && messageElement.id === 'forgot-message'; const isReady = !!(element_forgotEmailInput.value.trim() && !isMessageBlocking); toggleButtonState(element_forgotSubmitButton, isReady); } /** * Handles user input on username/password/forgot inputs. * Clears messages and updates button states accordingly. */ function handleInput(): void { clearMessage(); updateSubmitButton(); updateForgotSubmitButton(); } /** * Shows the login form and hides the forgot-password form. * Manages ARIA attributes and focus. */ function showLoginForm(): void { clearMessage(); element_loginForm.classList.remove('hidden'); element_forgotPasswordForm.classList.add('hidden'); element_forgotLink.classList.remove('hidden'); element_backToLoginLink.classList.add('hidden'); element_forgotEmailInput.value = ''; element_usernameInput.focus(); updateSubmitButton(); updateForgotSubmitButton(); } /** * Shows the forgot-password form and hides the login form. * Manages ARIA attributes and focus. */ function showForgotPasswordForm(): void { clearMessage(); element_loginForm.classList.add('hidden'); element_forgotPasswordForm.classList.remove('hidden'); element_forgotLink.classList.add('hidden'); element_backToLoginLink.classList.remove('hidden'); element_usernameInput.value = ''; element_passwordInput.value = ''; element_forgotEmailInput.focus(); updateSubmitButton(); updateForgotSubmitButton(); } /** * Sends a login request to the server. * @param username - The user's username (case preserved). * @param password - The user's plaintext password. */ async function sendLogin(username: string, password: string): Promise { element_submitButton.disabled = true; toggleButtonState(element_submitButton, false); clearMessage(); try { const response = await fetch('/auth', { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true' }, credentials: 'same-origin', body: JSON.stringify({ username, password }), }); const result = (await response.json()) as { message: string }; if (response.ok) { // SUCCESS const redirectTo = getQueryParam('redirectTo'); if (redirectTo) window.location.href = redirectTo; else window.location.href = `/member/${username.toLowerCase()}`; } else { // NOT OK messageElement = createMessageElement( 'login-error-message', 'password-input-line', 'error', result.message, ); } } catch (e: unknown) { console.error('Login fetch/processing error:', e); messageElement = createMessageElement( 'login-error-message', 'password-input-line', 'error', translations['network-error'], ); } element_submitButton.disabled = false; updateSubmitButton(); } /** * Sends a forgot-password request to the server. * @param email - The email address to send password-reset instructions to. */ async function sendForgotPasswordRequest(email: string): Promise { element_forgotSubmitButton.disabled = true; toggleButtonState(element_forgotSubmitButton, false); clearMessage(); try { const response = await fetch('/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true' }, body: JSON.stringify({ email }), }); const result = (await response.json()) as { message: string }; if (response.ok) messageElement = createMessageElement( 'forgot-message', 'email-input-line', 'success', result.message, ); else messageElement = createMessageElement( 'forgot-message', 'email-input-line', 'error', result.message, ); } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); console.error('Forgot password fetch/processing error:', errorMessage); messageElement = createMessageElement( 'forgot-message', 'email-input-line', 'error', translations['network-error'], ); } element_forgotSubmitButton.disabled = false; updateForgotSubmitButton(); } // --- Script Entry Point --- if ( !element_usernameInput || !element_passwordInput || !element_forgotEmailInput || !element_loginForm || !element_forgotPasswordForm || !element_submitButton || !element_forgotSubmitButton || !element_forgotLink || !element_backToLoginLink ) { throw Error('Required input elements are missing from the DOM.'); } // --- Event Listener Setup --- element_usernameInput.addEventListener('input', handleInput); element_passwordInput.addEventListener('input', handleInput); element_forgotEmailInput.addEventListener('input', handleInput); element_forgotLink.addEventListener('click', (event: MouseEvent): void => { event.preventDefault(); showForgotPasswordForm(); }); element_backToLoginLink.addEventListener('click', (event: MouseEvent): void => { event.preventDefault(); showLoginForm(); }); element_loginForm.addEventListener('submit', (event: SubmitEvent): void => { event.preventDefault(); if ( element_submitButton?.classList.contains('ready') && (!messageElement || messageElement.id !== 'login-error-message') ) { sendLogin(element_usernameInput.value, element_passwordInput.value); } }); element_forgotPasswordForm.addEventListener('submit', (event: SubmitEvent): void => { event.preventDefault(); if (element_forgotSubmitButton?.classList.contains('ready')) { if (element_forgotEmailInput.value.trim() !== '') { sendForgotPasswordRequest(element_forgotEmailInput.value); } } }); // --- Initial Setup --- updateSubmitButton(); updateForgotSubmitButton(); element_usernameInput.focus(); ================================================ FILE: src/client/scripts/esm/views/member.ts ================================================ // src/client/scripts/esm/views/member.ts /* * This script: * * * Fetches the data of the member's page we're viewing * so we can display that info. * * * Dynamically adjusts the font-size of the username. * Resends confirmation emails upon clicking the button. * * * Deletes account when button clicked and password entered. */ import validcheckmates from '../../../../shared/chess/util/validcheckmates.js'; import docutil from '../util/docutil.js'; import validatorama from '../util/validatorama.js'; import languagedropdown from '../components/header/dropdowns/languagedropdown.js'; // Types --------------------------------------------------------------------------------- interface MemberData { joined: string; seen: string; username: string; checkmates_beaten: string; ranked_elo: string; infinity_leaderboard_position: number | undefined; infinity_leaderboard_rating_deviation: number | undefined; // Only present/relevant if viewing our own profile email?: string; verified?: boolean; verified_notified?: boolean; // True if they've seen the "thank you" message. } // Elements ----------------------------------------------------------------------- const element_verifyErrorElement = document.getElementById('verifyerror')!; const element_verifyConfirmElement = document.getElementById('verifyconfirm')!; const element_sendEmail = document.getElementById('sendemail') as HTMLAnchorElement; const element_memberName = document.getElementById('membername')!; const element_checkmateBadgeBronze = document.getElementById( 'checkmate-badge-bronze', ) as HTMLImageElement; const element_checkmateBadgeSilver = document.getElementById( 'checkmate-badge-silver', ) as HTMLImageElement; const element_checkmateBadgeGold = document.getElementById( 'checkmate-badge-gold', ) as HTMLImageElement; const element_showAccountInfo = document.getElementById('show-account-info') as HTMLButtonElement; const element_deleteAccount = document.getElementById('delete-account') as HTMLButtonElement; const element_accountInfo = document.getElementById('accountinfo')!; const element_email = document.getElementById('email')!; // --- Event Listeners Setup --- element_sendEmail.addEventListener('click', resendConfirmEmail); element_showAccountInfo.addEventListener('click', showAccountInfo); // Note: deleteAccount listener added later conditionally // --- State --- let isOurProfile: boolean = false; const member: string = docutil.getLastSegmentOfURL(); // Assuming returns string // --- Initialization --- (async function loadMemberData(): Promise { // We have to wait for validatorama here because it might be attempting // to refresh our session in which case our session cookies will change // so our refresh token in this here fetch request here would then be invalid await validatorama.waitUntilInitialRequestBack(); const config: RequestInit = { method: 'GET', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, }; // Server reads refresh token cookie, no Authorization header needed here as per original comments try { const response = await fetch(`/member/${member}/data`, config); if (response.status === 404) { window.location.href = languagedropdown.addLngQueryParamToLink('/404'); // Use href for navigation return; } if (response.status === 500) { window.location.href = languagedropdown.addLngQueryParamToLink('/500'); return; } if (!response.ok) { // Handle other potential errors if needed console.error('Failed to fetch member data:', response.status, response.statusText); // Potentially redirect to an error page or show a message // For now, let's assume it resolves to JSON even on error based on later code // but ideally, handle non-JSON error responses too. return; } const result: MemberData = await response.json(); console.log(result); // { joined, seen, username, email, verified, checkmates_beaten, ranked_elo } // Change on-screen data of the member element_memberName.textContent = result.username; const joinedElement = document.getElementById('joined')!; joinedElement.textContent = result.joined; const seenElement = document.getElementById('seen')!; seenElement.textContent = result.seen; updateCompletedCheckmatesInformation(result.checkmates_beaten); const eloElement = document.getElementById('ranked_elo')!; eloElement.textContent = result.ranked_elo; const infinityLeaderboardPositionElement = document.getElementById( 'infinity_leaderboard_position', )!; infinityLeaderboardPositionElement.textContent = result.infinity_leaderboard_position === undefined ? '?' : result.ranked_elo.slice(-1) !== '?' ? '#' + String(result.infinity_leaderboard_position) : `#${result.infinity_leaderboard_position}?`; const infinityLeaderboardRatingDeviationElement = document.getElementById( 'infinity_leaderboard_rating_deviation', )!; infinityLeaderboardRatingDeviationElement.textContent = result.infinity_leaderboard_rating_deviation === undefined ? '?' : String(result.infinity_leaderboard_rating_deviation); const loggedInAs = validatorama.getOurUsername(); // Assuming returns string | null // Is it our own profile? if (loggedInAs && loggedInAs === result.username) { isOurProfile = true; // --- Verification Banner Logic (Runs ONLY for our own profile) --- // The API only sends these properties if we are viewing our own profile. if (result.verified === false) { // If they are not verified, show the "Please Verify" error banner. element_verifyErrorElement.classList.remove('hidden'); } else if (result.verified === true && result.verified_notified === false) { // If they ARE verified, but have NOT been notified yet, show the "Thank you" confirmation banner. // The server will have now marked them as notified for all future page loads. element_verifyConfirmElement.classList.remove('hidden'); } // --- Display elements specific to own profile --- element_showAccountInfo.classList.remove('hidden'); element_deleteAccount.classList.remove('hidden'); element_deleteAccount.addEventListener('click', () => removeAccount(true)); element_email.textContent = result.email!; } // Change username text size depending on character count recalcUsernameSize(); } catch (error) { console.error('Error loading member data:', error); // Redirect to a generic error page or display an error message // window.location.href = languagedropdown.addLngQueryParamToLink('/500'); // Example } })(); /** * Updates the counter on your profile telling you how many total checkmate practices you have beaten. * Also updates the badges. * "Practice Mode Progress: 3 / 33" * @param checkmates_beaten - Comma-delimited string of beaten checkmate IDs. */ function updateCompletedCheckmatesInformation(checkmates_beaten: string): void { const practiceProgressElement = document.getElementById('practice_progress')!; const completedCheckmates = checkmates_beaten ? checkmates_beaten.match(/[^,]+/g) || [] : []; // Handle empty/null string const numCompleted = completedCheckmates.length; const numTotal = Object.values(validcheckmates.validCheckmates).flat().length; practiceProgressElement.textContent = `${numCompleted} / ${numTotal}`; let shownBadge: HTMLImageElement | null = null; if (numCompleted >= numTotal) shownBadge = element_checkmateBadgeGold; else if (numCompleted >= 0.75 * numTotal) shownBadge = element_checkmateBadgeSilver; else if (numCompleted >= 0.5 * numTotal) shownBadge = element_checkmateBadgeBronze; // Ensure only the correct badge (or none) is shown [ element_checkmateBadgeBronze, element_checkmateBadgeSilver, element_checkmateBadgeGold, ].forEach((badge) => { if (badge === shownBadge) badge.classList.remove('hidden'); else badge.classList.add('hidden'); }); } /** Reveals the account information section. */ function showAccountInfo(): void { // Called from inside the html via event listener element_showAccountInfo.classList.add('hidden'); element_accountInfo.classList.remove('hidden'); } /** * Handles the account deletion process. * @param confirmation - Whether to show the initial confirmation dialog. */ async function removeAccount(confirmation: boolean): Promise { if (confirmation) { if (!confirm(translations['js-confirm_delete'])) return; // User cancelled the initial confirmation } const password = prompt(translations['js-enter_password']); const cancelWasPressed = password === null; if (cancelWasPressed) return; // User pressed Cancel in the password prompt // Password entered (even if empty string), proceed with deletion attempt const config: RequestInit = { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, body: JSON.stringify({ password }), // Send password in body credentials: 'same-origin', // Allows cookies (like session/CSRF) to be sent }; try { const response = await fetch(`/member/${member}/delete`, config); if (!response.ok) { // Probably incorrect password // Attempt to parse error message from server const result: { message: string } = await response.json(); alert(result.message); // Show server error message // Call removeAccount(false) again to re-prompt for password removeAccount(false); // Re-prompt without initial confirmation } else { // Deletion successful, redirect to homepage window.location.href = languagedropdown.addLngQueryParamToLink('/'); } } catch (error) { console.error('Network or other error during account deletion:', error); alert('An error occurred while trying to delete the account. Please try again.'); } } /** Sends a request to the server to resend the confirmation email. */ function resendConfirmEmail(): void { if (!isOurProfile) return; // Only allow resend if viewing own profile const config: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, credentials: 'same-origin', }; fetch(`/member/${member}/send-email`, config) .then((response) => { if (response.status === 401) { window.location.href = languagedropdown.addLngQueryParamToLink('/401'); // Unauthorized return Promise.reject(new Error('Unauthorized')); // Stop processing } if (!response.ok) { // Handle other errors (e.g., rate limiting, server issue) console.error('Failed to resend email:', response.status, response.statusText); // Optionally show an error message to the user alert('Failed to resend confirmation email. Please try again later.'); return Promise.reject(new Error(`Server error: ${response.status}`)); } return response.json(); // Expecting a success message perhaps? }) .then((result) => { // Email was resent! Reload the page so the user knows something happened. console.log('Resend email result:', result); // Log success indication from server if any window.location.reload(); }) .catch((error) => { // Catch errors from fetch itself or Promise.reject calls if (error.message !== 'Unauthorized' && !error.message.startsWith('Server error:')) { console.error('Error resending confirmation email:', error); alert('An error occurred while resending the email.'); } // Errors like 401 or server errors are already handled/logged in the .then block }); } /** Recalculates and sets the font size of the username based on window width and text length. */ function recalcUsernameSize(): void { const usernameText = element_memberName.textContent; if (!usernameText) return; // Exit if no username text // Estimate available width (adjust padding/margin values as needed based on actual layout) const otherElementsWidth = 185; // Approximate width of other elements on the same line/area const availableWidth = (window.innerWidth - otherElementsWidth) * 0.52; // Target width factor // Basic scaling - adjust the factor (3) based on desired look let fontSize = availableWidth * (3 / usernameText.length); // Set limits for font size const minFontSize = 12; // Minimum readable font size const maxFontSize = 50; // Maximum desired font size fontSize = Math.max(minFontSize, Math.min(fontSize, maxFontSize)); // Clamp font size element_memberName.style.fontSize = `${fontSize}px`; } // --- Global Event Listeners --- window.addEventListener('resize', recalcUsernameSize); ================================================ FILE: src/client/scripts/esm/views/news.ts ================================================ // src/client/scripts/esm/views/news.ts /** * This script runs on the news page. * It marks news as read when the page is visited and adds "NEW" badges to unread posts. */ import validatorama from '../util/validatorama.js'; /** * Marks all news as read for the current user */ async function markNewsAsRead(): Promise { // Only mark as read if user is logged in const username = validatorama.getOurUsername(); if (!username) return; try { const response = await fetch('/api/news/mark-read', { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', }, }); if (response.ok) { // Dispatch event to update header badge document.dispatchEvent(new CustomEvent('news-marked-read')); } } catch (error) { console.error('Error marking news as read:', error); } } /** * Fetches the list of unread news dates */ async function fetchUnreadNewsDates(): Promise { try { const response = await fetch('/api/news/unread-dates', { headers: { 'is-fetch-request': 'true', }, }); if (!response.ok) return []; const data = await response.json(); return data.dates || []; } catch (error) { console.error('Error fetching unread news dates:', error); return []; } } /** * Adds "NEW" badges to unread news posts */ function addNewBadgesToUnreadPosts(unreadDates: string[]): void { if (unreadDates.length === 0) return; // Find all news post elements const newsPosts = document.querySelectorAll('.news-post'); newsPosts.forEach((post) => { const postDate = (post as HTMLElement).dataset['date']; // Check if this post's date is in the unread list if (postDate && unreadDates.includes(postDate)) { addNewBadge(post as HTMLElement); } }); } /** * Creates and adds a "NEW" badge to a news post */ function addNewBadge(postElement: HTMLElement): void { // Don't add if already exists if (postElement.querySelector('.new-badge')) return; const badge = document.createElement('span'); badge.className = 'new-badge'; badge.textContent = 'NEW'; badge.style.cssText = ` display: inline-block; background-color: #ff4444; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2); `; // Find the date span and wrap both date and badge in a flex container const dateSpan = postElement.querySelector('.news-post-date'); if (dateSpan && dateSpan.parentNode) { // Create a wrapper div with flexbox const wrapper = document.createElement('div'); wrapper.style.cssText = ` display: inline-flex; justify-content: flex-start; align-items: center; gap: 8px; margin-top: 1em; `; // Remove margin-top from date span since wrapper now has it (dateSpan as HTMLElement).style.marginTop = '0'; // Replace the date span with the wrapper dateSpan.parentNode.insertBefore(wrapper, dateSpan); wrapper.appendChild(dateSpan); wrapper.appendChild(badge); } else { // If no date span, add it at the top of the post postElement.insertBefore(badge, postElement.firstChild); } } /** * Initializes the news page functionality */ async function init(): Promise { const username = validatorama.getOurUsername(); if (username) { // Fetch unread news dates first const unreadDates = await fetchUnreadNewsDates(); // Add NEW badges to unread posts if (unreadDates.length > 0) { addNewBadgesToUnreadPosts(unreadDates); } markNewsAsRead(); } } init(); export {}; ================================================ FILE: src/client/scripts/esm/views/resetpassword.ts ================================================ // src/client/scripts/esm/views/resetpassword.ts /** * This script handles the client-side logic for the password reset page. * It validates user input for a new password and sends it to the server. */ import validators from '../../../../shared/util/validators.js'; // Types ---------------------------------------------------------------- type FormElements = { form: HTMLFormElement; newPasswordInput: HTMLInputElement; confirmPasswordInput: HTMLInputElement; submitButton: HTMLInputElement; }; // --- Helper Functions (at module scope) --- /** * Extracts the password reset token from the page's URL. */ function getTokenFromUrl(): string { const pathSegments = window.location.pathname.split('/'); return pathSegments[pathSegments.length - 1] || ''; } /** * Creates or updates a message element on the page. */ function createErrorMessageElement(errorMessage: string): HTMLElement { const id = 'error-message'; const insertAfterId = 'confirm-password-line'; const existingEl = document.getElementById(id); if (existingEl) existingEl.remove(); const el = document.createElement('div'); el.id = id; el.className = 'error'; el.textContent = errorMessage; document.getElementById(insertAfterId)?.insertAdjacentElement('afterend', el); return el; } /** * The main setup function that attaches all logic and event listeners. * This function only runs if all required DOM elements are found. * @param elements - An object containing the verified DOM elements. */ function initializeForm(elements: FormElements): void { const { form, newPasswordInput, confirmPasswordInput, submitButton } = elements; let messageElement: HTMLElement | null = null; let isSubmitting: boolean = false; const token = getTokenFromUrl(); // --- Event Listeners & Initial Setup --- newPasswordInput.addEventListener('input', updateSubmitButtonState); confirmPasswordInput.addEventListener('input', updateSubmitButtonState); form.addEventListener('submit', handleResetSubmit); updateSubmitButtonState(); function clearMessage(): void { if (messageElement) { messageElement.remove(); messageElement = null; } } function updateSubmitButtonState(): void { if (isSubmitting) return; clearMessage(); if (newPasswordInput.value && confirmPasswordInput.value) { submitButton.disabled = false; submitButton.className = 'ready'; } else { submitButton.className = 'unavailable'; } } function validateForm(): boolean { const password = newPasswordInput.value; const confirmPassword = confirmPasswordInput.value; const result = validators.validatePassword(password); if (result !== validators.PasswordValidationResult.Ok) { messageElement = createErrorMessageElement( translations[validators.getPasswordErrorTranslation(result)!], ); newPasswordInput.focus(); return false; } if (password !== confirmPassword) { messageElement = createErrorMessageElement(translations['js-pwd_no_match']); confirmPasswordInput.focus(); return false; } return true; } async function handleResetSubmit(event: SubmitEvent): Promise { event.preventDefault(); if (isSubmitting || !validateForm()) return; clearMessage(); isSubmitting = true; submitButton.disabled = true; submitButton.className = 'unavailable'; submitButton.value = translations.processing; try { const response = await fetch('/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json', 'is-fetch-request': 'true', // Custom header }, body: JSON.stringify({ token, password: newPasswordInput.value }), }); const result = await response.json(); if (response.ok) { // SUCCESS form.innerHTML = `
${result.message}
`; // Redirect to login after a delay setTimeout(() => { window.location.href = '/login'; }, 4000); } else { // NOT OKAY => ERROR onFetchError(result.message || 'An unknown error occurred.'); } } catch (error: unknown) { // Likely a network error console.log(error instanceof Error ? error.message : String(error)); onFetchError(translations['network-error']); } } /** Called when the fetch request errors, either NOT okay or network error */ function onFetchError(errorMessage: string): void { messageElement = createErrorMessageElement(errorMessage); isSubmitting = false; submitButton.disabled = false; submitButton.className = 'ready'; submitButton.value = translations['reset-password']; } } // --- Script Entry Point --- // [FIX] Use instanceof for safe type checking instead of unsafe 'as' casting. const formEl = document.getElementById('reset-form'); const newPasswordEl = document.getElementById('new-password'); const confirmPasswordEl = document.getElementById('confirm-password'); const submitButtonEl = document.getElementById('submit-reset'); if ( formEl instanceof HTMLFormElement && newPasswordEl instanceof HTMLInputElement && confirmPasswordEl instanceof HTMLInputElement && submitButtonEl instanceof HTMLInputElement ) { // If all elements are found and are of the correct type, initialize the form logic. initializeForm({ form: formEl, newPasswordInput: newPasswordEl, confirmPasswordInput: confirmPasswordEl, submitButton: submitButtonEl, }); } else { console.error( 'One or more required elements for the reset password form are missing or of the wrong type.', ); } ================================================ FILE: src/client/scripts/esm/webgl/BufferUtil.ts ================================================ // src/client/scripts/esm/webgl/BufferUtil.ts /** * This script works with buffers. Creating them, assigning data, and modifying their indices. */ import { gl } from '../game/rendering/webgl.js'; import { TypedArray } from './Renderable.js'; // Variables -------------------------------------------------------------------------------- /** The draw hint when creating buffers on the gpu. Supposedly, dynamically * choosing which hint based on your needs offers very minor performance improvement. * Can choose between `gl.STATIC_DRAW`, `gl.DYNAMIC_DRAW`, or `gl.STREAM_DRAW` */ const DRAW_HINT = 'STATIC_DRAW'; // Functions -------------------------------------------------------------------------------- /** * Updates a buffer on the gpu with new data. * Can be used to modify meshes without having to create a new model. * @param buffer - The buffer to modify * @param data - The new data to put into the buffer. */ // function updateBuffer(buffer: WebGLBuffer, data: Float32Array) { // gl.bindBuffer(gl.ARRAY_BUFFER, buffer); // // gl.bufferData(gl.ARRAY_BUFFER, data, gl[DRAW_HINT]); // OLD. SLOW // gl.bufferSubData(gl.ARRAY_BUFFER, 0, data); // NEW. Sometimes faster? It stops being fast when I rewind & forward the game. // gl.bindBuffer(gl.ARRAY_BUFFER, null); // } /** * Updates only the provided indices of a buffer on the GPU with new data. * FAST. Use if only a part of the mesh has changed. * @param buffer - The WebGL buffer to update. * @param data - The typed array containing the new data (e.g., Float32Array, Uint16Array, etc.). * @param changedIndicesStart - The index in the vertex data marking the first value changed. * @param changedIndicesCount - The number of indices in the vertex data that were changed, beginning at {@link changedIndicesStart}. */ function updateBufferIndices( buffer: WebGLBuffer, data: TypedArray, changedIndicesStart: number, changedIndicesCount: number, ): void { const endIndice = changedIndicesStart + changedIndicesCount - 1; if (endIndice > data.length - 1) { return console.error( `Cannot update buffer indices when they overflow the data. Data length: ${data.length}, changedIndicesStart: ${changedIndicesStart}, changedIndicesCount: ${changedIndicesCount}, endIndice: ${endIndice}`, ); } // Calculate the byte offset and length based on the changed indices const offsetInBytes = changedIndicesStart * data.BYTES_PER_ELEMENT; // Update the specific portion of the buffer gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferSubData( gl.ARRAY_BUFFER, offsetInBytes, data.subarray(changedIndicesStart, changedIndicesStart + changedIndicesCount), ); gl.bindBuffer(gl.ARRAY_BUFFER, null); } /** * Creates a WebGL buffer from the provided Float32Array data and binds it to the ARRAY_BUFFER target. * The buffer is populated with the data and then unbound. * @param data - The vertex data to be copied into the buffer. * @returns The created WebGL buffer. */ function createBufferFromData(data: TypedArray): WebGLBuffer { const buffer = gl.createBuffer()!; // Create an empty buffer for the model's vertex data. gl.bindBuffer(gl.ARRAY_BUFFER, buffer); // Bind the buffer before we work with it. This is pretty much instantaneous no matter the buffer size. // Copy our vertex data into the buffer. // When copying over massive amounts of data (like millions of floats), // this FREEZES the screen for a moment before unfreezing. Not good for user experience! // When this happens, work with smaller meshes. // And always modify the buffer data on the gpu directly when you can, // using updateBufferIndices(), to avoid having to create another model! gl.bufferData(gl.ARRAY_BUFFER, data, gl[DRAW_HINT]); gl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer return buffer; } export { updateBufferIndices, createBufferFromData }; ================================================ FILE: src/client/scripts/esm/webgl/ProgramManager.ts ================================================ // src/client/scripts/esm/webgl/ProgramManager.ts import vsSource_color from '../../../shaders/color/vertex.glsl'; import fsSource_color from '../../../shaders/color/fragment.glsl'; import fsSource_water from '../../../shaders/water/fragment.glsl'; import vsSource_arrows from '../../../shaders/arrows/vertex.glsl'; import fsSource_glitch from '../../../shaders/glitch/fragment.glsl'; // Import the new glitch fragment shader import vsSource_texture from '../../../shaders/texture/vertex.glsl'; import fsSource_texture from '../../../shaders/texture/fragment.glsl'; import vsSource_postPass from '../../../shaders/post_pass/vertex.glsl'; import fsSource_postPass from '../../../shaders/post_pass/fragment.glsl'; import fsSource_vignette from '../../../shaders/vignette/fragment.glsl'; import fsSource_sineWave from '../../../shaders/sine_wave/fragment.glsl'; import fsSource_heatWave from '../../../shaders/heat_wave/fragment.glsl'; import { ShaderProgram } from './ShaderProgram'; import vsSource_starfield from '../../../shaders/starfield/vertex.glsl'; import vsSource_miniImages from '../../../shaders/mini_images/vertex.glsl'; import fsSource_miniImages from '../../../shaders/mini_images/fragment.glsl'; import vsSource_highlights from '../../../shaders/highlights/vertex.glsl'; import fsSource_colorGrade from '../../../shaders/color_grade/fragment.glsl'; import vsSource_arrowImages from '../../../shaders/arrow_images/vertex.glsl'; import fsSource_arrowImages from '../../../shaders/arrow_images/fragment.glsl'; import fsSource_waterRipple from '../../../shaders/water_ripple/fragment.glsl'; import vsSource_colorTexture from '../../../shaders/color_texture/vertex.glsl'; import fsSource_colorTexture from '../../../shaders/color_texture/fragment.glsl'; import vsSource_colorInstanced from '../../../shaders/color/instanced/vertex.glsl'; import vsSource_boardUberShader from '../../../shaders/board_uber_shader/vertex.glsl'; import fsSource_boardUberShader from '../../../shaders/board_uber_shader/fragment.glsl'; import vsSource_textureInstanced from '../../../shaders/texture/instanced/vertex.glsl'; import fsSource_voronoiDistortion from '../../../shaders/voronoi_distortion/fragment.glsl'; // =============================== Type Definitions =============================== // Attribute and Uniform Union Types for each ShaderProgram // Generic Shaders type Attributes_Color = 'a_position' | 'a_color'; type Uniforms_Color = 'u_transformmatrix'; type Attributes_ColorInstanced = 'a_position' | 'a_color' | 'a_instanceposition'; type Uniforms_ColorInstanced = 'u_transformmatrix'; type Attributes_Texture = 'a_position' | 'a_texturecoord'; type Uniforms_Texture = 'u_transformmatrix' | 'u_sampler'; type Attributes_TextureInstanced = 'a_position' | 'a_texturecoord' | 'a_instanceposition'; type Uniforms_TextureInstanced = 'u_transformmatrix' | 'u_sampler'; type Attributes_ColorTexture = 'a_position' | 'a_texturecoord' | 'a_color'; type Uniforms_ColorTexture = 'u_transformmatrix' | 'u_sampler'; // Specialized Shaders type Attributes_MiniImages = 'a_position' | 'a_texturecoord' | 'a_color' | 'a_instanceposition'; type Uniforms_MiniImages = 'u_transformmatrix' | 'u_sampler' | 'u_size'; type Attributes_Highlights = 'a_position' | 'a_color' | 'a_instanceposition'; type Uniforms_Highlights = 'u_transformmatrix' | 'u_size'; type Attributes_Arrows = | 'a_position' | 'a_instanceposition' | 'a_instancecolor' | 'a_instancerotation'; type Uniforms_Arrows = 'u_transformmatrix'; type Attributes_ArrowImages = | 'a_position' | 'a_texturecoord' | 'a_instanceposition' | 'a_instancecolor'; type Uniforms_ArrowImages = 'u_transformmatrix' | 'u_sampler'; type Attributes_Starfield = | 'a_position' | 'a_instanceposition' | 'a_instancecolor' | 'a_instancesize'; type Uniforms_Starfield = 'u_transformmatrix'; // Surface Level Effects type Attributes_BoardUberShader = 'a_position' | 'a_texturecoord' | 'a_color'; type Uniforms_BoardUberShader = // Global Uniforms | 'u_colorTexture' | 'u_maskTexture' | 'u_perlinNoiseTexture' | 'u_whiteNoiseTexture' | 'u_resolution' | 'u_pixelDensity' // Uber-Shader Logic | 'u_effectTypeA' | 'u_effectTypeB' | 'u_transitionProgress' // "Spectral Edge" Uniforms (Effect Type 4) | 'u4_flowDistance' | 'u4_flowDirectionVec' | 'u4_gradientRepeat' | 'u4_maskOffset' | 'u4_strength' | 'u4_color1' | 'u4_color2' | 'u4_color3' | 'u4_color4' | 'u4_color5' | 'u4_color6' // "Iridescence" Uniforms (Effect Type 5) | 'u5_flowDistance' | 'u5_flowDirectionVec' | 'u5_gradientRepeat' | 'u5_maskOffset' | 'u5_strength' | 'u5_color1' | 'u5_color2' | 'u5_color3' | 'u5_color4' | 'u5_color5' | 'u5_color6' // "Dusty Wastes" Uniforms (Effect Type 6) | 'u6_strength' | 'u6_noiseTiling' | 'u6_uvOffset1' | 'u6_uvOffset2' // "Static Zone" Uniforms (Effect Type 7) | 'u7_strength' | 'u7_uvOffset' | 'u7_pixelWidth' | 'u7_pixelSize'; // Post Processing Shaders type Attributes_PostPass = never; type Uniforms_PostPass = 'u_sceneTexture'; type Attributes_ColorGrade = never; type Uniforms_ColorGrade = | 'u_sceneTexture' | 'u_masterStrength' | 'u_brightness' | 'u_contrast' | 'u_gamma' | 'u_saturation' | 'u_tintColor' | 'u_hueOffset'; // type Attributes_Posterize = never; // Moved to dev-utils // type Uniforms_Posterize = 'u_sceneTexture' | 'u_masterStrength' | 'u_levels'; // Moved to dev-utils type Attributes_Vignette = never; type Uniforms_Vignette = | 'u_sceneTexture' | 'u_masterStrength' | 'u_radius' | 'u_softness' | 'u_intensity'; type Attributes_SineWave = never; type Uniforms_SineWave = | 'u_sceneTexture' | 'u_masterStrength' | 'u_amplitude' | 'u_frequency' | 'u_time' | 'u_angle'; type Attributes_Water = never; type Uniforms_Water = | 'u_sceneTexture' | 'u_masterStrength' | 'u_sourceCount' | 'u_centers' | 'u_time' | 'u_resolution' | 'u_strength' | 'u_oscillationSpeed' | 'u_frequency'; type Attributes_WaterRipple = never; type Uniforms_WaterRipple = | 'u_sceneTexture' | 'u_centers' | 'u_times' | 'u_dropletCount' | 'u_strength' | 'u_propagationSpeed' | 'u_oscillationSpeed' | 'u_frequency' | 'u_glintIntensity' | 'u_glintExponent' | 'u_falloff' | 'u_resolution'; type Attributes_HeatWave = never; type Uniforms_HeatWave = | 'u_sceneTexture' | 'u_masterStrength' | 'u_noiseTexture' | 'u_time' | 'u_strength' | 'u_resolution'; type Attributes_VoronoiDistortion = never; type Uniforms_VoronoiDistortion = | 'u_sceneTexture' | 'u_masterStrength' | 'u_time' | 'u_density' | 'u_strength' | 'u_ridgeThickness' | 'u_ridgeStrength' | 'u_resolution'; type Attributes_Glitch = never; // Glitch pass does not use attributes type Uniforms_Glitch = | 'u_sceneTexture' | 'u_masterStrength' | 'u_aberrationStrength' | 'u_aberrationOffset' | 'u_tearStrength' | 'u_tearResolution' | 'u_tearMaxDisplacement' | 'u_time' | 'u_resolution' | 'u_devicePixelRatio'; /** The Super Union of all possible attributes. */ export type Attributes_All = | Attributes_Color | Attributes_ColorInstanced | Attributes_Texture | Attributes_TextureInstanced | Attributes_ColorTexture | Attributes_MiniImages | Attributes_Highlights | Attributes_Arrows | Attributes_ArrowImages | Attributes_Starfield | Attributes_BoardUberShader | Attributes_PostPass | Attributes_ColorGrade // | Attributes_Posterize // Moved to dev-utils | Attributes_Vignette | Attributes_SineWave | Attributes_Water | Attributes_WaterRipple | Attributes_HeatWave | Attributes_VoronoiDistortion | Attributes_Glitch; // Each ShaderProgram type // Generic Shaders type Program_Color = ShaderProgram; type Program_ColorInstanced = ShaderProgram; type Program_Texture = ShaderProgram; type Program_TextureInstanced = ShaderProgram< Attributes_TextureInstanced, Uniforms_TextureInstanced >; type Program_ColorTexture = ShaderProgram; // Specialized Shaders type Program_MiniImages = ShaderProgram; type Program_Highlights = ShaderProgram; type Program_Arrows = ShaderProgram; type Program_ArrowImages = ShaderProgram; type Program_Starfield = ShaderProgram; // Surface Level Effects type Program_BoardUberShader = ShaderProgram; // Post Processing Shaders type Program_PostPass = ShaderProgram; type Program_ColorGrade = ShaderProgram; // type Program_Posterize = ShaderProgram; // Moved to dev-utils type Program_Vignette = ShaderProgram; type Program_SineWave = ShaderProgram; type Program_Water = ShaderProgram; type Program_WaterRipple = ShaderProgram; type Program_HeatWave = ShaderProgram; type Program_VoronoiDistortion = ShaderProgram< Attributes_VoronoiDistortion, Uniforms_VoronoiDistortion >; type Program_Glitch = ShaderProgram; export interface ProgramMap { // ======= Generic Shaders ======= /** Renders meshes with colored vertices. */ color: Program_Color; /** Instance renders a mesh with colored vertices. */ colorInstanced: Program_ColorInstanced; /** Renders a textured mesh. */ texture: Program_Texture; /** Instance renders a textured mesh. */ textureInstanced: Program_TextureInstanced; /** Renders a textured mesh with colored vertices. */ colorTexture: Program_ColorTexture; // ======= Specialized Shaders ======= /** Renders mini images. */ miniImages: Program_MiniImages; /** Renders mini images. Instance renders square highlights of a given size. */ highlights: Program_Highlights; /** Renders arrows (not the images, but tha arrow tip). */ arrows: Program_Arrows; /** Renders arrow images. */ arrowImages: Program_ArrowImages; /** Renders the starfield squares. */ starfield: Program_Starfield; // ====== Surface Level Effects ======= /** Renders textured surfaces with a masked noise texture animated behind them. */ board_uber_shader: Program_BoardUberShader; // ======= Post Processing Shaders ======= /** Post Processing Pass-Through Shader. Zero effects. */ post_pass: Program_PostPass; /** Post Processing Color Grading Shader. Several color effects. */ color_grade: Program_ColorGrade; // /** Post Processing Posterize Shader. */ // Moved to dev-utils // posterize: Program_Posterize; // Moved to dev-utils /** Post Processing Vignette Effect. */ vignette: Program_Vignette; /** Post Processing Dual Axis Sine Wave Distortion Effect. */ sine_wave: Program_SineWave; /** Post Processing Water Pond Distortion Effect. */ water: Program_Water; /** Post Processing Water Ripple Distortion Effect. */ water_ripple: Program_WaterRipple; /** Post Processing Heat Wave Distortion Effect. */ heat_wave: Program_HeatWave; /** Post Processing Voronoi Cellular Noise Distortion Effect. */ voronoi_distortion: Program_VoronoiDistortion; /** Post Processing Glitch Effect. */ glitch: Program_Glitch; } /** The vertex and fragment shader source codes for a shader. */ type ShaderSource = { /** The vertex shader source code. */ vertex: string; /** The fragment shader source code. */ fragment: string; }; // =============================== Implementation =============================== /** A mapping from program names to their corresponding shader sources. */ const shaderSources: Record = { // Generic Shaders color: { vertex: vsSource_color, fragment: fsSource_color }, colorInstanced: { vertex: vsSource_colorInstanced, fragment: fsSource_color }, texture: { vertex: vsSource_texture, fragment: fsSource_texture }, textureInstanced: { vertex: vsSource_textureInstanced, fragment: fsSource_texture }, colorTexture: { vertex: vsSource_colorTexture, fragment: fsSource_colorTexture }, // Specialized Shaders miniImages: { vertex: vsSource_miniImages, fragment: fsSource_miniImages }, highlights: { vertex: vsSource_highlights, fragment: fsSource_color }, arrows: { vertex: vsSource_arrows, fragment: fsSource_color }, arrowImages: { vertex: vsSource_arrowImages, fragment: fsSource_arrowImages }, starfield: { vertex: vsSource_starfield, fragment: fsSource_color }, // Surface Level Effects board_uber_shader: { vertex: vsSource_boardUberShader, fragment: fsSource_boardUberShader }, // Post Processing Shaders post_pass: { vertex: vsSource_postPass, fragment: fsSource_postPass }, color_grade: { vertex: vsSource_postPass, fragment: fsSource_colorGrade }, // posterize: { vertex: vsSource_postPass, fragment: fsSource_posterize }, // Moved to dev-utils vignette: { vertex: vsSource_postPass, fragment: fsSource_vignette }, sine_wave: { vertex: vsSource_postPass, fragment: fsSource_sineWave }, water: { vertex: vsSource_postPass, fragment: fsSource_water }, water_ripple: { vertex: vsSource_postPass, fragment: fsSource_waterRipple }, heat_wave: { vertex: vsSource_postPass, fragment: fsSource_heatWave }, voronoi_distortion: { vertex: vsSource_postPass, fragment: fsSource_voronoiDistortion }, glitch: { vertex: vsSource_postPass, fragment: fsSource_glitch }, }; /** * A factory and cache for creating and managing ShaderProgram instances. * Ensures that each shader program is only compiled and linked once. */ export class ProgramManager { private readonly gl: WebGL2RenderingContext; // The cache stores programs by their key from ProgramMap. We use a base // ShaderProgram type here internally, but the public `get` method provides full type safety. private programCache: Map> = new Map(); constructor(gl: WebGL2RenderingContext) { this.gl = gl; } /** * Retrieves a compiled and linked ShaderProgram from the cache, or creates it if it doesn't exist. * * @template K - The key (name) of the program to retrieve. * @param programName - The name of the shader program (e.g., 'phong', 'unlit'). * @returns The fully-typed ShaderProgram instance. */ public get(programName: K): ProgramMap[K] { // 1. Check if the program is already in the cache. if (this.programCache.has(programName)) { // We use a type assertion `as ProgramMap[K]` because we trust that the // internal cache is consistent with our public interface. return this.programCache.get(programName)! as ProgramMap[K]; } // 2. If not, get the source code for the requested program. const sources = shaderSources[programName]; if (!sources) throw Error(`Shader sources for program "${programName}" not found.`); // 3. Create a new ShaderProgram instance. // console.log(`Compiling and linking shader program: ${programName}`); const program = new ShaderProgram(this.gl, sources.vertex, sources.fragment); // 4. Store it in the cache for future requests. this.programCache.set(programName, program); return program as ProgramMap[K]; } } ================================================ FILE: src/client/scripts/esm/webgl/Renderable.ts ================================================ // src/client/scripts/esm/webgl/Renderable.ts /** * This script contains all the functions used to generate renderable buffer models of the * game objects that the shader programs can use. It receives the object's vertex data to do so, * the desired shader to use, the textures along with their uniform names, and the attribute * information, if applicable, such as how many components of the vertex data * are dedicated to position, color, texture coordinates, etc. * * It is also capable of instanced rendering. */ import type { Vec3 } from '../../../../shared/util/math/vectors.js'; import mat4 from '../game/rendering/gl-matrix.js'; import camera, { Mat4 } from '../game/rendering/camera.js'; import { ShaderProgram } from './ShaderProgram.js'; import { createBufferFromData, updateBufferIndices } from './BufferUtil.js'; import { Attributes_All, ProgramManager, ProgramMap } from './ProgramManager.js'; // Types ---------------------------------------------------------------------------------- /** * Any kind of array that may be passed to the constructors * to be used as vertex or instance data for a buffer model. * * Each of these are subsequently converted into aFloat32Array, * which have a max safe integer of 16,777,215 (16 million), * and a max value of 3.4e38. so beware of precision loss! * * number[] => Double precision (64-bit). Max safe integer of 9,007,199,254,740,991 (9 quadrillion). Max value of 1.8e+308. */ type InputArray = number[] | TypedArray; /** * All signed type arrays compatible with WebGL, that can be used as vertex data. * * Float32Array => Max safe integer: 16,777,215. Max value: 3.4e+38 * Int32Array => Max integer: 2,147,483,647 * Int16Array => Max integer: 32,767 * Int8Array => Max integer: 127 */ type TypedArray = Float32Array | Int32Array | Int16Array | Int8Array; /** All valid primitive shapes we can render with */ type PrimitiveType = | 'TRIANGLES' | 'TRIANGLE_STRIP' | 'TRIANGLE_FAN' | 'POINTS' | 'LINE_LOOP' | 'LINE_STRIP' | 'LINES'; /** An object describing a single attribute inside our vertex data, and how many components it has per stride/vertex. */ interface Attribute { /** The name of the attribute. */ name: Attributes_All; /** How many values the attribute has in a single stride/vertex of our data array. */ numComponents: number; } /** An object containing all attributes that some vertex data contains. */ type AttributeInfo = Attribute[]; /** An object containing the attribute info of both our vertex data and instance data. */ type AttributeInfoInstanced = { vertexDataAttribInfo: AttributeInfo; instanceDataAttribInfo: AttributeInfo; }; /** A texture, along with its given uniform name in the desired shader. */ interface TextureInfo { texture: WebGLTexture; /** e.g., 'u_sampler', 'u_noiseTexture' */ uniformName: string; } /** * **Call this** when you update specific vertex data within the source Float32Array! * FAST. Prevents you having to create a whole new model! * For example, when a single piece in the mesh moves. * @param {number} changedIndicesStart - The index in the vertex data marking the first value changed. * @param {number} changedIndicesCount - The number of indices in the vertex data that were changed, beginning at {@link changedIndicesStart}. */ type UpdateBufferIndicesFunc = (_changedIndicesStart: number, _changedIndicesCount: number) => void; /** Contains the properties that both the {@link Renderable} and {@link RenderableInstanced} types share. */ interface BaseRenderable { /** * **Renders** the buffer model! Translates and scales according to the provided arguments. * Applies any custom uniform values before rendering. * @param [position] - The positional translation, default [0,0,0] * @param [scale] - The scaling transformation, default [1,1,1] * @param uniforms - Custom uniform values, for example, 'u_size'. */ render: (_position?: Vec3, _scale?: Vec3, _uniforms?: Record) => void; } /** A renderable model. */ interface Renderable extends BaseRenderable { /** A reference to the vertex data, stored in a Float32Array, that went into this model's buffer. * If this is modified, we can use updateBufferIndices() to pass those changes * on to the gpu, without having to create a new buffer model! */ data: TypedArray; updateBufferIndices: UpdateBufferIndicesFunc; } /** A renderable model that uses instanced rendering! */ interface RenderableInstanced extends BaseRenderable { /** A reference to the vertex data of a SINGLE INSTANCE, stored in a Float32Array, that went into this model's buffer. * If this is modified, we can use updateBufferIndices() to pass those changes * on to the gpu, without having to create a new buffer model! */ vertexData: TypedArray; /** A reference to the vertex data OF EACH INSTANCE, stored in a Float32Array, that went into this model's buffer. * If this is modified, we can use updateBufferIndices() to pass those changes * on to the gpu, without having to create a new buffer model! */ instanceData: TypedArray; updateBufferIndices_VertexBuffer: UpdateBufferIndicesFunc; updateBufferIndices_InstanceBuffer: UpdateBufferIndicesFunc; } // Variables ---------------------------------------------------------------------------------- /** The global WebGL2 rendering context. */ let gl: WebGL2RenderingContext; /** The global program manager, used to get shader programs for rendering models. */ let programManager: ProgramManager; // Functions ---------------------------------------------------------------------------------- /** Initializes the script with the WebGL2 rendering context and ProgramManager. */ function init(context: WebGL2RenderingContext, program_manager: ProgramManager): void { gl = context; programManager = program_manager; } /** * The universal function for creating a renderable model, * given the vertex data, attribute information, * primitive rendering mode, and texture. */ function createRenderable( /** The array of vertex data of the mesh to be rendered. */ data: InputArray, /** The number of position components for a single vertex: x,y,z */ numPositionComponents: 2 | 3, /** What drawing primitive to use. */ mode: PrimitiveType, shader: keyof ProgramMap, /** Whether the vertex data contains color attributes. */ usingColor: boolean, /** If applicable, a texture to be bound when rendering (vertex data should contain texcoord attributes). */ texture?: WebGLTexture, ): Renderable { const usingTexture = texture !== undefined; const attribInfo = getAttribInfo(numPositionComponents, usingColor, usingTexture); const textureInfo: TextureInfo[] = []; if (texture) textureInfo.push({ texture, uniformName: 'u_sampler' }); // Most models with a single texture use the 'u_sampler' uniform return createRenderable_GivenInfo(data, attribInfo, mode, shader, textureInfo); } /** * The universal function for creating a renderable model THAT USES INSTANCED RENDERING, * given the vertex data and instance data, both attribute informations, primitive rendering mode, and texture! */ function createRenderable_Instanced( /** The array of vertex data of a single instance of the mesh. */ vertexData: InputArray, /** The instance-specific vertex data of the mesh. */ instanceData: InputArray, /** What drawing primitive to use. */ mode: PrimitiveType, shader: keyof ProgramMap, /** Whether the vertex data of a single instance contains color attributes, NOT THE INSTANCE-SPECIFIC DATA. */ usingColor: boolean, /** If applicable, a texture to be bound when rendering (instance data should contain texcoord attributes). */ texture?: WebGLTexture, ): RenderableInstanced { const usingTexture = texture !== undefined; const attribInfoInstanced = getAttribInfo_Instanced(usingColor, usingTexture); const textureInfo: TextureInfo[] = []; if (texture) textureInfo.push({ texture, uniformName: 'u_sampler' }); // Most models with a single texture use the 'u_sampler' uniform return createRenderable_Instanced_GivenInfo( vertexData, instanceData, attribInfoInstanced, mode, shader, textureInfo, ); } /** * Returns the attribute information object for some vertex data, * given the number of position components, and whether we're using * color and/or texture components. */ function getAttribInfo( numPositionComponents: 2 | 3, usingColor: boolean, usingTexture: boolean, ): AttributeInfo { if (usingColor && usingTexture) { return [ { name: 'a_position', numComponents: numPositionComponents }, { name: 'a_texturecoord', numComponents: 2 }, { name: 'a_color', numComponents: 4 }, ]; } else if (usingColor) { return [ { name: 'a_position', numComponents: numPositionComponents }, { name: 'a_color', numComponents: 4 }, ]; } else if (usingTexture) { return [ { name: 'a_position', numComponents: numPositionComponents }, { name: 'a_texturecoord', numComponents: 2 }, ]; } else throw new Error( 'Well we must be using ONE of either color or texcoord in our vertex data..', ); } /** * Returns the attribute information for the vertex and instance data arrays, * provided whether the vertex data contains color and/or texture coordinate information. */ function getAttribInfo_Instanced( usingColor: boolean, usingTexture: boolean, ): AttributeInfoInstanced { const vertexDataAttribInfo: AttributeInfo = [{ name: 'a_position', numComponents: 2 }]; if (usingTexture) vertexDataAttribInfo.push({ name: 'a_texturecoord', numComponents: 2 }); if (usingColor) vertexDataAttribInfo.push({ name: 'a_color', numComponents: 4 }); return { vertexDataAttribInfo, instanceDataAttribInfo: [{ name: 'a_instanceposition', numComponents: 2 }], }; } /** * Creates a renderable model, given the AttributeInfo object. */ function createRenderable_GivenInfo( data: InputArray, attribInfo: AttributeInfo, mode: PrimitiveType, shader: K, textures: TextureInfo[] = [], ): Renderable { const stride = getStrideFromAttributeInfo(attribInfo); if (data.length % stride !== 0) throw new Error( 'Data length is not divisible by stride when creating a buffer model. Check to make sure the specified attribInfo is correct.', ); data = ensureTypedArray(data); // Ensure the data is a Float32Array const BYTES_PER_ELEMENT = data.BYTES_PER_ELEMENT; const vertexCount = data.length / stride; const buffer = createBufferFromData(data); const shaderProgram = programManager.get(shader); // Generate the VAO that stores the attribute configuration. const vao = gl.createVertexArray(); if (!vao) throw new Error('Could not create Vertex Array Object'); gl.bindVertexArray(vao); configureAttributes(shaderProgram, buffer, attribInfo, stride, BYTES_PER_ELEMENT, false); gl.bindVertexArray(null); // Unbind VAO. The configuration is now saved inside the 'vao' object. gl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer next (configureAttributes() binds it) return { data, updateBufferIndices: (changedIndicesStart: number, changedIndicesCount: number): void => updateBufferIndices(buffer, data, changedIndicesStart, changedIndicesCount), render: ( position: Vec3 = [0, 0, 0], scale: Vec3 = [1, 1, 1], uniforms: Record = {}, ): void => prepareAndExecuteRender(shaderProgram, vao, position, scale, uniforms, textures, () => gl.drawArrays(gl[mode], 0, vertexCount), ), }; } /** * Creates a renderable model that uses instanced rendering, * given the AttributeInfo objects of both the vertex data and instance data arrays. */ function createRenderable_Instanced_GivenInfo( vertexData: InputArray, instanceData: InputArray, attribInfoInstanced: AttributeInfoInstanced, mode: PrimitiveType, shader: K, textures: TextureInfo[] = [], ): RenderableInstanced { const vertexDataStride = getStrideFromAttributeInfo(attribInfoInstanced.vertexDataAttribInfo); const instanceDataStride = getStrideFromAttributeInfo( attribInfoInstanced.instanceDataAttribInfo, ); if (vertexData.length % vertexDataStride !== 0) throw new Error( 'Vertex data length is not divisible by stride when creating an instanced buffer model. Check to make sure the specified attribInfo is correct.', ); if (instanceData.length % instanceDataStride !== 0) throw new Error( `Instance data length (${instanceData.length}) is not divisible by stride (${instanceDataStride}) when creating an instanced buffer model. Check to make sure the specified attribInfo is correct.`, ); vertexData = ensureTypedArray(vertexData); instanceData = ensureTypedArray(instanceData); const BYTES_PER_ELEMENT_VData = vertexData.BYTES_PER_ELEMENT; const BYTES_PER_ELEMENT_IData = instanceData.BYTES_PER_ELEMENT; const vertexCount = vertexData.length / vertexDataStride; // The vertex count of our vertex data of one single instance const instanceCount = instanceData.length / instanceDataStride; const vertexBuffer = createBufferFromData(vertexData); const instanceBuffer = createBufferFromData(instanceData); const shaderProgram = programManager.get(shader); // Generate the VAO that stores the attribute configuration. const vao = gl.createVertexArray(); if (!vao) throw new Error('Could not create Vertex Array Object'); gl.bindVertexArray(vao); configureAttributes( shaderProgram, vertexBuffer, attribInfoInstanced.vertexDataAttribInfo, vertexDataStride, BYTES_PER_ELEMENT_VData, false, ); configureAttributes( shaderProgram, instanceBuffer, attribInfoInstanced.instanceDataAttribInfo, instanceDataStride, BYTES_PER_ELEMENT_IData, true, ); gl.bindVertexArray(null); // Unbind VAO. The configuration is now saved inside the 'vao' object. gl.bindBuffer(gl.ARRAY_BUFFER, null); // Unbind the buffer next (configureAttributes() binds it) return { vertexData, instanceData, updateBufferIndices_VertexBuffer: ( changedIndicesStart: number, changedIndicesCount: number, ): void => updateBufferIndices(vertexBuffer, vertexData, changedIndicesStart, changedIndicesCount), updateBufferIndices_InstanceBuffer: ( changedIndicesStart: number, changedIndicesCount: number, ): void => updateBufferIndices( instanceBuffer, instanceData, changedIndicesStart, changedIndicesCount, ), render: ( position: Vec3 = [0, 0, 0], scale: Vec3 = [1, 1, 1], uniforms: Record = {}, ): void => prepareAndExecuteRender(shaderProgram, vao, position, scale, uniforms, textures, () => gl.drawArraysInstanced(gl[mode], 0, vertexCount, instanceCount), ), }; } /** * Accumulates the stride from the provided attribute info object. * Each attribute tells us how many components it uses. */ function getStrideFromAttributeInfo(attribInfo: AttributeInfo): number { return attribInfo.reduce((totalElements, currentAttrib) => { return totalElements + currentAttrib.numComponents; }, 0); } /** * Ensures the input is a Float32Array. If the input is already a typed array, * it is returned as-is. If it's a number array, a new Float32Array is created. * @param data - The input data, which can be either a number array or a typed array. * @returns A Float32Array representation of the input data. */ function ensureTypedArray(data: InputArray): TypedArray { if (!Array.isArray(data)) return data; // If it's already a TypedArray, return it. if (data.length > 1_000_000) { console.warn( 'Performance Warning: Float32Array generated from a very large number array (over 1 million in length). It is suggested to start with a Float32Array when computing your data!', ); } return new Float32Array(data); } /** * Renders a model, preparing the GPU state beforehand and cleaning up afterwards. * @param shaderProgram - The shader program to render with. * @param vao - The Vertex Array Object that stores the attribute configuration. * @param position - The positional translation of the object: `[x,y,z]` * @param scale - The scale transformation of the object: `[x,y,z]` * @param uniforms - An object with custom uniform names for the keys, and their value for the values. A custom uniform example is 'u_size'. Uniforms that are NOT custom are transformmatrix, and all texture samplers. * @param textures - The textures to bind. * @param drawCallback - A function that executes the actual draw call, e.g. drawArrays() or drawArraysInstanced(). */ function prepareAndExecuteRender( shaderProgram: ShaderProgram, vao: WebGLVertexArrayObject, position: Vec3, scale: Vec3, uniforms: Record, textures: TextureInfo[], drawCallback: () => void, ): void { // Switch to the program shaderProgram.use(); // Bind the VAO. ONE call to restore all attribute configuration. gl.bindVertexArray(vao); // Prepare the uniforms. setUniforms(shaderProgram, position, scale, uniforms, textures); // Call the draw function! drawCallback(); // Unbind the VAO. gl.bindVertexArray(null); // Unbind textures from all units that were used. // HAS TO BE AFTER THE DRAW CALL, or the render won't work. // We can't put it at the end of setUniforms() textures.forEach((texInfo, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, null); }); } /** * Configures the attributes for a shader program. * Tells the gpu how it will extract the data from the vertex data buffer. * BINDS THE BUFFER FOR YOU. * @param shaderProgram - The currently bound shader program, and the one we'll be rendering with. * @param buffer - The buffer that we have passed the vertex data into. * @param attribInfo - The AttributeInfo object, storing what attributes are in a single stride of the vertex data, and how many components they use. * @param stride - The vertex data's stride per vertex. * @param BYTES_PER_ELEMENT - How many bytes each element in the vertex data array take up (usually Float32Array.BYTES_PER_ELEMENT). * @param instanced - Whether the provided attributes to enable are instance-specific attributes (only updated once per instance instead of once per vertex) */ function configureAttributes( shaderProgram: ShaderProgram, buffer: WebGLBuffer, attribInfo: AttributeInfo, stride: number, BYTES_PER_ELEMENT: number, instanced: boolean, ): void { gl.bindBuffer(gl.ARRAY_BUFFER, buffer); const stride_bytes = stride * BYTES_PER_ELEMENT; // # bytes in each vertex/line. const vertexAttribDivisor = instanced ? 1 : 0; // 0 = attribs updated once per vertex 1 = updated once per instance let currentOffsetBytes = 0; // how many bytes inside the buffer to start from. for (const attrib of attribInfo) { const attribLoc = shaderProgram.getAttributeLocation(attrib.name as A)!; // Tell WebGL how to pull out the values from the vertex data and into the attribute in the shader code... gl.vertexAttribPointer( attribLoc, attrib.numComponents, gl.FLOAT, false, stride_bytes, currentOffsetBytes, ); gl.enableVertexAttribArray(attribLoc); // Enable the attribute for use // Be sure to set this every time, even if it's to 0! // If another shader set the same attribute index to be // used for instanced rendering, it would otherwise never be reset! gl.vertexAttribDivisor(attribLoc, vertexAttribDivisor); // 0 = attrib updated once per vertex 1 = updated once per instance // Adjust our offset for the next attribute currentOffsetBytes += attrib.numComponents * BYTES_PER_ELEMENT; } } /** * Sets the uniforms, preparing them before a draw call. * The transformmatrix uniform is updated with EVERY draw call! * @param shaderProgram - The currently bound shader program, and the one we'll be rendering with. * @param position - The positional translation of the object: `[x,y,z]` * @param scale - The scale transformation of the object: `[x,y,z]` * @param uniforms - An object with custom uniform names for the keys, and their value for the values. A custom uniform example is 'u_size'. Uniforms that are NOT custom are [transformMatrix, uSampler] * @param texture - The texture to bind, if applicable (we should be using the texcoord attribute). */ function setUniforms( shaderProgram: ShaderProgram, position: Vec3, scale: Vec3, uniforms: Record, textures: TextureInfo[], ): void { // Calculate the final Model-View-Projection matrix for this object const transformMatrix = genTransformMatrix(position, scale); // Send the transformMatrix to the gpu (every shader has this uniform) gl.uniformMatrix4fv( shaderProgram.getUniformLocation('u_transformmatrix' as U), false, transformMatrix, ); // Bind and set all textures textures.forEach((texInfo, i) => { const uLoc = shaderProgram.getUniformLocation(texInfo.uniformName as U); // Skip if the shader doesn't use this uniform. Useful for using the same model with different shaders? if (uLoc === null) { console.warn( `Uniform "${texInfo.uniformName}" not found in shader when trying to set texture. Skipping...`, ); return; } // Activate a unique texture unit for each texture. gl.activeTexture(gl.TEXTURE0 + i); // Bind the texture to that unit. gl.bindTexture(gl.TEXTURE_2D, texInfo.texture); // Tell the sampler uniform to use the texture unit we just activated. gl.uniform1i(uLoc, i); }); // Handle custom uniforms provided in the render call. for (const [name, value] of Object.entries(uniforms)) { const uLoc = shaderProgram.getUniformLocation(name as U); // It's common for game logic to pass uniforms that a specific shader might not use, so we just skip them. if (uLoc === null) { console.warn( `Uniform "${name}" not found in shader when trying to set custom uniform. Skipping...`, ); continue; } // Infer the correct uniform function from the value's type and structure if (Array.isArray(value)) { // Value is an array, treat it as a vector (e.g., vec2, vec3, vec4) switch (value.length) { case 2: gl.uniform2fv(uLoc, value); break; case 3: gl.uniform3fv(uLoc, value); break; case 4: gl.uniform4fv(uLoc, value); break; default: console.warn( `Unsupported array length for uniform "${name}". Expected 2, 3, or 4.`, ); } } else if (typeof value === 'number') { // Value is a number, treat it as a float gl.uniform1f(uLoc, value); } else if (typeof value === 'boolean') { // Value is a boolean, treat it as an integer (0 or 1) gl.uniform1i(uLoc, value ? 1 : 0); } else { throw new Error(`Unsupported data type "${typeof value}" for uniform "${name}".`); } } } /** * Calculates the final Model-View-Projection matrix for a given object. * This combines the camera's view and projection with the object's model matrix. */ function genTransformMatrix(position: Vec3, scale: Vec3): Mat4 { const { projMatrix, viewMatrix } = camera.getProjAndViewMatrixes(); const modelMatrix = genModelMatrix(position, scale); // Multiply the matrices in order: projection * view * model (world) const transformMatrix = mat4.create(); mat4.multiply(transformMatrix, projMatrix, viewMatrix); mat4.multiply(transformMatrix, transformMatrix, modelMatrix); return transformMatrix; } /** * Generates a model matrix given a position and scale to transform it by! * The gpu works with matrices REALLY FAST, so this is the most optimal way * to translate our models into position. */ function genModelMatrix(position: Vec3, scale: Vec3): Mat4 { const modelMatrix = mat4.create(); mat4.scale(modelMatrix, modelMatrix, scale); mat4.translate(modelMatrix, modelMatrix, position); return modelMatrix; } export { createRenderable, createRenderable_GivenInfo, createRenderable_Instanced, createRenderable_Instanced_GivenInfo, }; export default { init, }; export type { Renderable, RenderableInstanced, TypedArray, AttributeInfo, AttributeInfoInstanced, TextureInfo, }; ================================================ FILE: src/client/scripts/esm/webgl/ShaderProgram.ts ================================================ // src/client/scripts/esm/webgl/ShaderProgram.ts /** * A wrapper around a WebGLProgram that handles the boilerplate of * compiling, linking, and providing a clean interface for attributes and uniforms. * @template Attribute - A union of string literals representing the attribute names. * @template Uniform - A union of string literals representing the uniform names. */ export class ShaderProgram { private readonly program: WebGLProgram; private readonly gl: WebGL2RenderingContext; // Caches for attribute and uniform locations to avoid expensive lookups private attributeLocations: Map = new Map(); private uniformLocations: Map = new Map(); /** * Creates, compiles, and links a WebGL program from vertex and fragment shader source. * This constructor will throw an error if the shaders fail to compile or link. * @param gl - The WebGL rendering context. * @param vertexSource - The GLSL source code for the vertex shader. * @param fragmentSource - The GLSL source code for the fragment shader. */ constructor(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string) { this.gl = gl; const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource); const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource); this.program = this.createProgram(vertexShader, fragmentShader); } /** Activates this shader program for use in rendering. */ public use(): void { this.gl.useProgram(this.program); } /** Looks up and caches the location of a vertex attribute. */ public getAttributeLocation(name: Attribute): number { if (this.attributeLocations.has(name)) return this.attributeLocations.get(name)!; // Pre-cached location // Manually fetch location (more expensive) const location = this.gl.getAttribLocation(this.program, name); // It's common for unused attributes to be optimized out, so this isn't // always an error. We'll warn but not throw. if (location === -1) console.warn(`Attribute "${name}" not found in shader program.`); this.attributeLocations.set(name, location); // Cache the location return location; } /** Looks up and caches the location of a uniform. */ public getUniformLocation(name: Uniform): WebGLUniformLocation | null { if (this.uniformLocations.has(name)) return this.uniformLocations.get(name)!; // Pre-cached location // Manually fetch location (more expensive) const location = this.gl.getUniformLocation(this.program, name); // Unused uniforms are also common. if (location === null) console.warn(`Uniform "${name}" not found in shader program.`); this.uniformLocations.set(name, location!); // Cache the location return location; } // Private Helper Methods ----------------------------------------------------------- /** * Creates an actual program from the provided vertex shader and fragment shader * in which our webgl context can switch to via gl.useProgram() before rendering. */ private createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram { // Create the shader program const program = this.gl.createProgram(); if (!program) throw Error('Failed to create WebGL program.'); this.gl.attachShader(program, vertexShader); this.gl.attachShader(program, fragmentShader); this.gl.linkProgram(program); // Check if it was created successfully if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { const info = this.gl.getProgramInfoLog(program); throw Error(`Failed to link WebGL program: ${info}`); } return program; } /** * Creates a shader of the given type, from the specified source code. * @param type - `gl.VERTEX_SHADER` or `gl.FRAGMENT_SHADER` * @param sourceText - The shader source code, in GLSL version 1.00 */ private compileShader(type: number, source: string): WebGLShader { const shader = this.gl.createShader(type); if (!shader) throw Error(`Failed to create shader (type: ${type})`); this.gl.shaderSource(shader, source); // Send the source to the shader object this.gl.compileShader(shader); // Compile the shader program // Check if it compiled successfully if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { const info = this.gl.getShaderInfoLog(shader); const typeName = type === this.gl.VERTEX_SHADER ? 'VERTEX' : 'FRAGMENT'; this.gl.deleteShader(shader); throw Error(`Failed to compile ${typeName} shader: ${info}`); } return shader; } } ================================================ FILE: src/client/scripts/esm/webgl/TextureLoader.ts ================================================ // src/client/scripts/esm/webgl/TextureLoader.ts interface Options { /** Whether to generate and use mipmaps for the texture. Default is false. */ mipmaps?: boolean; } class TextureLoader { /** Default options if none are provided. */ private static defaultOptions: Required = { mipmaps: false, }; /** * Loads a WebGL texture from an HTMLImageElement. * @param gl - The WebGL2 rendering context. * @param img - The HTMLImageElement from which to create the texture. * @param options - Optional settings for texture creation. * @returns The created WebGLTexture. */ public static loadTexture( gl: WebGL2RenderingContext, img: HTMLImageElement, options: Options = {}, ): WebGLTexture { const settings: Required = { ...this.defaultOptions, ...options }; if (!isPowerOfTwo(img.naturalWidth) || !isPowerOfTwo(img.naturalHeight)) { throw new Error( `Image dimensions are not a power of two! Cannot use REPEAT wrapping mode. ${img.naturalWidth}x${img.naturalHeight}`, ); } const texture = gl.createTexture(); // Upload the image to the GPU gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // Flip image pixels into the bottom-to-top order that WebGL expects. gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); // Set filtering and mipmaps if (settings.mipmaps) { gl.generateMipmap(gl.TEXTURE_2D); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // Smooth edges, mipmap interpollation (half-blurry all the time, EXCEPT with LOD bias of +0.5) // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR); // DEFAULT if not set. Jagged edges, mipmap interpollation (never blurry, though always jaggy) // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST); // Smooth edges, mipmap snapping (clear on some zoom levels, full blurry at others) // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_NEAREST); // Jagged edges, mipmap snapping (jagged all the time) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // Magnification, smooth edges (noticeable when zooming in) } else { // No mipmaps. Set wrapping gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // Minification, smooth edges (not very noticeable) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); // Magnification, hard edges. Gives that pixelated look required for low-resolution board tiles texture. } // Not needed since it's the default, but adds clarity. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); gl.bindTexture(gl.TEXTURE_2D, null); return texture; } } function isPowerOfTwo(value: number): boolean { return (value & (value - 1)) === 0; } export default TextureLoader; ================================================ FILE: src/client/scripts/esm/webgl/maskedDraw.ts ================================================ // src/client/scripts/esm/webgl/maskedDraw.ts /** * This module manages stencil-masked rendering. * Both "inclusion" and "exclusion" masks are supported. */ import { gl } from '../game/rendering/webgl.js'; import { ProgramManager } from './ProgramManager.js'; // Variables ------------------------------------------------------------------------------- let programManager: ProgramManager; /** * Tracks how many times {@link execute} has been called this frame. * Each call gets its own isolated bit pair in the 8-bit stencil buffer (2 bits per call, 4 * calls max), so old stencil values from one call can never contaminate a later call. * * When the budget is exhausted, {@link resetStencilBuffer} is called to zero all bits via a * full-screen draw call (safe on TBDR GPUs — stays in the render pass), then the index recycles. */ let stencilCallIndex: number = 0; // Functions ------------------------------------------------------------------------------- /** * Must be called once after WebGL is initialized and the * ProgramManager is created, before any call to {@link execute}. */ function init(pm: ProgramManager): void { programManager = pm; } /** * Must be called once at the start of every frame, when clearing the screen. * Resets the stencil bit-pair index so each call gets a fresh pair this frame. */ function onFrameStart(): void { stencilCallIndex = 0; } /** * Resets all stencil bits to 0 using a full-screen draw call. * * Using `gl.clear(STENCIL_BUFFER_BIT)` mid-frame yields partial/torn frames on Chrome mobile. * This is believed to be because it forces a render-pass boundary on TBDR GPUs, causing tile * memory to be flushed to DRAM, which Chrome's compositor can read as a partial frame. * A full-screen draw call, by contrast, stays within the current render pass and tile memory, avoiding this issue. * * This is only called when the 4-call bit-pair budget is exhausted. */ function resetStencilBuffer(): void { programManager.get('post_pass').use(); gl.enable(gl.STENCIL_TEST); gl.colorMask(false, false, false, false); gl.depthMask(false); gl.stencilMask(0xff); gl.stencilFunc(gl.ALWAYS, 0, 0xff); gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE); gl.drawArrays(gl.TRIANGLES, 0, 6); // Full-screen quad via gl_VertexID (no VBO needed) gl.disable(gl.STENCIL_TEST); gl.colorMask(true, true, true, true); gl.depthMask(true); stencilCallIndex = 0; // Reset the call index since all bits are now zeroed } /** * Renders content using a flexible stencil mask. * Handles all stencil buffer state changes internally, ensuring a clean state before and after. * @param drawInclusionMaskFunc - A function that renders the INCLUSION ZONE MASK. The main scene will appear inside this zone. * @param drawExclusionMaskFunc - A function that renders the EXCLUSION ZONE MASK. The main scene will NOT appear inside this zone. * @param drawContentFunc - A function that renders the main scene content. Will be masked. * @param intersectionMode - Determines the behavior for intersections of the two mask types: * 'and' => Main scene will only be drawn where the inclusion mask and inversion of the exclusion mask intersect. * 'or' => Main scene will be drawn inside the inclusion mask and inversion of the exclusion mask. * Has no effect if only one mask type is provided. */ function execute( drawInclusionMaskFunc: Function | undefined, drawExclusionMaskFunc: Function | undefined, drawContentFunc: Function, intersectionMode: 'and' | 'or', ): void { if (!drawExclusionMaskFunc && !drawInclusionMaskFunc) throw Error('No mask functions provided.'); /** * Assign this call its own isolated bit pair in the 8-bit stencil buffer. * * We use 2 bits per call (supporting up to 4 calls/frame — we currently use up to 3). * The bit pairs from different calls never overlap, so leftover stencil values from * earlier calls are invisible to us because we only test/write bits within our own mask. * * Example callIndex 0 → bitMask=0x03, exclusionBit=0x01, inclusionBit=0x02 * callIndex 1 → bitMask=0x0C, exclusionBit=0x04, inclusionBit=0x08 * callIndex 2 → bitMask=0x30, exclusionBit=0x10, inclusionBit=0x20 * * If all 4 bit pairs are exhausted, {@link resetStencilBuffer} zeros the buffer via a * full-screen draw call and the index recycles from 0. */ if (stencilCallIndex >= 4) resetStencilBuffer(); const callIndex = stencilCallIndex++; const exclusionBit = 1 << (callIndex * 2); // e.g. 0x01, 0x04, 0x10 const inclusionBit = 1 << (callIndex * 2 + 1); // e.g. 0x02, 0x08, 0x20 const bitMask = exclusionBit | inclusionBit; // e.g. 0x03, 0x0C, 0x30 // Enable the stencil test. gl.enable(gl.STENCIL_TEST); // We don't want the mask to be affected by depth. // WITHOUT THIS, sometimes the mask doesn't do its masking, because it // initially failed the depth test if something else is rendered in front of it! gl.disable(gl.DEPTH_TEST); try { // We want to write to the stencil buffer, but make the mask itself invisible. gl.colorMask(false, false, false, false); // Disable writing to the color buffer gl.depthMask(false); // Disable writing to the depth buffer // Only write to our assigned bit pair; bits from other calls are preserved. gl.stencilMask(bitMask); gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE); // Draw the Masks if (intersectionMode === 'and') { drawInclusion(); drawExclusion(); } else { drawExclusion(); drawInclusion(); } function drawInclusion(): void { if (!drawInclusionMaskFunc) return; // Writes inclusionBit into our bit pair. The readMask in stencilFunc is // irrelevant here (ALWAYS passes regardless), but ref is what REPLACE stores. gl.stencilFunc(gl.ALWAYS, inclusionBit, bitMask); drawInclusionMaskFunc(); } function drawExclusion(): void { if (!drawExclusionMaskFunc) return; // Writes exclusionBit into our bit pair. gl.stencilFunc(gl.ALWAYS, exclusionBit, bitMask); drawExclusionMaskFunc(); } // Draw the Main Content // Re-enable drawing to the screen. gl.colorMask(true, true, true, true); gl.depthMask(true); // During content draw, don't write to the stencil; only test against our bit pair. gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); if (drawExclusionMaskFunc && drawInclusionMaskFunc) { // Case: COMPOSITE MASK (both exclusion and inclusion masks provided) if (intersectionMode === 'or') { // Draw where our bit pair is NOT set to exclusionBit (i.e. 0 or inclusionBit). gl.stencilFunc(gl.NOTEQUAL, exclusionBit, bitMask); } else { // Draw where our bit pair is exactly inclusionBit (not 0, not exclusionBit). gl.stencilFunc(gl.EQUAL, inclusionBit, bitMask); } } else if (drawExclusionMaskFunc) { // Case: EXCLUSION ONLY. Draw where our bit pair is not exclusionBit (i.e. 0). gl.stencilFunc(gl.NOTEQUAL, exclusionBit, bitMask); } else if (drawInclusionMaskFunc) { // Case: INCLUSION ONLY. Draw where our bit pair is inclusionBit. gl.stencilFunc(gl.EQUAL, inclusionBit, bitMask); } else throw Error('Unexpected!'); drawContentFunc(); } finally { // Return to a normal state. gl.disable(gl.STENCIL_TEST); gl.enable(gl.DEPTH_TEST); } } // Exports ----------------------------------------------------------------- export default { init, onFrameStart, execute }; ================================================ FILE: src/client/scripts/esm/webgl/post_processing/PostProcessingPipeline.ts ================================================ // src/client/scripts/esm/webgl/post_processing/PostProcessingPipeline.ts import { ShaderProgram } from '../ShaderProgram'; import { ProgramManager } from '../ProgramManager'; import { PassThroughPass } from './passes/PassThroughPass'; /** A Post Processing Effect applied to the whole screen after rendering the scene. */ export interface PostProcessPass { /** The shader program this pass uses. */ readonly program: ShaderProgram; /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ masterStrength: number; /** * Executes the render pass. * This method is responsible for activating the shader and setting its uniforms. * @param gl The WebGL2 rendering context. * @param inputTexture The texture to read from (the result of the previous pass). */ render(_gl: WebGL2RenderingContext, _inputTexture: WebGLTexture): void; } /** * Manages the post-processing pipeline for a raw WebGL2 application. * This class handles FBO creation, resizing, and the "ping-pong" technique * for chaining multiple effects. */ export class PostProcessingPipeline { private gl: WebGL2RenderingContext; private passes: PostProcessPass[] = []; private maxSamples: number; // For MSAA // --- Multisampled FBO for the main scene render --- private sceneFBO: WebGLFramebuffer; private sceneColorBuffer: WebGLRenderbuffer; private sceneDepthStencilBuffer: WebGLRenderbuffer; // --- Ping-Pong Framebuffers for post-processing --- // We use two FBOs to read from one while writing to the other. private readFBO: WebGLFramebuffer; private writeFBO: WebGLFramebuffer; private readTexture: WebGLTexture; private writeTexture: WebGLTexture; // This will hold the default shader for the "zero effects" case. private passThroughPass: PassThroughPass; constructor(gl: WebGL2RenderingContext, programManager: ProgramManager) { this.gl = gl; // Get the pass-through shader from your manager. this.passThroughPass = new PassThroughPass(programManager); // Get the max MSAA samples supported by the hardware. this.maxSamples = gl.getParameter(gl.MAX_SAMPLES); const initialWidth = gl.canvas.width; const initialHeight = gl.canvas.height; // --- Create Framebuffers and Textures for Post-Processing --- const { fbo: fboA, texture: textureA } = this.createFBO(initialWidth, initialHeight); const { fbo: fboB, texture: textureB } = this.createFBO(initialWidth, initialHeight); this.readFBO = fboA; this.readTexture = textureA; this.writeFBO = fboB; this.writeTexture = textureB; // --- Create Multisampled FBO and Renderbuffers for Scene --- this.sceneFBO = gl.createFramebuffer()!; this.sceneColorBuffer = gl.createRenderbuffer()!; this.sceneDepthStencilBuffer = gl.createRenderbuffer()!; // --- Initial sizing --- this.resize(gl.canvas.width, gl.canvas.height); } /** * Creates a single Framebuffer Object and its corresponding color texture. * This is for the single-sampled post-processing passes. */ private createFBO( width: number, height: number, ): { fbo: WebGLFramebuffer; texture: WebGLTexture } { const gl = this.gl; const texture = gl.createTexture(); if (!texture) throw new Error('Could not create texture'); gl.bindTexture(gl.TEXTURE_2D, texture); // Allocate storage for the texture IMMEDIATELY upon creation. // FIXES MOBILE BUG. Previousy we were attaching sizeless (0x0) textures // to framebuffers. Strict mobile drivers permanently mark these as invalid, // while lenient desktop drivers allow it. This line allocates the texture's // storage with the correct dimensions before attaching it, ensuring the // framebuffer is valid from the start on all platforms. gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); const fbo = gl.createFramebuffer(); if (!fbo) throw new Error('Could not create framebuffer'); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); // Unbind to be clean gl.bindTexture(gl.TEXTURE_2D, null); gl.bindFramebuffer(gl.FRAMEBUFFER, null); return { fbo, texture }; } /** * Updates the entire list of post processing effect passes. */ public setPasses(passes: PostProcessPass[]): void { this.passes = passes; } /** * Call this BEFORE rendering your main 3D scene. * It binds the FBO, redirecting all subsequent draw calls to an off-screen texture. */ public begin(): void { const gl = this.gl; // Bind the MULTISAMPLED FBO we will write the 3D scene into. gl.bindFramebuffer(gl.FRAMEBUFFER, this.sceneFBO); // Check if the framebuffer is complete. const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); if (status !== gl.FRAMEBUFFER_COMPLETE) { console.error(`Scene FBO is not complete: ${status}`); } // Set the viewport to the FBO size and clear it. gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); // Enable blending if your main scene needs it. gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.DEPTH_TEST); } /** * Call this AFTER your main 3D scene has been rendered. * It executes the post-processing passes and draws the final result to the canvas. */ public end(): void { const gl = this.gl; // --- RESOLVE MSAA --- // Copy (blit) the anti-aliased scene from the multisampled FBO // to the first single-sampled FBO in our ping-pong chain. gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.sceneFBO); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.readFBO); // prettier-ignore gl.blitFramebuffer( 0, 0, gl.canvas.width, gl.canvas.height, // source rect 0, 0, gl.canvas.width, gl.canvas.height, // destination rect gl.COLOR_BUFFER_BIT, // buffer to copy gl.NEAREST, // filter (must be NEAREST for MSAA resolve) ); // The anti-aliased scene is now in `readTexture`. // Unbind framebuffers and prepare for 2D post-processing passes. gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); // If we have no added no passes, we'll use our pass-through shader. // This creates a unified code path for all scenarios. const activePasses: PostProcessPass[] = this.passes.length > 0 ? this.passes : [this.passThroughPass]; // 1. PING-PONG PASSES: Loop through all but the very last pass. // These passes all render to the next FBO. for (let i = 0; i < activePasses.length - 1; i++) { const pass = activePasses[i]!; gl.bindFramebuffer(gl.FRAMEBUFFER, this.writeFBO); // Target the off-screen buffer pass.render(gl, this.readTexture); gl.drawArrays(gl.TRIANGLES, 0, 6); // 6 vertices (2 triangles) this.swapFBOs(); // The FBO we just wrote to becomes the read FBO for the next pass } // 2. FINAL PASS: Render the last effect directly to the screen. const lastPass = activePasses[activePasses.length - 1]!; gl.bindFramebuffer(gl.FRAMEBUFFER, null); // Target the canvas gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.clear(gl.COLOR_BUFFER_BIT); // Clear canvas before drawing final result lastPass.render(gl, this.readTexture); gl.drawArrays(gl.TRIANGLES, 0, 6); // 6 vertices (2 triangles) // RESTORE THE STATE of the gl context before ever using the pipeline! // This prevents texture alpha issues on frames where the pipeline is not used. gl.enable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); } /** * Swaps the read and write FBOs for the ping-pong technique. */ private swapFBOs(): void { const tempFBO = this.readFBO; this.readFBO = this.writeFBO; this.writeFBO = tempFBO; const tempTexture = this.readTexture; this.readTexture = this.writeTexture; this.writeTexture = tempTexture; } /** * Must be called whenever the canvas is resized to update the FBO textures * and the depth/stencil buffer. * @param width The new width of the canvas. * @param height The new height of the canvas. */ // prettier-ignore public resize(width: number, height: number): void { const gl = this.gl; // Resize the single-sampled color textures for post-processing const textures = [this.readTexture, this.writeTexture]; for (const texture of textures) { gl.bindTexture(gl.TEXTURE_2D, texture); // Use RGBA8 for standard dynamic range. You could use RGBA16F for HDR. gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); } // --- Resize the MULTISAMPLED renderbuffers for the main scene --- // Color buffer gl.bindRenderbuffer(gl.RENDERBUFFER, this.sceneColorBuffer); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, this.maxSamples, gl.RGBA8, width, height); // Depth/stencil renderbuffer gl.bindRenderbuffer(gl.RENDERBUFFER, this.sceneDepthStencilBuffer); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, this.maxSamples, gl.DEPTH24_STENCIL8, width, height); // Attach the multisampled renderbuffers to the scene FBO gl.bindFramebuffer(gl.FRAMEBUFFER, this.sceneFBO); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, this.sceneColorBuffer); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, this.sceneDepthStencilBuffer); // Unbind to be clean gl.bindTexture(gl.TEXTURE_2D, null); gl.bindRenderbuffer(gl.RENDERBUFFER, null); gl.bindFramebuffer(gl.FRAMEBUFFER, null); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/ColorGradePass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/ColorGradePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass for applying a full suite of color grading effects. */ export class ColorGradePass implements PostProcessPass { readonly program: ProgramMap['color_grade']; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** Adjusts overall brightness. 0.0 is no change. */ public brightness: number = 0.0; /** Adjusts contrast. 1.0 is no change. */ public contrast: number = 1.0; /** * Adjusts mid-tones. 1.0 is no change. * MUST BE > 0! */ public gamma: number = 1.0; /** Adjusts color intensity. 1.0 is no change, 0.0 is grayscale. */ public saturation: number = 1.0; /** Tints the scene with a color. [1, 1, 1] is no change. */ public tint: [number, number, number] = [1.0, 1.0, 1.0]; /** Rotates all colors. 0.0 is no change, wraps at 1.0. */ public hueOffset: number = 0.0; constructor(programManager: ProgramManager) { this.program = programManager.get('color_grade'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); // Bind the input texture to texture unit 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1f(this.program.getUniformLocation('u_brightness'), this.brightness); gl.uniform1f(this.program.getUniformLocation('u_contrast'), this.contrast); gl.uniform1f(this.program.getUniformLocation('u_gamma'), this.gamma); gl.uniform1f(this.program.getUniformLocation('u_saturation'), this.saturation); gl.uniform3fv(this.program.getUniformLocation('u_tintColor'), this.tint); gl.uniform1f(this.program.getUniformLocation('u_hueOffset'), this.hueOffset); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/GlitchPass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/GlitchPass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass that applies a glitch effect, * combining horizontal tearing and chromatic aberration. */ export class GlitchPass implements PostProcessPass { readonly program: ProgramMap['glitch']; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The strength of the chromatic aberration. */ public aberrationStrength: number = 0.0; /** The direction and magnitude of the color channel separation for chromatic aberration in virtual CSS pixels. */ public aberrationOffsetPixels: [number, number] = [10.0, 0.0]; /** The strength of the horizontal tearing. */ public tearStrength: number = 0.0; /** The height of individual tear lines in virtual CSS pixels. */ public tearResolution: number = 16.0; /** The maximum horizontal displacement for a tear in virtual CSS pixels. */ public tearMaxDisplacement: number = 20.0; /** The current time, used to animate the glitch patterns. Increment this each frame. */ public time: number = 0.0; constructor(programManager: ProgramManager) { this.program = programManager.get('glitch'); } // prettier-ignore render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); // Bind the scene texture from the pipeline to TEXTURE UNIT 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); // Chromatic Aberration Uniforms gl.uniform1f(this.program.getUniformLocation('u_aberrationStrength'), this.aberrationStrength); // Convert the aberration offset to UV space const uvAberrationOffset: [number, number] = [ this.aberrationOffsetPixels[0] * window.devicePixelRatio / gl.canvas.width, this.aberrationOffsetPixels[1] * window.devicePixelRatio / gl.canvas.height, ]; gl.uniform2fv(this.program.getUniformLocation('u_aberrationOffset'), uvAberrationOffset); // Horizontal Tearing Uniforms gl.uniform1f(this.program.getUniformLocation('u_tearStrength'), this.tearStrength); gl.uniform1f(this.program.getUniformLocation('u_tearResolution'), this.tearResolution); gl.uniform1f(this.program.getUniformLocation('u_tearMaxDisplacement'), this.tearMaxDisplacement); gl.uniform1f(this.program.getUniformLocation('u_time'), this.time); gl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height); gl.uniform1f(this.program.getUniformLocation('u_devicePixelRatio'), window.devicePixelRatio); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/HeatWavePass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/HeatWavePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass that applies a rising, shimmering heat distortion effect. */ export class HeatWavePass implements PostProcessPass { readonly program: ProgramMap['heat_wave']; private noiseTexture: WebGLTexture; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The strength of the distortion effect. */ public strength: number = 0.04; // Default: 0.04 /** The current time, used to animate the waves. Increment this each frame. */ public time: number = 0.0; constructor(programManager: ProgramManager, noiseTexture: WebGLTexture) { this.program = programManager.get('heat_wave'); this.noiseTexture = noiseTexture; } // prettier-ignore render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); // 1. Bind the scene texture from the pipeline to TEXTURE UNIT 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // 2. Bind our own noise texture to TEXTURE UNIT 1 gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.noiseTexture); // 3. Set the uniforms, telling the shader which texture unit to use for each gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); // Use unit 0 gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1i(this.program.getUniformLocation('u_noiseTexture'), 1); // Use unit 1 gl.uniform1f(this.program.getUniformLocation('u_time'), this.time); gl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength); gl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/PassThroughPass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/PassThroughPass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A Post Processing Pass Through Effect, with zero effects. * Only required if we have no other effects. */ export class PassThroughPass implements PostProcessPass { readonly program: ProgramMap['post_pass']; /** * A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. * HAS NO EFFECT IN THE PASS THROUGH PASS. */ public masterStrength: number = 1.0; constructor(programManager: ProgramManager) { this.program = programManager.get('post_pass'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/SineWavePass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/SineWavePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass that applies a double-axis sine wave distortion to the image. */ export class SineWavePass implements PostProcessPass { readonly program: ProgramMap['sine_wave']; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The strength of the wave on the [x, y] axes. */ public amplitude: [number, number] = [0.003, 0.003]; /** The number of full waves across the screen on the [x, y] axes. */ public frequency: [number, number] = [2.0, 2.0]; /** The angle of the primary wave axis in degrees. The second wave is perpendicular. */ public angle: number = 0.0; /** The current time, used to animate the waves. Increment this each frame. */ public time: number = 0.0; constructor(programManager: ProgramManager) { this.program = programManager.get('sine_wave'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); // Bind the scene texture from the pipeline to TEXTURE UNIT 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Convert angle from degrees to radians for the shader const angleInRadians = this.angle * (Math.PI / 180.0); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform2fv(this.program.getUniformLocation('u_amplitude'), this.amplitude); gl.uniform2fv(this.program.getUniformLocation('u_frequency'), this.frequency); gl.uniform1f(this.program.getUniformLocation('u_time'), this.time); gl.uniform1f(this.program.getUniformLocation('u_angle'), angleInRadians); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/VignettePass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/VignettePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass for applying a vignette effect, * darkening the corners of the image. */ export class VignettePass implements PostProcessPass { readonly program: ProgramMap['vignette']; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The inner radius of the vignette, where darkening begins. Default is 0.3. */ public radius: number = 0.3; /** The softness of the vignette's edge. Default is 0.5. */ public softness: number = 0.5; /** The strength of the darkening effect. 1.0 is fully black. Default is 0.8. */ public intensity: number = 0.8; constructor(programManager: ProgramManager) { this.program = programManager.get('vignette'); } render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1f(this.program.getUniformLocation('u_radius'), this.radius); gl.uniform1f(this.program.getUniformLocation('u_softness'), this.softness); gl.uniform1f(this.program.getUniformLocation('u_intensity'), this.intensity); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/VoronoiDistortionPass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/VoronoiDistortionPass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** * A post-processing pass that distorts the image based on an animated * Voronoi cellular noise pattern. */ export class VoronoiDistortionPass implements PostProcessPass { readonly program: ProgramMap['voronoi_distortion']; // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The current time, used to animate the cells. Increment this each frame. */ public time: number = 0.0; /** The density of the Voronoi cells. */ public density: number = 3.5; /** The strength of the cells' distortion. */ public strength: number = 0.007; /** The thickness of the ridges between cells. */ public ridgeThickness = 0.02; /** The strength of the ridges' lensing effect. */ public ridgeStrength = 0.04; constructor(programManager: ProgramManager) { this.program = programManager.get('voronoi_distortion'); } // prettier-ignore render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); // Set all the uniforms gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1f(this.program.getUniformLocation('u_time'), this.time); gl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength); gl.uniform1f(this.program.getUniformLocation('u_density'), this.density); gl.uniform1f(this.program.getUniformLocation('u_ridgeThickness'), this.ridgeThickness); gl.uniform1f(this.program.getUniformLocation('u_ridgeStrength'), this.ridgeStrength); gl.uniform2f(this.program.getUniformLocation('u_resolution'), gl.canvas.width, gl.canvas.height); } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/WaterPass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/WaterPass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** Defines a single ripple's source point. */ export interface RippleSource { /** The center of the source in UV coordinates [0-1, 0-1]. */ center: [number, number]; } /** * A post-processing pass that simulates a pond-like surface with ripples * emanating from various source points. The ripples are radial sine waves * with constant intensity. */ export class WaterPass implements PostProcessPass { readonly program: ProgramMap['water']; private static readonly MAX_SOURCES = 10; // MUST match the shader constant // --- Public Properties to Control the Effect --- /** A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. */ public masterStrength: number = 1.0; /** The overall strength and visibility of the distortion. */ public strength: number = 0.001; /** How fast the waves oscillate or "bob" up and down, in cycles per second. */ public oscillationSpeed: number = 8.0; /** The density of the rings in the ripple, in waves per UV unit. */ public frequency: number = 40.0; /** The current time, used to animate the waves. Should be updated each frame. */ public time: number = 0.0; // --- Internal State --- private activeSources: RippleSource[] = []; private resolution: [number, number] = [1, 1]; // Pre-allocated array for performance to avoid creating new arrays every frame private centersArray: Float32Array = new Float32Array(WaterPass.MAX_SOURCES * 2); /** * Creates a new PondPass. * @param programManager - The ProgramManager instance to retrieve the shader program. * @param width - The current width of the canvas. * @param height - The current height of the canvas. */ constructor(programManager: ProgramManager, width: number, height: number) { this.program = programManager.get('water'); this.setResolution(width, height); } /** * Updates the pass with the current list of active ripple sources. * Call this every frame. * @param sources An array of active source points. */ public updateSources(sources: RippleSource[]): void { // Clamp the number of sources to the maximum allowed by the shader const count = Math.min(sources.length, WaterPass.MAX_SOURCES); this.activeSources = sources.slice(0, count); } /** * Informs the pass of the current rendering resolution. * This is crucial for correcting aspect ratio distortion. * Call this whenever the canvas is resized. * @param width The width of the canvas. * @param height The height of the canvas. */ public setResolution(width: number, height: number): void { this.resolution[0] = width; this.resolution[1] = height; } // prettier-ignore render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { // --- 1. Prepare uniform data --- const sourceCount = this.activeSources.length; for (let i = 0; i < sourceCount; i++) { const source = this.activeSources[i]!; this.centersArray[i * 2 + 0] = source.center[0]; this.centersArray[i * 2 + 1] = source.center[1]; } // --- 2. Set uniforms and render --- this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1f(this.program.getUniformLocation('u_masterStrength'), this.masterStrength); gl.uniform1i(this.program.getUniformLocation('u_sourceCount'), sourceCount); gl.uniform1f(this.program.getUniformLocation('u_time'), this.time / 1000); // Convert ms to seconds gl.uniform2fv(this.program.getUniformLocation('u_resolution'), this.resolution); gl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength); gl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency); gl.uniform1f(this.program.getUniformLocation('u_oscillationSpeed'), this.oscillationSpeed); if (sourceCount > 0) { // Use subarray to only send data for active sources gl.uniform2fv(this.program.getUniformLocation('u_centers'), this.centersArray.subarray(0, sourceCount * 2)); } } } ================================================ FILE: src/client/scripts/esm/webgl/post_processing/passes/WaterRipplePass.ts ================================================ // src/client/scripts/esm/webgl/post_processing/passes/WaterRipplePass.ts import type { PostProcessPass } from '../PostProcessingPipeline'; import type { ProgramManager, ProgramMap } from '../../ProgramManager'; /** A simple structure to define a single droplet's state. */ export interface RippleState { /** The center of the droplet in UV coordinates [0-1, 0-1]. */ center: [number, number]; /** The time snapshot in millseconds the ripple was created. */ timeCreated: number; } /** * A post-processing pass that simulates multiple water droplet ripples on the screen. */ export class WaterRipplePass implements PostProcessPass { readonly program: ProgramMap['water_ripple']; private static readonly MAX_DROPLETS = 20; // MUST match the shader constant // --- Global Effect Controls --- /** * A master control for the strength of the entire pass. 0.0 is off, 1.0 is full effect. * HAS NO EFFECT ON THE WATER RIPPLE PASS. */ public masterStrength: number = 1.0; /** The overall strength and visibility of the distortion. */ public strength: number = 0.06; // Default: 0.06 /** How fast the ripple's leading edge expands outwards, in UV units per second. */ public propagationSpeed: number = 2.0; /** How fast the internal waves oscillate or "bob" up and down. */ public oscillationSpeed: number = 40.0; /** The density of the rings in the ripple, in waves per UV unit. */ public frequency: number = 50.0; /** How sharply the trailing waves decay. Hhigher values create a shorter tail. */ public falloff: number = 200.0; /** The brightness of the white glow on the wave crests. */ public glintIntensity: number = 0.5; /** The sharpness of the glint; higher values create a smaller, tighter highlight. */ public glintExponent: number = 7.0; // --- Internal State --- private activeDroplets: RippleState[] = []; private resolution: [number, number] = [1, 1]; // Pre-allocated arrays for performance to avoid creating new arrays every frame private centersArray: Float32Array = new Float32Array(WaterRipplePass.MAX_DROPLETS * 2); private timesArray: Float32Array = new Float32Array(WaterRipplePass.MAX_DROPLETS); /** * Creates a new WaterRipplePass. * @param programManager - The ProgramManager instance to retrieve shader programs. * @param width - The current width of the canvas. * @param height - The current height of the canvas. */ constructor(programManager: ProgramManager, width: number, height: number) { this.program = programManager.get('water_ripple'); this.setResolution(width, height); } /** * Updates the pass with the current list of active droplets. * Call this every frame from your main application loop. * @param droplets An array of active droplet states. */ public updateDroplets(droplets: RippleState[]): void { // Clamp the number of droplets to the maximum allowed by the shader const count = Math.min(droplets.length, WaterRipplePass.MAX_DROPLETS); this.activeDroplets = droplets.slice(-count); // Keep the most recent droplets } /** * Informs the pass of the current rendering resolution. * This is crucial for correcting aspect ratio distortion, * preventing ripples from not being circular on non-square screens. * Call this whenever the canvas is resized. * @param width The width of the canvas. * @param height The height of the canvas. */ public setResolution(width: number, height: number): void { this.resolution[0] = width; this.resolution[1] = height; } // prettier-ignore render(gl: WebGL2RenderingContext, inputTexture: WebGLTexture): void { const now = Date.now(); // --- 1. Prepare uniform data --- const dropletCount = this.activeDroplets.length; for (let i = 0; i < dropletCount; i++) { const droplet = this.activeDroplets[i]!; this.centersArray[i * 2 + 0] = droplet.center[0]; this.centersArray[i * 2 + 1] = droplet.center[1]; this.timesArray[i] = (now - droplet.timeCreated) / 1000; // Convert to seconds } // --- 2. Set uniforms and render --- this.program.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, inputTexture); gl.uniform1i(this.program.getUniformLocation('u_sceneTexture'), 0); gl.uniform1i(this.program.getUniformLocation('u_dropletCount'), dropletCount); gl.uniform1f(this.program.getUniformLocation('u_strength'), this.strength); gl.uniform1f(this.program.getUniformLocation('u_propagationSpeed'), this.propagationSpeed); gl.uniform1f(this.program.getUniformLocation('u_oscillationSpeed'), this.oscillationSpeed); gl.uniform1f(this.program.getUniformLocation('u_frequency'), this.frequency); gl.uniform1f(this.program.getUniformLocation('u_falloff'), this.falloff); gl.uniform1f(this.program.getUniformLocation('u_glintIntensity'), this.glintIntensity); gl.uniform1f(this.program.getUniformLocation('u_glintExponent'), this.glintExponent); gl.uniform2fv(this.program.getUniformLocation('u_resolution'), this.resolution); if (dropletCount > 0) { // Use subarray to only send data for active droplets gl.uniform2fv(this.program.getUniformLocation('u_centers'), this.centersArray.subarray(0, dropletCount * 2)); gl.uniform1fv(this.program.getUniformLocation('u_times'), this.timesArray.subarray(0, dropletCount)); } } } ================================================ FILE: src/client/scripts/esm/workers/icnvalidator.worker.ts ================================================ // src/client/scripts/esm/workers/icnvalidator.worker.ts /** * The web worker script for the ICN Validator Tool. */ import type { GameConclusion } from '../../../../shared/chess/util/winconutil.js'; import icnconverter from '../../../../shared/chess/logic/icn/icnconverter.js'; import { players as p, Player } from '../../../../shared/chess/util/typeutil.js'; import gameformulator from '../game/chess/gameformulator.js'; // Define types interface WorkerMessage { chunkId: number; games: { index: number; icn: string }[]; } // Listen for the main thread to send data self.onmessage = (e: MessageEvent) => { const { chunkId, games } = e.data; const localResults = { success: true, successfulCount: 0, icnconverterErrors: 0, formulatorErrors: 0, illegalMoveErrors: 0, terminationMismatchErrors: 0, errors: [] as any[], variantErrors: {} as Record, }; // Helper for variant stats const incrementVariantError = (variantName: string, type: string): void => { if (!localResults.variantErrors[variantName]) { localResults.variantErrors[variantName] = { total: 0, icn: 0, formulator: 0, illegal: 0, termination: 0, }; } localResults.variantErrors[variantName].total++; localResults.variantErrors[variantName][type]++; }; // Process the batch for (const item of games) { const { index, icn: gameICN } = item; try { // Stage 1: Convert ICN to long format let longFormat: any; try { longFormat = icnconverter.ShortToLong_Format(gameICN); } catch (error) { const message = error instanceof Error ? error.message : String(error); localResults.icnconverterErrors++; localResults.errors.push({ gameIndex: index, phase: 'icnconverter', error: message, icn: gameICN, }); incrementVariantError('Unknown (ICN Parse Failed)', 'icn'); continue; // Move to next game } // Extract metadata const variant = longFormat.metadata?.Variant || 'Unknown'; const termination = longFormat.metadata?.Termination; const result = longFormat.metadata?.Result; // Stage 2: Formulate (No validation) let game: any; try { game = gameformulator.formulateGame(longFormat); } catch (error) { const message = error instanceof Error ? error.message : String(error); localResults.formulatorErrors++; localResults.errors.push({ gameIndex: index, phase: 'formulator', error: message, variant: variant, icn: gameICN, }); incrementVariantError(variant, 'formulator'); continue; } // Stage 3: Validate Moves try { gameformulator.formulateGame(longFormat, true); } catch (error) { const message = error instanceof Error ? error.message : String(error); localResults.illegalMoveErrors++; localResults.errors.push({ gameIndex: index, phase: 'illegal-move', error: message, variant: variant, icn: gameICN, }); incrementVariantError(variant, 'illegal'); continue; } // Stage 4: Termination Check try { validateTermination(termination, result, game.basegame.gameConclusion); } catch (error) { const message = error instanceof Error ? error.message : String(error); localResults.terminationMismatchErrors++; localResults.errors.push({ gameIndex: index, phase: 'termination-mismatch', error: message, variant: variant, termination: termination, result: result, gameConclusion: game.basegame.gameConclusion, icn: gameICN, }); incrementVariantError(variant, 'termination'); continue; } // If we got here, game is valid localResults.successfulCount++; } catch (error) { // Unexpected const message = error instanceof Error ? error.message : String(error); localResults.formulatorErrors++; localResults.errors.push({ gameIndex: index, phase: 'unknown', error: message, icn: gameICN, }); } // Report progress every 50 games (optional optimization to keep UI responsive) if (localResults.successfulCount % 10 === 0) { self.postMessage({ type: 'progress', chunkId, count: 10 }); } } // Send final results for this chunk self.postMessage({ type: 'done', chunkId, results: localResults }); }; // --- Helper Logic --- function validateTermination( termination: string | undefined, result: string | undefined, gameConclusion: GameConclusion | undefined, ): void { if (termination === 'Maximum moves reached') { if (gameConclusion !== undefined) throw new Error( `Termination is "Maximum moves reached" but game is over: ${JSON.stringify(gameConclusion)}`, ); return; } if (termination && termination.startsWith('Material adjudication')) { if (gameConclusion !== undefined) throw new Error( `Termination is Material Adjudication, but game is over: ${JSON.stringify(gameConclusion)}`, ); return; } if (gameConclusion === undefined) { if (termination) throw new Error(`Game isn't over, but Termination is specified: "${termination}"`); return; } const { victor, condition } = gameConclusion; const conditionMappings: Record = { Checkmate: 'checkmate', 'All pieces captured': 'allpiecescaptured', Stalemate: 'stalemate', 'Threefold repetition': 'repetition', '50-move rule': 'moverule', 'Insufficient material': 'insuffmat', }; if (termination && termination in conditionMappings) { if (condition !== conditionMappings[termination]) throw new Error(`Game is over by ${condition}, but Termination is "${termination}"`); } else if (termination) { throw new Error(`Disallowed Termination metadata: "${termination}"`); } if (victor !== undefined && result) { const resultMappings: Record = { '1-0': p.WHITE, '0-1': p.BLACK, '1/2-1/2': null, }; if (result in resultMappings) { if (victor !== resultMappings[result]) throw new Error(`Result "${result}" does not match victor ${victor}`); } else { throw new Error(`Unknown Result metadata: "${result}"`); } } } ================================================ FILE: src/client/shaders/arrow_images/fragment.glsl ================================================ #version 300 es precision highp float; in vec2 vTextureCoord; // From vertex shader in vec4 vInstanceColor; // From vertex shader uniform sampler2D u_sampler; // Texture sampler out vec4 fragColor; // Output color void main() { // Sample texture with LOD bias for sharpness vec4 texColor = texture(u_sampler, vTextureCoord, -0.5); fragColor = texColor * vInstanceColor; } ================================================ FILE: src/client/shaders/arrow_images/vertex.glsl ================================================ #version 300 es in vec4 a_position; // Per-vertex position (vec4 for homogeneous coordinates) in vec2 a_texturecoord; // Per-vertex texture coordinates in vec3 a_instanceposition; // Per-instance position offset (vec3: xyz) in vec4 a_instancecolor; // Per-instance color (RGBA) uniform mat4 u_transformmatrix; // Transformation matrix out vec2 vTextureCoord; // To fragment shader out vec4 vInstanceColor; // To fragment shader void main() { // Apply instance position offset vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0); // Transform position and pass through texture coords gl_Position = u_transformmatrix * offsetPosition; // Pass texture coords and instance color to fragment shader vTextureCoord = a_texturecoord; vInstanceColor = a_instancecolor; } ================================================ FILE: src/client/shaders/arrows/vertex.glsl ================================================ #version 300 es in vec4 a_position; in vec3 a_instanceposition; // Instance position offset (vec3: xyz) in vec4 a_instancecolor; // Instance color (vec4: rgba) in float a_instancerotation; // Instance rotation (float: radians) uniform mat4 u_transformmatrix; out vec4 vColor; void main() { // Create rotation matrix float cosA = cos(a_instancerotation); float sinA = sin(a_instancerotation); mat2 rotMat = mat2(cosA, sinA, -sinA, cosA); // Rotate vertex position vec2 rotated = rotMat * a_position.xy; vec3 rotatedPosition = vec3(rotated, a_position.z); // Add instance position offset vec3 finalPosition = rotatedPosition + a_instanceposition; gl_Position = u_transformmatrix * vec4(finalPosition, 1.0); vColor = a_instancecolor; } ================================================ FILE: src/client/shaders/board_uber_shader/fragment.glsl ================================================ #version 300 es precision highp float; // src/client/shaders/board_uber_shader/fragment.glsl // GLOBAL UNIFORMS (May be used by several effects) uniform sampler2D u_colorTexture; uniform sampler2D u_maskTexture; // This texture has white pixels where light tiles are and black pixels where dark tiles are. uniform sampler2D u_perlinNoiseTexture; uniform sampler2D u_whiteNoiseTexture; uniform vec2 u_resolution; // Canvas dimensions, used for aspect correction uniform float u_pixelDensity; // How many device pixels per virtual pixel // The integers representing the unique id of effect types A & B this frame. uniform float u_effectTypeA; uniform float u_effectTypeB; // The master blend factor between the 'A' and 'B' effect slots. uniform float u_transitionProgress; // Spectral Edge Uniforms (Effect Type 4) uniform float u4_flowDistance; uniform vec2 u4_flowDirectionVec; uniform float u4_gradientRepeat; uniform float u4_maskOffset; uniform float u4_strength; uniform vec3 u4_color1; uniform vec3 u4_color2; uniform vec3 u4_color3; uniform vec3 u4_color4; uniform vec3 u4_color5; uniform vec3 u4_color6; // Iridescence Uniforms (Effect Type 5) uniform float u5_flowDistance; uniform vec2 u5_flowDirectionVec; uniform float u5_gradientRepeat; uniform float u5_maskOffset; uniform float u5_strength; uniform vec3 u5_color1; uniform vec3 u5_color2; uniform vec3 u5_color3; uniform vec3 u5_color4; uniform vec3 u5_color5; uniform vec3 u5_color6; // Ember Verge Uniforms (Effect Type 11) uniform float u11_flowDistance; uniform vec2 u11_flowDirectionVec; uniform float u11_gradientRepeat; uniform float u11_maskOffset; uniform float u11_strength; uniform vec3 u11_color1; uniform vec3 u11_color2; uniform vec3 u11_color3; uniform vec3 u11_color4; uniform vec3 u11_color5; uniform vec3 u11_color6; // Dusty Wastes Uniforms (Effect Type 6) uniform float u6_strength; // The opacity of the scrolling noise texture uniform float u6_noiseTiling; // How many times the noise texture repeats across the screen uniform vec2 u6_uvOffset1; // The texture offset for noise layer 1 (calculated cpu side for more control) uniform vec2 u6_uvOffset2; // The texture offset for noise layer 2 (calculated cpu side for more control) // Static Zone Uniforms (Effect Type 7) uniform float u7_strength; // The opacity of the white noise pixels uniform vec2 u7_uvOffset; // The texture offset for the white noise (calculated cpu side for more control) uniform float u7_pixelWidth; // How many pixels wide the white noise texture is uniform float u7_pixelSize; // How many virtual pixels wide each static pixel should be // INPUTS in vec2 v_uv; // The model's original UVs for color/mask in vec4 v_screenCoord; // The screen-space coordinate for the noise in vec4 v_color; out vec4 out_color; // Helper function to get a color from a procedural gradient. vec3 getColorFromRamp(float coord, vec3 color1, vec3 color2, vec3 color3, vec3 color4, vec3 color5, vec3 color6) { vec3 color = u5_color1; // Scale coord by the number of colors to create N segments, // allowing the last segment to wrap back to the first. float NUM_COLORS = 6.0; float scaledCoord = coord * NUM_COLORS; int index = int(floor(scaledCoord)); float blendFactor = fract(scaledCoord); // This chain of if-statements acts as an array lookup. if (index == 0) color = mix(color1, color2, blendFactor); else if (index == 1) color = mix(color2, color3, blendFactor); else if (index == 2) color = mix(color3, color4, blendFactor); else if (index == 3) color = mix(color4, color5, blendFactor); else if (index == 4) color = mix(color5, color6, blendFactor); else if (index == 5) color = mix(color6, color1, blendFactor); // Wrap back to the first return color; } // Applies a color gradient flow procedural gradient effect. vec3 ColorFlow( // --- Input values --- vec3 baseColor, vec2 screenUV, float maskValue, // --- Effect parameters --- float flowDistance, vec2 flowDirectionVec, float gradientRepeat, float maskOffset, float strength, // --- Color stops --- vec3 color1, vec3 color2, vec3 color3, vec3 color4, vec3 color5, vec3 color6 ) { // Project the screen UV onto the flow direction vector to get a 1D coordinate. float projectedUv = dot(screenUV, flowDirectionVec); // Add the scrolled distance, apply the repeat factor, and apply the mask offset. float phase = (projectedUv * gradientRepeat) + flowDistance + (maskValue * maskOffset); // Get the final wrapped coordinate for the color lookup. float gradientCoord = fract(phase); // Get the procedural color from our ramp. vec3 gradientColor = getColorFromRamp(gradientCoord, color1, color2, color3, color4, color5, color6); // Blend the gradient color with the base tile color. return mix(baseColor, gradientColor, strength); } // Applies the "Dusty Wastes" animated noise effect. vec3 DustyWastes( // --- Input values --- vec3 baseColor, vec2 screenUV ) { const float NOISE_MULTIPLIER = 1.0; // Default: 1.13 Affects average final brightness to more closely match the original texture color // Apply the pre-calculated offsets. vec2 uv1 = screenUV * u6_noiseTiling + u6_uvOffset1; vec2 uv2 = screenUV * u6_noiseTiling + u6_uvOffset2; float noise1 = texture(u_perlinNoiseTexture, uv1).r; float noise2 = texture(u_perlinNoiseTexture, uv2).r; float finalNoise = noise1 * noise2 * NOISE_MULTIPLIER; float signedNoise = (finalNoise * 2.0) - 1.0; return baseColor + (signedNoise * u6_strength); } // Applies the "Static" pixelated noise effect. vec3 Static( vec3 baseColor, vec2 screenUV ) { // vec2 snappedUV = floor((screenUV * u_resolution) / u7_pixelSize) * u7_pixelSize / u_resolution + u7_uvOffset; vec2 snappedUV = screenUV * u_resolution[1] / u7_pixelWidth / u7_pixelSize / u_pixelDensity + u7_uvOffset; float noise = texture(u_whiteNoiseTexture, snappedUV).r; float signedNoise = (noise * 2.0) - 1.0; return baseColor + (signedNoise * u7_strength); // Apply a brightness/darkness effect } // Switchboard. Takes an effect type and returns the result at full strength. vec3 calculateEffectColor( float effectType, vec3 baseColor, vec2 screenUV, float maskValue ) { if (effectType == 4.0) { return ColorFlow( baseColor, screenUV, maskValue, // Pass effect-specific uniforms u4_flowDistance, u4_flowDirectionVec, u4_gradientRepeat, u4_maskOffset, u4_strength, // Color stops u4_color1, u4_color2, u4_color3, u4_color4, u4_color5, u4_color6 ); } else if (effectType == 5.0) { return ColorFlow( baseColor, screenUV, maskValue, // Pass effect-specific uniforms u5_flowDistance, u5_flowDirectionVec, u5_gradientRepeat, u5_maskOffset, u5_strength, // Color stops u5_color1, u5_color2, u5_color3, u5_color4, u5_color5, u5_color6 ); } else if (effectType == 11.0) { return ColorFlow( baseColor, screenUV, maskValue, // Pass effect-specific uniforms u11_flowDistance, u11_flowDirectionVec, u11_gradientRepeat, u11_maskOffset, u11_strength, // Color stops u11_color1, u11_color2, u11_color3, u11_color4, u11_color5, u11_color6 ); } else if (effectType == 6.0) { return DustyWastes( baseColor, screenUV ); } else if (effectType == 7.0) { return Static( baseColor, screenUV ); } // Default case: no effect return baseColor; } void main() { // INITIAL SETUP vec4 baseColor = texture(u_colorTexture, v_uv) * v_color; float maskValue = texture(u_maskTexture, v_uv).r; // Normalize coordinates and adjust for aspect ratio vec2 screenUV = gl_FragCoord.xy / u_resolution.xy; float aspect_ratio = u_resolution.x / u_resolution.y; screenUV.x *= aspect_ratio; // UBER-SHADER LOGIC // 1. Calculate the result for Slot A at full strength. vec3 modulatedColorA = calculateEffectColor(u_effectTypeA, baseColor.rgb, screenUV, maskValue); // 2. Calculate the result for Slot B at full strength. vec3 modulatedColorB = calculateEffectColor(u_effectTypeB, baseColor.rgb, screenUV, maskValue); // 3. Smoothly blend between the full results of the two slots. vec3 blendedModulatedColor = mix(modulatedColorA, modulatedColorB, u_transitionProgress); // 4. The final blended color is now applied directly to the whole tile. // The mask is only used internally by effects that need it (like color flow). out_color = vec4(clamp(blendedModulatedColor, 0.0, 1.0), baseColor.a); } ================================================ FILE: src/client/shaders/board_uber_shader/vertex.glsl ================================================ #version 300 es // INPUTS in vec3 a_position; in vec2 a_texturecoord; in vec4 a_color; uniform mat4 u_transformmatrix; // OUTPUTS out vec2 v_uv; out vec4 v_screenCoord; // Crucial for screen-space effects out vec4 v_color; // Color is needed for transparency of bigger boards void main() { gl_Position = u_transformmatrix * vec4(a_position, 1.0); v_uv = a_texturecoord; v_screenCoord = gl_Position; v_color = a_color; } ================================================ FILE: src/client/shaders/color/fragment.glsl ================================================ #version 300 es precision highp float; in vec4 vColor; out vec4 fragColor; void main() { fragColor = vColor; } ================================================ FILE: src/client/shaders/color/instanced/vertex.glsl ================================================ #version 300 es in vec4 a_position; in vec4 a_color; in vec4 a_instanceposition; // Per-instance position offset attribute uniform mat4 u_transformmatrix; out vec4 vColor; void main() { // Add the instance offset to the vertex position vec4 transformedVertexPosition = vec4(a_position.xyz + a_instanceposition.xyz, 1.0); gl_Position = u_transformmatrix * transformedVertexPosition; vColor = a_color; } ================================================ FILE: src/client/shaders/color/vertex.glsl ================================================ #version 300 es in vec4 a_position; in vec4 a_color; uniform mat4 u_transformmatrix; out vec4 vColor; void main() { gl_Position = u_transformmatrix * a_position; vColor = a_color; } ================================================ FILE: src/client/shaders/color_grade/fragment.glsl ================================================ #version 300 es precision highp float; // --- UNIFORMS --- uniform sampler2D u_sceneTexture; uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform float u_brightness; // 0.0 is no change uniform float u_contrast; // 1.0 is no change uniform float u_gamma; // 1.0 is no change uniform float u_saturation; // 1.0 is no change, 0.0 = grayscale uniform vec3 u_tintColor; // vec3(1.0) is no change uniform float u_hueOffset; // 0.0 is no change (0.0 to 1.0) in vec2 v_uv; out vec4 out_color; // --- CONSTANTS --- // These are standard weights for calculating luminance, based on human eye perception. const vec3 LUMINANCE_VECTOR = vec3(0.2126, 0.7152, 0.0722); // --- HELPER FUNCTIONS for Hue Shift --- // Converts RGB color space to HSV color space vec3 rgb2hsv(vec3 c) { vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); float d = q.x - min(q.w, q.y); float e = 1.0e-10; return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); } // Converts HSV color space to RGB color space vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); } void main() { // Start with the original color from the scene vec4 originalColor = texture(u_sceneTexture, v_uv); vec4 processedColor = texture(u_sceneTexture, v_uv); // --- ORDER OF OPERATIONS --- // 1. Apply Contrast processedColor.rgb = (processedColor.rgb - 0.5) * u_contrast + 0.5; // 2. Apply Brightness processedColor.rgb += u_brightness; // 3. Apply Gamma Correction // We use 1.0 / gamma which is the standard for gamma correction. // Use max() to ensure the input to pow() is never negative, preventing NaN errors. processedColor.rgb = pow(max(processedColor.rgb, 0.0), vec3(1.0 / u_gamma)); // 4. Apply Saturation // Calculate the grayscale value using the luminance vector. // The dot product is a fast way to do (r*0.2126 + g*0.7152 + b*0.0722). float luminance = dot(processedColor.rgb, LUMINANCE_VECTOR); vec3 grayscale = vec3(luminance); // Blend between the grayscale processed color and the original color. // mix() is a built-in GLSL function for linear interpolation. processedColor.rgb = mix(grayscale, processedColor.rgb, u_saturation); // 5. Apply Tint processedColor.rgb *= u_tintColor; // 6. Apply Hue Shift vec3 hsv = rgb2hsv(processedColor.rgb); hsv.x += u_hueOffset; hsv.x = fract(hsv.x); // Wrap the hue value around (0.0 to 1.0) processedColor.rgb = hsv2rgb(hsv); // Clamp the processed color to ensure it's in the valid 0.0-1.0 range processedColor.rgb = clamp(processedColor.rgb, 0.0, 1.0); // Apply Master Strength // Blend between the original color and the fully processed color vec3 finalRgb = mix(originalColor.rgb, processedColor.rgb, u_masterStrength); out_color = vec4(finalRgb, originalColor.a); // Preserve original alpha } ================================================ FILE: src/client/shaders/color_texture/fragment.glsl ================================================ #version 300 es precision highp float; in vec2 vTextureCoord; in vec4 vColor; uniform sampler2D u_sampler; out vec4 fragColor; void main(void) { fragColor = texture(u_sampler, vTextureCoord, -0.5) * vColor; // Apply a mipmap LOD bias so as to make the textures sharper. } ================================================ FILE: src/client/shaders/color_texture/vertex.glsl ================================================ #version 300 es in vec4 a_position; in vec2 a_texturecoord; in vec4 a_color; uniform mat4 u_transformmatrix; out vec2 vTextureCoord; out vec4 vColor; void main(void) { gl_Position = u_transformmatrix * a_position; vTextureCoord = a_texturecoord; vColor = a_color; } ================================================ FILE: src/client/shaders/fullscreen_colorflow/fragment.glsl ================================================ #version 300 es precision highp float; // This shader is used by ColorFlowRenderer.ts to render a fullscreen // color flow effect in the background of the chess game, replacing the starfield. // This is only used occasionally for obtaining cool video footage. // --- Uniforms --- uniform vec2 u_resolution; uniform float u_flowDistance; // Equivalent to time * speed uniform vec2 u_flowDirectionVec; // Calculated cos/sin vector uniform float u_gradientRepeat; // How dense the rainbow is uniform float u_alpha; // Master opacity // The 6-stop gradient colors uniform vec3 u_colors[6]; out vec4 fragColor; // Linearly interpolates between 6 colors based on a 0-1 t value vec3 getColorFromRamp(float t) { float scaledT = t * 6.0; int index = int(floor(scaledT)); float blend = fract(scaledT); // Handle wrapping int nextIndex = (index + 1) % 6; // In WebGL2 we can index arrays dynamically // Note: We clamp index to avoid any precision issues at exactly 1.0 if (index >= 6) index = 0; return mix(u_colors[index], u_colors[nextIndex], blend); } void main() { // 1. Normalized UV Coordinates with Aspect Ratio Correction vec2 uv = gl_FragCoord.xy / u_resolution; float aspect = u_resolution.x / u_resolution.y; uv.x *= aspect; // 2. Project UV onto the flow vector // This creates the linear "river" direction float projectedUv = dot(uv, u_flowDirectionVec); // 3. Calculate Phase // (projected position * density) + animation offset float phase = (projectedUv * u_gradientRepeat) + u_flowDistance; // 4. Wrap for gradient lookup (0.0 to 1.0) float gradientCoord = fract(phase); // 5. Sample Color vec3 finalColor = getColorFromRamp(gradientCoord); fragColor = vec4(finalColor, u_alpha); } ================================================ FILE: src/client/shaders/glitch/fragment.glsl ================================================ #version 300 es precision highp float; // src/client/shaders/glitch/fragment.glsl uniform sampler2D u_sceneTexture; // --- Master Strength --- uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect // --- Chromatic Aberration Uniforms --- uniform float u_aberrationStrength; uniform vec2 u_aberrationOffset; // Direction and magnitude of the color channel separation // --- Horizontal Tearing Uniforms --- uniform float u_tearStrength; uniform float u_tearResolution; // Height of tear lines in virtual CSS pixels (e.g., 5.0 for 5px high lines) uniform float u_tearMaxDisplacement; // Max horizontal shift for a tear in virtual CSS pixels uniform float u_time; // For animating tear patterns uniform vec2 u_resolution; // Viewport resolution (width, height) in pixels uniform float u_devicePixelRatio; in vec2 v_uv; out vec4 out_color; void main() { vec4 originalColor = texture(u_sceneTexture, v_uv); vec2 texCoord = v_uv; // --- Horizontal Tearing --- // Calculate a unique tear offset for this scanline based on its Y coordinate and time // Convert u_tearResolution (pixels) to UV space height of a tear line // Convert u_tearMaxDisplacement (pixels) to UV space horizontal displacement float tearLineHeightUV = u_tearResolution * u_devicePixelRatio / u_resolution.y; float tearMaxDisplacementUV = u_tearMaxDisplacement * u_devicePixelRatio / u_resolution.x; // Determine which "tear line" this pixel belongs to float lineIndex = floor(v_uv.y / tearLineHeightUV); // Use a quantized time for a less fluid, more 'jerky' animation float quantizedTime = floor(u_time * 20.0) / 20.0; // Adjust 20.0 for desired 'steps' per second // Generate a pseudo-random value for displacement per line, varying with quantized time // This replaces drawing from a noise texture float randomOffset = fract(sin(lineIndex * 123.456 + quantizedTime * 789.0) * 4567.89); // Example magic numbers // Map randomOffset (0-1) to desired displacement range (-tearMaxDisplacementUV to +tearMaxDisplacementUV) float tearOffset = (randomOffset * 2.0 - 1.0) * tearMaxDisplacementUV; // Determine direction based on lineIndex: every other line shifts opposite float direction = mix(1.0, -1.0, mod(lineIndex, 2.0)); // Apply the tear offset, scaled by tearStrength and direction texCoord.x += tearOffset * direction * u_tearStrength; // --- Chromatic Aberration --- // Sample the red, green, and blue channels with different offsets vec4 color; color.r = texture(u_sceneTexture, texCoord + u_aberrationOffset * u_aberrationStrength).r; color.g = texture(u_sceneTexture, texCoord).g; color.b = texture(u_sceneTexture, texCoord - u_aberrationOffset * u_aberrationStrength).b; color.a = texture(u_sceneTexture, texCoord).a; // Keep alpha as is // Get the fully distorted color vec4 distortedColor = color; // 'color' already contains the combined aberration and tear effects // Blend between original and distorted color using master strength out_color = mix(originalColor, distortedColor, u_masterStrength); } ================================================ FILE: src/client/shaders/heat_wave/fragment.glsl ================================================ #version 300 es precision highp float; uniform sampler2D u_sceneTexture; uniform sampler2D u_noiseTexture; uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform float u_time; uniform float u_strength; uniform vec2 u_resolution; // Canvas dimensions in vec2 v_uv; out vec4 out_color; void main() { // Store the original, unaffected color vec4 originalColor = texture(u_sceneTexture, v_uv); // Aspect ratio correction float aspectRatio = u_resolution.x / u_resolution.y; vec2 noiseBaseUV = v_uv; noiseBaseUV.x *= aspectRatio; // Create two different scrolling UVs for the noise texture. // They scroll at different speeds and in different directions. vec2 noiseUV1 = vec2(noiseBaseUV.x - u_time * 0.05, noiseBaseUV.y - u_time * 0.2); vec2 noiseUV2 = vec2(noiseBaseUV.x + u_time * 0.03, noiseBaseUV.y + u_time * 0.13); // Sample the noise texture at both locations. float noise1 = texture(u_noiseTexture, noiseUV1).r; float noise2 = texture(u_noiseTexture, noiseUV2).r; // Calculate the distortion from the *difference* between the two samples. float distortion = (noise1 - noise2); // Calculate the horizontal offset in UV space. Resolution-independent. float horizontalOffset = distortion * u_strength; // Correct the offset for the screen's aspect ratio. // Results in consistent pixel displacement regardless of screen width. horizontalOffset /= aspectRatio; // Create the final distorted UVs for the scene texture. vec2 distortedUV = vec2(v_uv.x + horizontalOffset, v_uv.y); // Sample the scene using the distorted coordinates. vec4 distortedColor = texture(u_sceneTexture, distortedUV); // Blend between original and distorted color using master strength out_color = mix(originalColor, distortedColor, u_masterStrength); } ================================================ FILE: src/client/shaders/highlights/vertex.glsl ================================================ #version 300 es in vec4 a_position; // Base shape vertex position (e.g., from -0.5 to 0.5) in vec4 a_color; // Base shape vertex color in vec3 a_instanceposition; // Per-instance position offset (center of the shape) uniform mat4 u_transformmatrix; // Combined model-view-projection matrix uniform float u_size; // Desired size multiplier of the shape (scales a_position) out vec4 vColor; // Pass color to fragment shader void main() { // Scale the base vertex position's X and Y by the shape width. // Assumes Z is 0 or handled appropriately, W is 1 for position. vec3 scaledLocalPosition = vec3(a_position.xy * u_size, a_position.z); // Add the instance-specific position offset to the scaled local position. vec3 finalPosition = scaledLocalPosition + a_instanceposition; // Transform the final position. gl_Position = u_transformmatrix * vec4(finalPosition, 1.0); // Pass the vertex color through. vColor = a_color; } ================================================ FILE: src/client/shaders/mini_images/fragment.glsl ================================================ #version 300 es precision highp float; in vec2 vTextureCoord; // Interpolated texture coordinate from vertex shader in vec4 vColor; // Interpolated vertex color from vertex shader uniform sampler2D u_sampler; // Texture sampler out vec4 fragColor; // Output fragment color void main() { // Sample the texture with LOD bias for sharpness vec4 texColor = texture(u_sampler, vTextureCoord, -0.5); // Multiply the texture color by the vertex color fragColor = texColor * vColor; } ================================================ FILE: src/client/shaders/mini_images/vertex.glsl ================================================ #version 300 es in vec4 a_position; // Per-vertex position in vec2 a_texturecoord; // Per-vertex texture coordinate in vec4 a_color; // Per-vertex color in vec3 a_instanceposition; // Per-instance position offset uniform mat4 u_transformmatrix; // Transformation matrix uniform float u_size; // Desired size multiplier of the shape (scales a_position) out vec2 vTextureCoord; // Pass texture coord to fragment shader out vec4 vColor; // Pass vertex color to fragment shader void main() { // Scale the base vertex position's X and Y by the shape width. // Assumes Z is 0 or handled appropriately, W is 1 for position. vec3 scaledLocalPosition = vec3(a_position.xy * u_size, a_position.z); // Apply instance position offset to the base vertex position vec3 finalPosition = scaledLocalPosition + a_instanceposition; // Transform the final position gl_Position = u_transformmatrix * vec4(finalPosition, 1.0); // Pass texture coordinates and vertex color to the fragment shader vTextureCoord = a_texturecoord; vColor = a_color; } ================================================ FILE: src/client/shaders/post_pass/fragment.glsl ================================================ #version 300 es precision highp float; // The texture containing our rendered scene. uniform sampler2D u_sceneTexture; // The UV coordinates passed from the vertex shader. in vec2 v_uv; // The output color for this pixel. out vec4 out_color; void main() { // Simply sample the texture at the given UV coordinate and output the color. // This is a "pass-through" shader. out_color = texture(u_sceneTexture, v_uv); } ================================================ FILE: src/client/shaders/post_pass/vertex.glsl ================================================ #version 300 es // A simple quad that covers the entire screen in Normalized Device Coordinates. const vec2 positions[6] = vec2[]( vec2(-1.0, -1.0), vec2( 1.0, -1.0), vec2(-1.0, 1.0), vec2(-1.0, 1.0), vec2( 1.0, -1.0), vec2( 1.0, 1.0) ); // We need to pass the UV coordinates to the fragment shader. // They are derived from the vertex positions. out vec2 v_uv; void main() { gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0); // Convert NDC position to UV coordinates (0.0 to 1.0) v_uv = gl_Position.xy * 0.5 + 0.5; } ================================================ FILE: src/client/shaders/sine_wave/fragment.glsl ================================================ #version 300 es precision highp float; uniform sampler2D u_sceneTexture; // --- Distortion Controls --- uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform vec2 u_amplitude; uniform vec2 u_frequency; uniform float u_angle; // The new angle in radians uniform float u_time; in vec2 v_uv; out vec4 out_color; const float PI = 3.1415926535; void main() { // Get the original, unaffected color vec4 originalColor = texture(u_sceneTexture, v_uv); // Calculate the distorted texture coordinates // Setup the rotated coordinate system of each wave vec2 dir1 = vec2(cos(u_angle), sin(u_angle)); vec2 dir2 = vec2(-dir1.y, dir1.x); // Center the UV coordinates so the rotation is around the middle of the screen vec2 centeredUV = v_uv - 0.5; // Calculate distances along the rotated axes float dist1 = dot(centeredUV, dir2); float dist2 = dot(centeredUV, dir1); // Calculate the sine wave offsets float offset1 = sin(dist1 * u_frequency.y * 2.0 * PI + u_time) * u_amplitude.x; float offset2 = sin(dist2 * u_frequency.x * 2.0 * PI + u_time) * u_amplitude.y; // Combine offsets to get the final distortion vector // The final offset is a combination of both waves moving along their respective directions vec2 totalOffset = (dir1 * offset1) + (dir2 * offset2); vec2 distortedUV = v_uv + totalOffset; // Get the fully distorted color vec4 distortedColor = texture(u_sceneTexture, distortedUV); // Blend between original and distorted color using master strength out_color = mix(originalColor, distortedColor, u_masterStrength); } ================================================ FILE: src/client/shaders/starfield/vertex.glsl ================================================ #version 300 es // Base shape vertex (a corner of the star's quad) in vec2 a_position; // Per-instance attributes in vec2 a_instanceposition; // Center position of the star (x,y) in vec4 a_instancecolor; // Color of the star (r,g,b,a) in float a_instancesize; // Size of the star uniform mat4 u_transformmatrix; out vec4 vColor; void main() { // Scale the base quad vertex by the instance's size, then add the instance's position. // This creates a quad of the correct size at the correct location. vec2 finalPosition = (a_position * a_instancesize) + a_instanceposition; // We provide z=0.0 and w=1.0 for a complete 3D position vector gl_Position = u_transformmatrix * vec4(finalPosition, 0.0, 1.0); vColor = a_instancecolor; } ================================================ FILE: src/client/shaders/texture/fragment.glsl ================================================ #version 300 es precision highp float; in vec2 vTextureCoord; uniform sampler2D u_sampler; out vec4 fragColor; void main() { // Apply a mipmap LOD bias to make textures sharper. fragColor = texture(u_sampler, vTextureCoord, -0.5); } ================================================ FILE: src/client/shaders/texture/instanced/vertex.glsl ================================================ #version 300 es in vec4 a_position; // Per-vertex position (vec4 for homogeneous coordinates) in vec2 a_texturecoord; // Per-vertex texture coordinates in vec3 a_instanceposition; // Per-instance position offset (vec3: xyz) uniform mat4 u_transformmatrix; // Transformation matrix out vec2 vTextureCoord; // To fragment shader void main() { // Apply instance position offset vec4 offsetPosition = a_position + vec4(a_instanceposition, 0.0); // Transform position and pass through texture coords gl_Position = u_transformmatrix * offsetPosition; // Pass texture coordinates directly to fragment shader vTextureCoord = a_texturecoord; } ================================================ FILE: src/client/shaders/texture/vertex.glsl ================================================ #version 300 es in vec4 a_position; in vec2 a_texturecoord; uniform mat4 u_transformmatrix; out vec2 vTextureCoord; void main(void) { gl_Position = u_transformmatrix * a_position; vTextureCoord = a_texturecoord; } ================================================ FILE: src/client/shaders/vignette/fragment.glsl ================================================ #version 300 es precision highp float; uniform sampler2D u_sceneTexture; // --- Vignette Controls --- uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform float u_radius; // How far the vignette reaches. 0.5 is the screen edge. uniform float u_softness; // How gradual the falloff is. uniform float u_intensity; // How dark the vignette is. 1.0 is pure black. in vec2 v_uv; out vec4 out_color; void main() { // Store the original, unaffected color vec4 originalColor = texture(u_sceneTexture, v_uv); // Calculate the applied vignette color // Calculate the distance of the pixel from the center (0.5, 0.5) float dist = length(v_uv - vec2(0.5)); // Calculate the vignette factor using smoothstep for a nice falloff. float vignetteFactor = smoothstep(u_radius, u_radius + u_softness, dist); // Calculate the color with the applied vignette. // The 'mix' function blends between the original color and black. vec3 vignettedColor = mix(originalColor.rgb, vec3(0.0), vignetteFactor * u_intensity); // Blend between original and vignetted color using master strength vec3 finalRgb = mix(originalColor.rgb, vignettedColor, u_masterStrength); // Set the final output, preserving the original alpha out_color = vec4(finalRgb, originalColor.a); } ================================================ FILE: src/client/shaders/voronoi_distortion/fragment.glsl ================================================ #version 300 es precision highp float; // Input Texture uniform sampler2D u_sceneTexture; // Effect Controls uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform float u_time; // Used for animation uniform float u_density; // Controls the number of Voronoi cells uniform float u_strength; // The maximum strength of the cells' distortion uniform float u_ridgeThickness; // The width of the ridges between cells uniform float u_ridgeStrength; // The intensity of the ridges' lensing // Canvas Properties uniform vec2 u_resolution; // Canvas dimensions for aspect ratio correction in vec2 v_uv; out vec4 out_color; // --- Helper Functions --- vec2 noise2x2(vec2 p) { // A small constant is added after the dot product, preventing the bottom-left point from being stationary. float x = dot(p, vec2(123.4, 234.5)) + 42.0; float y = dot(p, vec2(345.6, 456.7)) + 24.0; vec2 noise = vec2(x, y); noise = sin(noise); noise = noise * 43758.5453; noise = fract(noise); return noise; } void main() { // Store the original, unaffected color vec4 originalColor = texture(u_sceneTexture, v_uv); // Voronoi Cell Calculation // Normalize coordinates and adjust for aspect ratio to keep cells roughly square. vec2 uv = gl_FragCoord.xy / u_resolution.xy; float aspect_ratio = u_resolution.x / u_resolution.y; uv.x *= aspect_ratio; // Scale coordinates by density vec2 uv_scaled = uv * u_density; // Get the integer and fractional parts of the coordinate vec2 currentGridId = floor(uv_scaled); vec2 currentGridCoord = fract(uv_scaled); currentGridCoord = currentGridCoord - 0.5; // Moves range from [0,1] to [-0.5,0.5] float d1 = 10.0; // Distance to the closest point float d2 = 10.0; // Distance to the second-closest point vec2 d1_vector = vec2(0.0); // Vector to the closest point // Loop through neighboring cells to find the two closest points for (float i = -1.0; i <= 1.0; i++) { for (float j = -1.0; j <= 1.0; j++) { vec2 adjGridCoords = vec2(i, j); // Vary points based on time + noise. vec2 noise = noise2x2(currentGridId + adjGridCoords); vec2 pointOnAdjGrid = adjGridCoords + sin(u_time * noise) * 0.5; // 0.5 controls how far the points can move (should not exceed nearest neighbor) // Calculate distance from the current fragment to this cell's point float dist = length(currentGridCoord - pointOnAdjGrid); if (dist < d1) { // This point is the new closest. // The old closest becomes the new second-closest. d2 = d1; d1 = dist; d1_vector = pointOnAdjGrid - currentGridCoord; } else if (dist < d2) { // This point is not the closest, but it is the new second-closest. d2 = dist; } } } // Calculate the main cell distortion // Determine the direction of distortion. We want to push *away* from the // closest point, which is the inverse of the vector *to* the closest point. vec2 distortion_direction = normalize(-d1_vector); // Determine the magnitude of the distortion. We want zero distortion near // the point (min_dist = 0) and max distortion far from it. float distortion_magnitude = u_strength * smoothstep(0.1, 0.8, d1); vec2 total_offset = distortion_direction * distortion_magnitude; // Boundary Lensing Effect // Calculate the boundary "ridge" mask. // (d2 - d1) is our edge detector. It's almost 0 on the boundary. float ridge_mask = 1.0 - smoothstep(0.0, u_ridgeThickness, d2 - d1); // Create the sharp "lensing" distortion perpendicular to the boundary. // The direction is perpendicular to the vector pointing from the pixel to the cell center. vec2 ridge_direction = normalize(vec2(d1_vector.y, -d1_vector.x)); vec2 ridge_offset = ridge_direction * ridge_mask * u_ridgeStrength; // Combine the base distortion with the new boundary distortion. total_offset = total_offset + ridge_offset; // The final offset vector needs to be scaled back for the non-aspect-corrected UVs. total_offset.x /= aspect_ratio; // [DEBUG] Visualize the raw distance field. // out_color = vec4(vec3(d1), 1.0); // return; // Get the fully distorted color // Apply the calculated offset to the original texture coordinates vec2 distorted_uv = v_uv + total_offset; vec4 distortedColor = texture(u_sceneTexture, distorted_uv); // Blend between original and distorted color using master strength out_color = mix(originalColor, distortedColor, u_masterStrength); } ================================================ FILE: src/client/shaders/water/fragment.glsl ================================================ #version 300 es precision highp float; // src/client/shaders/water/fragment.glsl // --- Input from Vertex Shader --- in vec2 v_uv; out vec4 out_color; // The maximum number of sources, must match the JS constant. const int MAX_SOURCES = 10; // --- Uniforms --- uniform float u_masterStrength; // 0.0 = no effect, 1.0 = full effect uniform sampler2D u_sceneTexture; // The original scene texture uniform int u_sourceCount; // How many active ripple sources we have uniform vec2 u_centers[MAX_SOURCES]; // The centers of the ripple sources (in UV space) uniform float u_time; // Current time for animation uniform vec2 u_resolution; // The dimensions of the canvas (width, height) uniform float u_strength; // The magnitude of the distortion uniform float u_oscillationSpeed; // How fast the waves oscillate uniform float u_frequency; // The density of the waves (waves per UV unit) void main() { // Store the original, unaffected color vec4 originalColor = texture(u_sceneTexture, v_uv); // This will store the combined X and Y offsets from all sources. vec2 totalDistortionVector = vec2(0.0); for (int i = 0; i < MAX_SOURCES; i++) { if (i >= u_sourceCount) break; // Stop if we've processed all active sources vec2 center = u_centers[i]; // Calculate the difference vector and apply aspect correction to it. vec2 diff = v_uv - center; diff.x *= u_resolution.x / u_resolution.y; float dist = length(diff); // Calculate the sine wave. This creates the ripple pattern. // The wave is based on distance from the center, frequency, and time. float wave = sin(dist * u_frequency - u_time * u_oscillationSpeed); // Calculate the distortion vector for this specific ripple source and add it to our accumulator. // `normalize(diff)` gives the direction away from this source's center. if (dist > 0.0) { // Avoid division by zero at the exact center vec2 sourceDistortion = normalize(diff) * wave; totalDistortionVector += sourceDistortion; } } // De-correct the aspect ratio of the final distortion vector // before applying it to the non-corrected UV coordinates. totalDistortionVector.x /= (u_resolution.x / u_resolution.y); // Apply the calculated distortion to the texture coordinates. vec2 distortedTexCoord = v_uv + totalDistortionVector * u_strength; // Sample the original scene with the new, distorted coordinates. vec4 distortedColor = texture(u_sceneTexture, distortedTexCoord); // Blend between original and distorted color using master strength out_color = mix(originalColor, distortedColor, u_masterStrength); } ================================================ FILE: src/client/shaders/water_ripple/fragment.glsl ================================================ #version 300 es precision highp float; // The maximum number of concurrent droplets supported by this shader. // This value MUST match the corresponding constant in the WaterRipplePass class. const int MAX_DROPLETS = 20; // Input Texture uniform sampler2D u_sceneTexture; // The result of the previous rendering pass. // Droplet Data (Received every frame) uniform vec2 u_centers[MAX_DROPLETS]; // The center UV coordinate for each droplet. uniform float u_times[MAX_DROPLETS]; // The elapsed time (in seconds) for each droplet. uniform int u_dropletCount; // The number of active droplets to process in the arrays. // Global Effect Controls (Configurable) uniform float u_strength; // Overall strength of the distortion effect. uniform float u_propagationSpeed; // How fast the ripple's leading edge expands (UV units/sec). uniform float u_oscillationSpeed; // How fast the internal waves oscillate (phase shift/sec). uniform float u_frequency; // The density of the rings in the ripple (waves per UV unit). uniform float u_falloff; // How quickly the trailing waves decay. Higher is faster. uniform float u_glintIntensity; // Controls the brightness of the glint. uniform float u_glintExponent; // Controls the sharpness/tightness of the glint. Higher is thinner. // Canvas Properties uniform vec2 u_resolution; // The width and height of the canvas for aspect correction. in vec2 v_uv; out vec4 out_color; void main() { // This vector will accumulate the distortion offset from all active droplets. vec2 totalOffset = vec2(0.0); float totalGlint = 0.0; // Loop through only the active droplets for this frame. for (int i = 0; i < u_dropletCount; i++) { vec2 center = u_centers[i]; float time = u_times[i]; // Calculate aspect-corrected distance from the droplet's center. // This makes ripples circular on non-square screens. vec2 diff = v_uv - center; diff.x *= u_resolution.x / u_resolution.y; float dist = length(diff); // Create a soft mask for the ripple's leading edge that is 1.0 inside and fades to 0.0 outside. // This prevents the ripple from appearing before it should. float maxRadius = time * u_propagationSpeed; float waveMask = 1.0 - smoothstep(maxRadius - 0.1, maxRadius, dist); // Generate the animating sine wave. float wave = sin((dist * u_frequency) - (time * u_oscillationSpeed)); // Calculate the inverse square decay for the trailing waves. // Determine how far this pixel is "behind" the leading edge. float distanceBehind = max(0.0, maxRadius - dist); float trailingFade = 1.0 / (1.0 + u_falloff * distanceBehind * distanceBehind); // Combine factors to get the magnitude of the ripple. float rippleMagnitude = wave * waveMask * trailingFade; // Calculate the offset in the aspect-corrected space. // `normalize(diff)` gives a direction in the circular space. vec2 offset = normalize(diff) * rippleMagnitude * u_strength; // Transform the offset vector back from the aspect-corrected space to the original UV space. // We only transformed the x-component, so we only need to reverse that. offset.x /= (u_resolution.x / u_resolution.y); // The accumulated final offset. totalOffset += offset; // Calculate the glint for this droplet // Isolate the crest of the wave (the positive part). float crest = max(0.0, wave); // Raise it to a high power to create a tight hotspot and add it to the total. totalGlint += pow(crest, u_glintExponent) * waveMask * trailingFade; } // Apply the final, combined offset to the original texture coordinates. vec2 distortedUV = v_uv + totalOffset; vec4 color = texture(u_sceneTexture, distortedUV); // Add the final accumulated glint color.rgb += totalGlint * u_glintIntensity; // Glint intensity out_color = color; } ================================================ FILE: src/client/sounds/spritesheet/note.txt ================================================ Sound spritesheet was compressed to opus format, using stereo, 48000Hz, and 64 Bitrate. ================================================ FILE: src/client/views/admin.ejs ================================================ <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>
================================================ FILE: src/client/views/components/footer.ejs ================================================
================================================ FILE: src/client/views/components/header.ejs ================================================

<%=t('header.home')%>

================================================ FILE: src/client/views/createaccount.ejs ================================================ <%=t('create-account.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('create-account.title')%>

<%# Honeypot Bot Catcher: Any client that fills out this invisible field is a bot! %>

<%=t('create-account.agreement.0')%><%=t('create-account.agreement.1')%><%=t('create-account.agreement.2')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/credits.ejs ================================================ <%=t('credits.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('credits.title')%>

<%=t('credits.copyright')%>

<%=t('credits.variants_heading')%>

<%=t('credits.variants_credits.0')%>

<%=t('credits.variants_credits.1')%>

<%=t('credits.variants_credits.2')%>

<%=t('credits.variants_credits.3')%>

<%=t('credits.variants_credits.4')%>

<%=t('credits.variants_credits.5')%>

<%=t('credits.variants_credits.6')%>

<%=t('credits.variants_credits.7')%>

<%=t('credits.variants_credits.8')%>

<%=t('credits.variants_credits.9')%>

Omega <%=t('credits.variants_credits.10')%>

Omega^2 <%=t('credits.variants_credits.11')%>

Omega^3 <%=t('credits.variants_credits.12')%>

Omega^4 <%=t('credits.variants_credits.13')%>

<%=t('credits.variants_credits.14')%>

<%=t('credits.variants_credits.15')%>

<%=t('credits.variants_credits.16')%>

<%=t('credits.variants_credits.17')%>

<%=t('credits.textures_heading')%>

Cburnett <%=t('credits.textures_licensed_under')%> Creative Commons Attribution-Share Alike 3.0 Unported License.

Green Chess <%=t('credits.textures_licensed_under')%> Creative Commons Attribution-Share Alike 3.0

Pychess <%=t('credits.textures_licensed_under')%> GNU General Public License

<%=t('credits.sounds_heading')%>

<%=t('credits.sounds_credits.0.0')%> Lichess-org Lila <%=t('credits.sounds_credits.0.1')%> GNU Affero General Public License

<%=t('credits.sounds_credits.1')%>

<%=t('credits.code_heading')%>

High Performance Matrix & Vector Operations <%=t('credits.code_credits.0')%>

Infinite Chess Notation Converter <%=t('credits.code_credits.1')%>

HydroChess Engine <%=t('credits.code_credits.2')%>

<%=t('credits.language_heading')%>

<%=t('credits.language_credits.0')%><%=t('credits.language_credits.1')%><%=t('credits.language_credits.2')%><%=t('credits.language_credits.3')%><%=t('credits.language_credits.4')%>

<%=t('credits.language_credits.5')%><%=t('credits.language_credits.6')%><%=t('credits.language_credits.7')%>

<%=t('credits.language_credits.8')%><%=t('credits.language_credits.9')%><%=t('credits.language_credits.10')%>

<%=t('credits.language_credits.11')%><%=t('credits.language_credits.12')%><%=t('credits.language_credits.13')%>

<%=t('credits.language_credits.14')%><%=t('credits.language_credits.15')%><%=t('credits.language_credits.16')%>

<%=t('credits.language_credits.17')%><%=t('credits.language_credits.18')%><%=t('credits.language_credits.19')%>

<%=t('credits.language_credits.20')%><%=t('credits.language_credits.21')%><%=t('credits.language_credits.22')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/errors/400.ejs ================================================ 400

400 Bad Request

<%=t('error-pages.400_message')%>

================================================ FILE: src/client/views/errors/401.ejs ================================================ 401

401 Unauthorized

================================================ FILE: src/client/views/errors/404.ejs ================================================ 404

404 Not Found

================================================ FILE: src/client/views/errors/409.ejs ================================================ 409

409 Conflict

<%=t('error-pages.409_message.0')%><%=t('error-pages.409_message.1')%><%=t('error-pages.409_message.2')%>

================================================ FILE: src/client/views/errors/500.ejs ================================================ 500

500 Server Error

<%=t('error-pages.500_message')%>

================================================ FILE: src/client/views/guide.ejs ================================================ <%=t('play.guide.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('play.guide.title')%>

<%=t('play.guide.rules')%>


<%=t('play.guide.rules_paragraphs.0')%>

  • <%=t('play.guide.rules_paragraphs.1')%>
  • <%=t('play.guide.rules_paragraphs.2.0')%><%=t('play.guide.rules_paragraphs.2.1')%><%=t('play.guide.rules_paragraphs.2.2')%>

<%=t('play.guide.rules_paragraphs.3')%>

<%=t('play.guide.rules_paragraphs.4')%>

<%=t('play.guide.careful_heading')%>


<%=t('play.guide.careful_paragraphs.0')%>

<%=t('play.guide.careful_paragraphs.1')%>

<%=t('play.guide.controls_heading')%>


<%=t('play.guide.controls_paragraph')%>

  • WASD<%=t('play.guide.keybinds.0')%>
  • <%=t('play.guide.keybinds.1.0')%><%=t('play.guide.keybinds.1.1')%><%=t('play.guide.keybinds.1.2')%><%=t('play.guide.keybinds.1.3')%>
  • <%=t('play.guide.keybinds.2.0')%><%=t('play.guide.keybinds.2.1')%>
  • <%=t('play.guide.keybinds.3.0')%><%=t('play.guide.keybinds.3.1')%><%=t('play.guide.keybinds.3.2')%><%=t('play.guide.keybinds.3.3')%>
  • <%=t('play.guide.keybinds.4.0')%><%=t('play.guide.keybinds.4.1')%>
  • 1<%=t('play.guide.keybinds.5')%>

<%=t('play.guide.controls_paragraph2')%>

  • R<%=t('play.guide.keybinds_extra.0')%>
  • N<%=t('play.guide.keybinds_extra.1')%>
  • M<%=t('play.guide.keybinds_extra.2')%>
  • P<%=t('play.guide.keybinds_extra.3')%>P.
  • `<%=t('play.guide.keybinds_extra.4.0')%>~<%=t('play.guide.keybinds_extra.4.1')%>

<%=t('play.guide.fairy_heading')%>


<%=t('play.guide.fairy_paragraph')%>

<%=t('play.guide.pieces.chancellor.name')%>

<%=t('play.guide.pieces.chancellor.description')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/icnvalidator.html ================================================ ICN Game Notation Validator

ICN Game Notation Validator

This tool validates Infinite Chess Notation (ICN) games outputed from the HydroChess SPRT. Upload a JSON file to check if all game notations are valid syntax, don't crash, contain no illegal moves, and have the expected endings.

Upload JSON File

Click to select or drag and drop a JSON file containing an array of game notations

Processing...

0%

Processing game 0 of 0

Validation Summary

0 / 0 GAMES PASSED
0% SUCCESS RATE

ICN Converter Errors

0

Formulator Errors

0

Illegal Move Errors

0

Termination Mismatch

0

Activity Log

================================================ FILE: src/client/views/index.ejs ================================================ <%=t('index.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('index.what_is_it_title')%>

<%-t('index.what_is_it_pargaraphs', { joinArrays: '

' })%>

<%=t('index.how_to_title')%>

<%=t('index.how_to_paragraph.0')%><%=t('index.how_to_paragraph.1')%><%=t('index.how_to_paragraph.2')%>


<%=t('index.about_title')%>

<%=t('index.about_paragraphs.0')%>

<%=t('index.about_paragraphs.1.0')%><%=t('index.about_paragraphs.1.1')%><%=t('index.about_paragraphs.1.2')%>


<%=t('index.patreon_title')%>

Andreas

Mauer01

Meni Rosenfeld

Uncle Dave

IM Luke Harmon-Vellotti

KnightBeforeLast

Marillia

Elliot Glazer

AbyssalCryptid

Marsgreekgod

Or Ben Naim

EmmaBellHelium

Mark Wiemer

Tommy Nordman

Joe & Rafi Moed

<%=t('index.github_title')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/leaderboard.ejs ================================================ <%=t('leaderboard.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('leaderboard.title')%>


<%=t('leaderboard.inactive_players.0')%><%=ratingDeviationUncertaintyThreshold%><%=t('leaderboard.inactive_players.1')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/login.ejs ================================================ <%=t('login.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('login.title')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/member.ejs ================================================ <%=t('member.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>
Blank profile image

<%=t('member.joined')%>

<%=t('member.seen')%>

<%=t('member.infinity_leaderboard_position')%>

<%=t('member.ranked_elo')%>

<%=t('member.infinity_leaderboard_rating_deviation')%>

<%=t('member.practice_progress')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/news.ejs ================================================ <%=t('news.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('news.title')%>


<%-newsHTML%>

<%=t('news.more_dev_logs.0')%><%=t('news.more_dev_logs.1')%><%=t('news.more_dev_logs.2')%><%=t('news.more_dev_logs.3')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/play.ejs ================================================ <%=t('play.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>
<%=t('play.loading')%>
================================================ FILE: src/client/views/resetpassword.ejs ================================================ <%=t('reset_password.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('reset_password.title')%>

<%=t('reset_password.instruction')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/client/views/termsofservice.ejs ================================================ <%=t('terms.title')%> <%- include(`${viewsfolder}/components/header`, {t:t, languages:languages, language:language}) %>

<%=t('terms.title')%>

<% if (language !== "en-US") { %>

<%=t('terms.warning.0')%><%=t('terms.warning.1')%><%=t('terms.warning.2')%>

<% } %>

<%=t('terms.consent')%>

<%=t('terms.guardian_consent')%>

<%=t('terms.parents_header')%>

<%=t('terms.parents_paragraphs.0')%>

<%=t('terms.parents_paragraphs.1')%>

<%=t('terms.fair_play_header')%>

<%=t('terms.fair_play_paragraph1.0')%>

<%=t('terms.fair_play_paragraph2')%>

  • <%=t('terms.fair_play_rules.0')%>

  • <%=t('terms.fair_play_rules.1')%>

  • <%=t('terms.fair_play_rules.2')%>

  • <%=t('terms.fair_play_rules.3')%>

<%=t('terms.cleanliness_header')%>

<%=t('terms.cleanliness_rules.0')%>

<%=t('terms.cleanliness_rules.1')%>

<%=t('terms.privacy_header')%>

<%=t('terms.privacy_rules.0')%>

<%=t('terms.privacy_rules.1')%>

<%=t('terms.privacy_rules.2')%>

<%=t('terms.privacy_rules.3')%>

<%=t('terms.privacy_rules.4.0')%> <%=t('terms.privacy_rules.4.1')%> <%=t('terms.privacy_rules.4.2')%>

<%=t('terms.privacy_rules.5')%>

<%=t('terms.privacy_rules.6')%>

<%=t('terms.cookie_header')%>

<%=t('terms.cookie_paragraphs.0')%>

<%=t('terms.cookie_paragraphs.1')%>

<%=t('terms.conclusion_header')%>

<%=t('terms.conclusion_paragraphs.0')%>

<%=t('terms.conclusion_paragraphs.1.0')%> <%=t('terms.conclusion_paragraphs.1.1')%> <%=t('terms.conclusion_paragraphs.1.2')%>

<%=t('terms.conclusion_paragraphs.2.0')%> <%=t('terms.conclusion_paragraphs.2.1')%><%=t('terms.conclusion_paragraphs.2.2')%>

<%=t('terms.conclusion_paragraphs.3')%>

<%=t('terms.conclusion_paragraphs.4')%>

<%=t('terms.conclusion_paragraphs.5.0')%> <%=t('terms.conclusion_paragraphs.5.1')%>

<%=t('terms.thanks')%>

<%- include(`${viewsfolder}/components/footer`, {t:t, languages:languages, language:language}) %> ================================================ FILE: src/server/api/AdminPanel.ts ================================================ // src/server/api/AdminPanel.ts /** * This script handles all incoming commands send from the admin console page * /admin */ import type { Request, Response } from 'express'; import validators from '../../shared/util/validators.js'; import { deleteAccount } from '../controllers/deleteAccountController.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { manuallyVerifyUser } from '../controllers/verifyAccountController.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { areRolesHigherInPriority } from '../controllers/roles.js'; import { refreshGitHubContributorsList } from './GitHub.js'; import { deleteAllRefreshTokensForUser } from '../database/refreshTokenManager.js'; import { addToBlacklist, removeFromBlacklist } from '../database/blacklistManager.js'; // Constants ------------------------------------------------------------------------- const validCommands = [ 'ban', 'unban', 'delete', 'username', 'logout', 'verify', 'userinfo', 'updatecontributors', 'help', ] as const; // Functions ------------------------------------------------------------------------- function processCommand(req: Request, res: Response): void { const command = req.params['command']!; const commandAndArgs = parseArgumentsFromCommand(command); if (!req.memberInfo || !req.memberInfo.signedIn) { res.status(401).send('Cannot send commands while logged out.'); return; } if (!(req.memberInfo.roles?.includes('admin') ?? false)) { res.status(403).send('Cannot send commands without the admin role'); return; } // TODO prevent affecting accounts with equal or higher roles switch (commandAndArgs[0]) { case 'ban': banEmailCommand(command, commandAndArgs, req, res); return; case 'unban': unbanEmailCommand(command, commandAndArgs, req, res); return; case 'delete': deleteCommand(command, commandAndArgs, req, res); return; case 'username': usernameCommand(command, commandAndArgs, req, res); return; case 'logout': logoutUser(command, commandAndArgs, req, res); return; case 'verify': verify(command, commandAndArgs, req, res); return; case 'userinfo': getUserInfo(command, commandAndArgs, req, res); return; case 'updatecontributors': updateContributorsCommand(command, req, res); return; case 'help': helpCommand(commandAndArgs, res); return; default: res.status(422).send('Unknown command.'); return; } } function parseArgumentsFromCommand(command: string): string[] { // Parse command const commandAndArgs: string[] = []; let inQuote: boolean = false; let temp: string = ''; for (let i = 0; i < command.length; i++) { if (command[i] === '"') { if (i === 0 || command[i - 1] !== '\\') { inQuote = !inQuote; } else { temp += '"'; } } else if (command[i] === ' ' && !inQuote) { commandAndArgs.push(temp); temp = ''; } else if (inQuote || (command[i] !== '"' && command[i] !== ' ')) { temp += command[i]; } } commandAndArgs.push(temp); return commandAndArgs; } function deleteCommand( command: string, commandAndArgs: string[], req: Request, res: Response, ): void { if (commandAndArgs.length < 3) { res.status(422).send( 'Invalid number of arguments, expected 2, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const reason = commandAndArgs[2]!; const usernameArgument = commandAndArgs[1]!; const record = getMemberDataByCriteria( ['user_id', 'username', 'roles'], 'username', usernameArgument, ); if (record === undefined) return sendAndLogResponse(res, 404, 'User ' + usernameArgument + ' does not exist.'); // They were found... const adminsRoles = req.memberInfo?.signedIn ? req.memberInfo.roles : null; const rolesOfAffectedUser = record.roles === null ? null : JSON.parse(record.roles); // Don't delete them if they are equal or higher than your status if (!areRolesHigherInPriority(adminsRoles, rolesOfAffectedUser)) return sendAndLogResponse(res, 403, 'Forbidden to delete ' + record.username + '.'); try { deleteAccount(record.user_id, reason); sendAndLogResponse(res, 200, 'Successfully deleted user ' + record.username + '.'); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); sendAndLogResponse(res, 500, `Failed to delete user (${record.username}): ${errorMessage}`); } } function banEmailCommand( command: string, commandAndArgs: string[], req: Request, res: Response, ): void { if (commandAndArgs.length !== 2) { res.status(422).send( 'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const email = commandAndArgs[1]!.toLowerCase(); // Validate email format const validationResult = validators.validateEmail(email); if (validationResult !== validators.EmailValidationResult.Ok) { const errorKey = validators.getEmailErrorTranslation(validationResult); sendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`); return; } try { addToBlacklist(email, 'banned'); sendAndLogResponse(res, 200, `Successfully banned ${email}.`); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); sendAndLogResponse(res, 500, `Failed to ban email (${email}): ${errorMessage}`); } } function unbanEmailCommand( command: string, commandAndArgs: string[], req: Request, res: Response, ): void { if (commandAndArgs.length !== 2) { res.status(422).send( 'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const email = commandAndArgs[1]!.toLowerCase(); // Validate email format const validationResult = validators.validateEmail(email); if (validationResult !== validators.EmailValidationResult.Ok) { const errorKey = validators.getEmailErrorTranslation(validationResult); sendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`); return; } try { removeFromBlacklist(email); sendAndLogResponse(res, 200, `Successfully unbanned ${email}.`); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); sendAndLogResponse(res, 500, `Failed to unban email (${email}): ${errorMessage}`); } } function usernameCommand( command: string, commandAndArgs: string[], req: Request, res: Response, ): void { if (commandAndArgs[1] === 'get') { if (commandAndArgs.length < 3) { res.status(422).send( 'Invalid number of arguments, expected 2, got ' + (commandAndArgs.length - 1) + '.', ); return; } const parsedId = Number.parseInt(commandAndArgs[2]!); if (Number.isNaN(parsedId)) { res.status(422).send('User id must be an integer.'); return; } // Valid Syntax logCommand(command, req); const record = getMemberDataByCriteria(['username'], 'user_id', parsedId); if (record === undefined) sendAndLogResponse(res, 404, 'User with id ' + parsedId + ' does not exist.'); else sendAndLogResponse(res, 200, record.username); } else if (commandAndArgs[1] === 'set') { if (commandAndArgs.length < 4) { res.status(422).send( 'Invalid number of arguments, expected 3, got ' + (commandAndArgs.length - 1) + '.', ); return; } // TODO add username changing logic res.status(503).send('Changing usernames is not yet supported.'); } else if (commandAndArgs[1] === undefined) { res.status(422).send('Expected either get or set as a subcommand.'); } else { res.status(422).send( 'Invalid subcommand, expected either get or set, got ' + commandAndArgs[1] + '.', ); } } function logoutUser(command: string, commandAndArgs: string[], req: Request, res: Response): void { if (commandAndArgs.length < 2) { res.status(422).send( 'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const usernameArgument = commandAndArgs[1]!; const record = getMemberDataByCriteria(['user_id', 'username'], 'username', usernameArgument); if (record === undefined) { sendAndLogResponse(res, 404, 'User ' + usernameArgument + ' does not exist.'); return; } try { // Effectively terminates all login sessions of the user deleteAllRefreshTokensForUser(record.user_id); } catch (e) { const errorMessage = e instanceof Error ? e.stack : String(e); logEventsAndPrint( `Error during admin-manual-logout of user "${record.username}": ${errorMessage}`, 'errLog.txt', ); sendAndLogResponse( res, 500, `Failed to log out user "${record.username}" due to internal error.`, ); return; } sendAndLogResponse(res, 200, 'User ' + record.username + ' successfully logged out.'); // Use their case-sensitive username } function verify(command: string, commandAndArgs: string[], req: Request, res: Response): void { if (commandAndArgs.length < 2) { res.status(422).send( 'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const email = commandAndArgs[1]!.toLowerCase(); // Validate email format const validationResult = validators.validateEmail(email); if (validationResult !== validators.EmailValidationResult.Ok) { const errorKey = validators.getEmailErrorTranslation(validationResult); sendAndLogResponse(res, 422, `Invalid email format: ${errorKey ?? 'unknown error'}`); return; } // This method works without us having to confirm they exist first const result = manuallyVerifyUser(email); // { success, username, reason } if (result.success) sendAndLogResponse(res, 200, 'User ' + result.username + ' has been verified!'); else sendAndLogResponse(res, 500, result.reason); // Failure message } function getUserInfo(command: string, commandAndArgs: string[], req: Request, res: Response): void { if (commandAndArgs.length < 2) { res.status(422).send( 'Invalid number of arguments, expected 1, got ' + (commandAndArgs.length - 1) + '.', ); return; } // Valid Syntax logCommand(command, req); const username = commandAndArgs[1]!; const record = getMemberDataByCriteria( [ 'user_id', 'username', 'roles', 'joined', 'last_seen', 'preferences', 'is_verified', 'is_verification_notified', 'username_history', 'checkmates_beaten', ], 'username', username, ); if (record === undefined) sendAndLogResponse(res, 404, 'User ' + username + ' does not exist.'); else sendAndLogResponse(res, 200, JSON.stringify(record)); } function updateContributorsCommand(command: string, req: Request, res: Response): void { logCommand(command, req); refreshGitHubContributorsList(); sendAndLogResponse(res, 200, 'Contributors should now be updated!'); } function helpCommand(commandAndArgs: string[], res: Response): void { if (commandAndArgs.length === 1) { res.status(200).send( 'Commands: ' + validCommands.join(', ') + '\nUse help to get more information about a command.', ); return; } switch (commandAndArgs[1]) { case 'ban': res.status(200).send('Syntax: ban \nBans the given email address.'); return; case 'unban': res.status(200).send('Syntax: unban \nUnbans the given email address.'); return; case 'delete': res.status(200).send( "Syntax: delete [reason]\nDeletes the given user's account for an optional reason.", ); return; case 'username': res.status(200).send( 'Syntax: username get \n username set \nGets or sets the username of the account with the given userid', ); return; case 'logout': res.status(200).send( 'Syntax: logout \nLogs out all sessions of the account with the given username.', ); return; case 'verify': res.status(200).send( 'Syntax: verify \nVerifies the account with the given email address.', ); return; case 'userinfo': res.status(200).send('Syntax: userinfo \nPrints info about a user.'); return; case 'updatecontributors': res.status(200).send( 'Syntax: updatecontributors\nManually update to the most recent contributors list from the Github API. Should be used for testing', ); return; case 'help': res.status(200).send( 'Syntax: help [command]\nPrints the list of commands or information about a command.', ); return; default: res.status(422).send('Unknown command.'); return; } } function logCommand(command: string, req: Request): void { if (req.memberInfo?.signedIn) { logEventsAndPrint( `Command executed by admin "${req.memberInfo.username}" of id "${req.memberInfo.user_id}": ` + command, 'adminCommands.txt', ); } else throw new Error('Admin SHOULD have been logged in by this point. DANGEROUS'); } function sendAndLogResponse(res: Response, code: number, message: any): void { res.status(code).send(message); // Also log the sent response logEventsAndPrint('Result: ' + message + '\n', 'adminCommands.txt'); } export { processCommand }; ================================================ FILE: src/server/api/EditorSavesAPI.int.test.ts ================================================ // src/server/api/EditorSavesAPI.int.test.ts /** * Integration tests for the EditorSavesAPI endpoints. * * This test suite verifies that the editor saves API endpoints work correctly, * including authentication, validation, quota limits, and ownership verification. */ import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; import editorutil from '../../shared/util/editorutil.js'; import { testRequest } from '../../tests/testRequest.js'; import integrationUtils from '../../tests/integrationUtils.js'; import editorSavesManager from '../database/editorSavesManager.js'; import { generateTables, clearAllTables } from '../database/databaseTables.js'; describe('EditorSavesAPI Integration', () => { // Runs once at the very start of this file beforeAll(() => { generateTables(); }); // Runs before EVERY single 'it' block beforeEach(() => { clearAllTables(); }); describe('GET /api/editor-saves', () => { it('should return all saved positions for authenticated user', async () => { const user = await integrationUtils.createAndLoginUser(); const position1 = { name: 'A simple position', piece_count: 32, timestamp: Date.now(), icn: 'icn-data-1', compression: 'none', pawn_double_push: true, castling: true, }; const position2 = { name: 'Another simple position', piece_count: 76, timestamp: Date.now(), icn: 'icn-data-2', compression: 'none', pawn_double_push: false, castling: true, }; // Save positions to the database through the API await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send(position1); await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send(position2); const response = await testRequest() .get('/api/editor-saves') .set('Cookie', user.cookie); expect(response.status).toBe(200); expect(response.body.saves).toMatchObject([ { name: position1.name, piece_count: position1.piece_count, timestamp: position1.timestamp, }, { name: position2.name, piece_count: position2.piece_count, timestamp: position2.timestamp, }, ]); }); it('should return 401 if user is not authenticated', async () => { const response = await testRequest().get('/api/editor-saves'); expect(response.status).toBe(401); }); }); describe('POST /api/editor-saves', () => { it('should save a new position successfully', async () => { const user = await integrationUtils.createAndLoginUser(); const position = { name: 'Test Position', piece_count: 32, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: false, }; const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send(position); expect(response.status).toBe(201); expect(response.body).toMatchObject({ success: true }); // Verify the position was actually saved to the database const saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id); expect(saves[0]).toMatchObject({ name: position.name, piece_count: position.piece_count, timestamp: position.timestamp, }); }); it('should save and retrieve undefined (indeterminate) tristate for pawn_double_push and castling', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Tristate Position', piece_count: 5, timestamp: Date.now(), icn: 'test-icn-tristate', compression: 'none', pawn_double_push: undefined, castling: undefined, }); expect(response.status).toBe(201); // Verify raw DB values: -1 = indeterminate const icnData = editorSavesManager.getSavedPositionICN( 'Tristate Position', user.user_id, ); expect(icnData?.pawn_double_push).toBe(-1); expect(icnData?.castling).toBe(-1); }); it('should return 400 if name is missing', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ piece_count: 10, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(400); }); it('should return 400 if name is empty', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: '', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: false, castling: false, }); expect(response.status).toBe(400); }); it('should return 400 if name exceeds max length', async () => { const user = await integrationUtils.createAndLoginUser(); const longName = 'a'.repeat(editorutil.MAX_POSITION_NAME_LENGTH + 1); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: longName, piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(400); }); it('should return 400 if icn is missing', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(400); }); it('should return 400 if icn is empty', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 0, timestamp: Date.now(), icn: '', compression: 'none', pawn_double_push: false, castling: false, }); expect(response.status).toBe(400); }); it('should return 400 if icn exceeds max length', async () => { const user = await integrationUtils.createAndLoginUser(); const longIcn = 'a'.repeat(editorutil.MAX_ICN_LENGTH + 1); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 278_569, timestamp: Date.now(), icn: longIcn, compression: 'none', pawn_double_push: true, castling: false, }); expect(response.status).toBe(400); }); it('should return 403 if quota is exceeded', async () => { const user = await integrationUtils.createAndLoginUser(); // Add 50 positions to reach the quota limit for (let i = 0; i < editorSavesManager.MAX_SAVED_POSITIONS; i++) { await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: `Position ${i}`, piece_count: 8, timestamp: Date.now(), icn: 'test-icn', compression: 'none', pawn_double_push: true, castling: true, }); } // Try to add one more, should fail const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: false, castling: false, }); expect(response.status).toBe(403); }); it('should overwrite position if name already exists', async () => { const user = await integrationUtils.createAndLoginUser(); // Save first position await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Duplicate Name', piece_count: 10, timestamp: 1000, icn: 'test-icn-1', compression: 'none', pawn_double_push: true, castling: false, }); // Save another position with the same name but different data const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Duplicate Name', piece_count: 20, timestamp: 2000, icn: 'test-icn-2', compression: 'none', pawn_double_push: false, castling: true, }); // Should succeed expect(response.status).toBe(201); // Verify only one position exists with the new data const saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id); expect(saves).toMatchObject([ { name: 'Duplicate Name', piece_count: 20, timestamp: 2000, }, ]); // Verify the ICN was also overwritten const icnData = editorSavesManager.getSavedPositionICN('Duplicate Name', user.user_id); expect(icnData?.icn).toBe('test-icn-2'); expect(icnData?.pawn_double_push).toBe(0); expect(icnData?.castling).toBe(1); }); it('should return 401 if user is not authenticated', async () => { const response = await testRequest().post('/api/editor-saves').send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(401); }); it('should return 400 if timestamp is missing', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 13, icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(400); }); it('should return 400 if piece_count is missing', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(400); }); it('should treat missing pawn_double_push as indeterminate and save successfully', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', castling: true, }); expect(response.status).toBe(201); // Omitted field should be stored as -1 (indeterminate) const icnData = editorSavesManager.getSavedPositionICN('Test Position', user.user_id); expect(icnData?.pawn_double_push).toBe(-1); }); it('should treat missing castling as indeterminate and save successfully', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, }); expect(response.status).toBe(201); // Omitted field should be stored as -1 (indeterminate) const icnData = editorSavesManager.getSavedPositionICN('Test Position', user.user_id); expect(icnData?.castling).toBe(-1); }); }); describe('GET /api/editor-saves/:position_name', () => { it('should return position ICN if user owns it', async () => { const user = await integrationUtils.createAndLoginUser(); // Save a position first await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: false, }); const response = await testRequest() .get(`/api/editor-saves/${encodeURIComponent('Test Position')}`) .set('Cookie', user.cookie); expect(response.status).toBe(200); expect(response.body).toMatchObject({ icn: 'test-icn-data', pawn_double_push: true, castling: false, }); expect(typeof response.body.timestamp).toBe('number'); }); it('should return 404 if position not found or not owned', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .get(`/api/editor-saves/${encodeURIComponent('Nonexistent Position')}`) .set('Cookie', user.cookie); expect(response.status).toBe(404); }); it('should handle position names with spaces', async () => { const user = await integrationUtils.createAndLoginUser(); // Save a position with spaces in the name await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Position With Spaces', piece_count: 16, timestamp: Date.now(), icn: 'test-icn-spaces', compression: 'none', pawn_double_push: false, castling: true, }); const response = await testRequest() .get(`/api/editor-saves/${encodeURIComponent('Position With Spaces')}`) .set('Cookie', user.cookie); expect(response.status).toBe(200); expect(response.body).toMatchObject({ icn: 'test-icn-spaces', pawn_double_push: false, castling: true, }); expect(typeof response.body.timestamp).toBe('number'); }); it('should return undefined for indeterminate (tristate) pawn_double_push and castling', async () => { const user = await integrationUtils.createAndLoginUser(); await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Tristate Get Test', piece_count: 3, timestamp: Date.now(), icn: 'test-icn-tristate', compression: 'none', pawn_double_push: undefined, castling: undefined, }); const response = await testRequest() .get(`/api/editor-saves/${encodeURIComponent('Tristate Get Test')}`) .set('Cookie', user.cookie); expect(response.status).toBe(200); expect(response.body.pawn_double_push).toBeUndefined(); expect(response.body.castling).toBeUndefined(); }); it('should return 401 if user is not authenticated', async () => { const response = await testRequest().get( `/api/editor-saves/${encodeURIComponent('Test Position')}`, ); expect(response.status).toBe(401); }); }); describe('DELETE /api/editor-saves/:position_name', () => { it('should delete position successfully', async () => { const user = await integrationUtils.createAndLoginUser(); // Save a position first await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Test Position', piece_count: 13, timestamp: Date.now(), icn: 'test-icn-data', compression: 'none', pawn_double_push: true, castling: true, }); const response = await testRequest() .delete(`/api/editor-saves/${encodeURIComponent('Test Position')}`) .set('Cookie', user.cookie); expect(response.status).toBe(200); expect(response.body).toMatchObject({ success: true, saves: [] }); // Verify the position was actually deleted from the database const saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id); expect(saves).toHaveLength(0); }); it('should return 404 if position not found or not owned', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .delete(`/api/editor-saves/${encodeURIComponent('Nonexistent Position')}`) .set('Cookie', user.cookie); expect(response.status).toBe(404); }); it('should handle position names with spaces', async () => { const user = await integrationUtils.createAndLoginUser(); // Save a position with spaces await testRequest().post('/api/editor-saves').set('Cookie', user.cookie).send({ name: 'Position With Spaces', piece_count: 8, timestamp: Date.now(), icn: 'test-icn', compression: 'none', pawn_double_push: false, castling: false, }); const response = await testRequest() .delete(`/api/editor-saves/${encodeURIComponent('Position With Spaces')}`) .set('Cookie', user.cookie); expect(response.status).toBe(200); }); it('should return 401 if user is not authenticated', async () => { const response = await testRequest().delete( `/api/editor-saves/${encodeURIComponent('Test Position')}`, ); expect(response.status).toBe(401); }); }); describe('Edge cases and integration', () => { it('should handle very long ICN within limit', async () => { const user = await integrationUtils.createAndLoginUser(); const maxLengthIcn = 'a'.repeat(editorutil.MAX_ICN_LENGTH); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test', piece_count: 250_592, timestamp: Date.now(), icn: maxLengthIcn, compression: 'none', pawn_double_push: true, castling: false, }); expect(response.status).toBe(201); // Verify it was saved correctly const save = editorSavesManager.getSavedPositionICN('Test', user.user_id); expect(save?.icn).toBe(maxLengthIcn); }); it('should handle name at max length', async () => { const user = await integrationUtils.createAndLoginUser(); const maxLengthName = 'a'.repeat(editorutil.MAX_POSITION_NAME_LENGTH); const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: maxLengthName, piece_count: 4, timestamp: Date.now(), icn: 'test', compression: 'none', pawn_double_push: false, castling: true, }); expect(response.status).toBe(201); // Verify it was saved correctly const saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id); expect(saves[0]?.name).toBe(maxLengthName); }); it('should receive piece_count from client', async () => { const user = await integrationUtils.createAndLoginUser(); const icn = '12345'; const response = await testRequest() .post('/api/editor-saves') .set('Cookie', user.cookie) .send({ name: 'Test', piece_count: 100, timestamp: Date.now(), icn, compression: 'none', pawn_double_push: true, castling: true, }); expect(response.status).toBe(201); // Verify the piece_count was set correctly from client const saves = editorSavesManager.getAllSavedPositionsForUser(user.user_id); expect(saves[0]?.piece_count).toBe(100); }); it('should allow two different users to have positions with the same name', async () => { const user1 = await integrationUtils.createAndLoginUser(); const user2 = await integrationUtils.createAndLoginUser(); // Both users save a position with the same name const response1 = await testRequest() .post('/api/editor-saves') .set('Cookie', user1.cookie) .send({ name: 'Same Name', piece_count: 10, timestamp: Date.now(), icn: 'icn-user1', compression: 'none', pawn_double_push: true, castling: false, }); const response2 = await testRequest() .post('/api/editor-saves') .set('Cookie', user2.cookie) .send({ name: 'Same Name', piece_count: 10, timestamp: Date.now(), icn: 'icn-user2', compression: 'none', pawn_double_push: false, castling: true, }); expect(response1.status).toBe(201); expect(response2.status).toBe(201); // Verify both positions exist independently const saves1 = editorSavesManager.getAllSavedPositionsForUser(user1.user_id); const saves2 = editorSavesManager.getAllSavedPositionsForUser(user2.user_id); expect(saves1[0]?.name).toBe('Same Name'); expect(saves2[0]?.name).toBe('Same Name'); }); }); }); ================================================ FILE: src/server/api/EditorSavesAPI.ts ================================================ // src/server/api/EditorSavesAPI.ts /** * API endpoints for managing saved positions in the editor. */ import type { Request, Response } from 'express'; import * as z from 'zod'; import editorutil from '../../shared/util/editorutil.js'; import { logZodError } from '../utility/zodlogger.js'; import editorSavesManager from '../database/editorSavesManager.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Zod Schemas ------------------------------------------------------------------------------- /** Schema for validating the body of POST /api/editor-saves (save position) */ const SavePositionBodySchema = z.strictObject({ name: z .string() .trim() .min(1, 'Name is required') .max( editorutil.MAX_POSITION_NAME_LENGTH, `Name must be ${editorutil.MAX_POSITION_NAME_LENGTH} characters or less`, ), piece_count: z .number() .int('Piece count must be an integer') .nonnegative('Piece count must be 0+'), timestamp: z.number().int('Timestamp must be an integer').nonnegative('Timestamp must be 0+'), icn: z .string() .min(1, 'ICN is required') .max( editorutil.MAX_ICN_LENGTH, `ICN must be ${editorutil.MAX_ICN_LENGTH} characters or less`, ), compression: z.enum(['none', 'deflate-raw']), // undefined represents the indeterminate (third) state pawn_double_push: z.boolean().optional(), castling: z.boolean().optional(), }); /** Schema for validating position_name in URL params */ const PositionNameParamSchema = z.strictObject({ position_name: z .string() .trim() .min(1, 'Position name is required') .max( editorutil.MAX_POSITION_NAME_LENGTH, `Position name must be ${editorutil.MAX_POSITION_NAME_LENGTH} characters or less`, ), }); // API Endpoints ----------------------------------------------------------------------------- /** * API endpoint to get all saved positions for the current user. * Returns { saves: EditorSavesListRecord[] } with position_id, name, and size. * Requires authentication. */ function getSavedPositions(req: Request, res: Response): void { if (!req.memberInfo) { res.status(500).json({ error: 'Server error' }); // `memberInfo` should have been set by auth middleware, even if not signed in return; } // Check if user is authenticated if (!req.memberInfo.signedIn) { res.status(401).json({ error: 'Must be signed in' }); return; } const userId = req.memberInfo.user_id; try { // Get all saved positions for this user const saves = editorSavesManager.getAllSavedPositionsForUser(userId); res.json({ saves }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error retrieving saved positions for user_id ${userId}: ${message}`, 'errLog.txt', ); res.status(500).json({ error: 'Failed to retrieve saved positions' }); } } /** * API endpoint to save a new position for the current user. * If a position with the same name already exists, it will be overwritten. * Expects { name: string, piece_count: number, timestamp: number, icn: string, pawn_double_push?: boolean, castling?: boolean } in request body. * Returns { success: true } on success. * Requires authentication. */ function savePosition(req: Request, res: Response): void { if (!req.memberInfo) { res.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in return; } // Check if user is authenticated if (!req.memberInfo.signedIn) { res.status(401).json({ error: 'Must be signed in' }); return; } const userId = req.memberInfo.user_id; // Validate request body with Zod const parseResult = SavePositionBodySchema.safeParse(req.body); if (!parseResult.success) { const firstError = parseResult.error.issues[0]; const errorMessage = firstError?.message || 'Invalid request body'; res.status(400).json({ error: errorMessage }); logZodError(req.body, parseResult.error, `Invalid save position request body.`); return; } const { name, piece_count, timestamp, icn, compression, pawn_double_push, castling } = parseResult.data; try { // Add the saved position to the database (throws on quota exceeded) editorSavesManager.addSavedPosition( userId, name, piece_count, timestamp, icn, compression, pawn_double_push, castling, ); const saves = editorSavesManager.getAllSavedPositionsForUser(userId); res.status(201).json({ success: true, saves }); } catch (error: unknown) { // Handle the specific quota error if (error instanceof Error && error.message === editorSavesManager.QUOTA_EXCEEDED_ERROR) { res.status(403).json({ error: `Maximum saved positions exceeded` }); return; } const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error saving position for user_id ${userId}: ${message}`, 'errLog.txt'); res.status(500).json({ error: 'Failed to save position' }); } } /** * API endpoint to get a specific saved position by position_name. * Returns { icn: string, pawn_double_push: number, castling: number } on success. * Requires authentication and ownership of the position. */ function getPosition(req: Request, res: Response): void { if (!req.memberInfo) { res.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in return; } // Check if user is authenticated if (!req.memberInfo.signedIn) { res.status(401).json({ error: 'Must be signed in' }); return; } const userId = req.memberInfo.user_id; // Validate position_name from URL params with Zod const parseResult = PositionNameParamSchema.safeParse(req.params); if (!parseResult.success) { res.status(400).json({ error: 'Invalid position_name' }); logZodError(req.params, parseResult.error, `Invalid get position request params.`); return; } const positionName = parseResult.data.position_name; try { // Get the position from the database (filtered by user_id) const position = editorSavesManager.getSavedPositionICN(positionName, userId); if (!position) { res.status(404).json({ error: 'Position not found' }); return; } res.json({ timestamp: position.timestamp, icn: position.icn, compression: position.compression, // Decode tristate: -1 → undefined, 0 → false, 1 → true pawn_double_push: position.pawn_double_push === -1 ? undefined : Boolean(position.pawn_double_push), castling: position.castling === -1 ? undefined : Boolean(position.castling), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error retrieving position for name "${positionName}": ${message}`, 'errLog.txt', ); res.status(500).json({ error: 'Failed to retrieve position' }); } } /** * API endpoint to delete a specific saved position by position_name. * Returns { success: true } on success. * Requires authentication and ownership of the position. */ function deletePosition(req: Request, res: Response): void { if (!req.memberInfo) { res.status(500).json({ error: 'Server error' }); // memberInfo should have been set by auth middleware, even if not signed in return; } // Check if user is authenticated if (!req.memberInfo.signedIn) { res.status(401).json({ error: 'Must be signed in' }); return; } const userId = req.memberInfo.user_id; // Validate position_name from URL params with Zod const parseResult = PositionNameParamSchema.safeParse(req.params); if (!parseResult.success) { res.status(400).json({ error: 'Invalid position_name' }); logZodError(req.params, parseResult.error, `Invalid delete position request params.`); return; } const positionName = parseResult.data.position_name; try { // Delete the position from the database (filtered by user_id) const result = editorSavesManager.deleteSavedPosition(positionName, userId); if (result.changes === 0) { res.status(404).json({ error: 'Position not found' }); return; } const saves = editorSavesManager.getAllSavedPositionsForUser(userId); res.json({ success: true, saves }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error deleting position "${positionName}" for user_id ${userId}: ${message}`, 'errLog.txt', ); res.status(500).json({ error: 'Failed to delete position' }); } } // Exports ----------------------------------------------------------------------------------- export default { // Endpoints getSavedPositions, savePosition, getPosition, deletePosition, }; ================================================ FILE: src/server/api/GitHub.ts ================================================ // src/server/api/GitHub.ts /* * This module, in the future, where be where we connect to GitHub's API * to dynamically refresh a list of github contributors on the webiste, * probably below our patron donors. * * INSTRUCTIONS: * In ANY github account (does not need to be a maintainer of the project), * create a classic access token with ZERO permissions (that is enough), * and paste it in the GITHUB_API_KEY field in the .env file. */ import fs from 'fs'; import path from 'path'; import * as z from 'zod'; import process from 'node:process'; import { writeFile } from 'node:fs/promises'; import AbortController from 'abort-controller'; import { fileURLToPath } from 'node:url'; import { request, RequestOptions } from 'node:https'; import { logZodError } from '../utility/zodlogger.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** A GitHub contributor on the infinitechess.org repository. */ interface Contributor { name: string; iconUrl: string; linkUrl: string; contributionCount: number; } // Variables --------------------------------------------------------------------------- const GitHubContributorSchema = z.array( z.object({ login: z.string(), avatar_url: z.string(), html_url: z.string(), contributions: z.number(), }), ); const PATH_TO_CONTRIBUTORS_FILE = path.join(__dirname, '../../../database/contributors.json'); /** A list of contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org). * This should be periodically refreshed. * * example contributor: * ```js * { name: 'Naviary2', iconUrl: 'https://avatars.githubusercontent.com/u/163621561?v=4', linkUrl: 'https://github.com/Naviary2', contributionCount: 1502 } ``` */ let contributors: Contributor[] = (() => { if (!fs.existsSync(PATH_TO_CONTRIBUTORS_FILE)) return []; const file = fs.readFileSync(PATH_TO_CONTRIBUTORS_FILE).toString(); return JSON.parse(file); })(); // console.log(contributors); /** The interval, in milliseconds, to use GitHub's API to refresh the contributor list. */ const intervalToRefreshContributorsMillis = 1000 * 60 * 60 * 3; // 3 hours // const intervalToRefreshContributorsMillis = 1000 * 5; // 5s for dev testing /** The id of the interval to update contributors. Can be used to cancel it if the API token isn't specified. */ const intervalId = setInterval(refreshGitHubContributorsList, intervalToRefreshContributorsMillis); // refreshGitHubContributorsList(); // Initial refreshal for dev testing // Functions --------------------------------------------------------------------------- /** * Uses GitHub's API to fetch all contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org), * and updates our list! */ function refreshGitHubContributorsList(): void { const { GITHUB_API_KEY, GITHUB_REPO } = process.env; if ( GITHUB_API_KEY === undefined || GITHUB_REPO === undefined || GITHUB_API_KEY.length === 0 || GITHUB_REPO.length === 0 ) { logEventsAndPrint( 'Either Github API key not detected, or repository not specified. Stopping updating contributor list.', 'errLog.txt', ); clearInterval(intervalId); return; } // Create an AbortController for the request const controller = new AbortController(); const signal: AbortSignal = controller.signal as AbortSignal; const options: RequestOptions = { method: 'GET', hostname: 'api.github.com', // "port": null, path: `/repos/${GITHUB_REPO}/contributors`, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${GITHUB_API_KEY}`, 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': process.env['APP_BASE_URL'], // "Content-Length": "0" }, signal, // Pass the signal to the request options }; const req = request(options, function (res) { // The type of this is Uint8Array because Buffer.concat() expects it. const chunks: Uint8Array[] = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', async () => { const body = Buffer.concat(chunks); if (res.statusCode !== 200) return logEventsAndPrint( `Response from GitHub when using API to get contributor list: ${body.toString()}`, 'errLog.txt', ); const response = body.toString(); let unvalidatedJson: any; try { unvalidatedJson = JSON.parse(response); } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); logEventsAndPrint('Error parsing contributors JSON: ' + errMsg, 'errLog.txt'); return; } const zod_result = GitHubContributorSchema.safeParse(unvalidatedJson); if (!zod_result.success) { logZodError( unvalidatedJson, zod_result.error, 'Invalid GitHub API response for contributors.', ); return; } const currentContributors: Contributor[] = zod_result.data.map((c) => ({ name: c.login, iconUrl: c.avatar_url, linkUrl: c.html_url, contributionCount: c.contributions, })); contributors = currentContributors; await writeFile(PATH_TO_CONTRIBUTORS_FILE, JSON.stringify(contributors, null, 2)); // console.log('Contributors updated!'); }); }); // Handle request errors req.on('error', (err) => { if (err.name === 'AbortError') { logEventsAndPrint( 'GitHub contributor request was aborted due to timeout.', 'errLog.txt', ); } else { logEventsAndPrint( `Request error while fetching GitHub contributors: ${err.message}`, 'errLog.txt', ); } }); // Add a timeout using AbortController if request takes too long const abortTimeout = setTimeout(() => { controller.abort(); logEventsAndPrint('GitHub API request timed out.', 'errLog.txt'); }, 10000); req.on('response', () => { clearTimeout(abortTimeout); // Clear timeout once the request gets a response }); req.end(); } /** * Returns a list of contributors on the infinitechess.org [repository](https://github.com/Infinite-Chess/infinitechess.org), * updated every {@link intervalToRefreshContributorsMillis}. */ function getContributors(): Contributor[] { return contributors; } export { refreshGitHubContributorsList, getContributors }; ================================================ FILE: src/server/api/LeaderboardAPI.ts ================================================ // src/server/api/LeaderboardAPI.ts /** * Route * Fetched by leaderboard script. * Sends the client the information about the leaderboard they are currently profile viewing. */ import type { Request, Response } from 'express'; import { Leaderboard } from '../../shared/chess/variants/validleaderboard.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { getTopPlayersForLeaderboard, getPlayerRankInLeaderboard, getEloOfPlayerInLeaderboard, } from '../database/leaderboardsManager.js'; /** Maximum number of players allowed to be requested in a single request. */ const MAX_N_PLAYERS_REQUEST_CAP = 100; // Functions ------------------------------------------------------------- /** * Responds to the request to fetch top (N = n_players) players of leaderboard * leaderboard_id, starting from start_rank, and also find rank of requester if find_requester_rank === 1 */ const getLeaderboardData = async (req: Request, res: Response): Promise => { // route: /leaderboard/top/:leaderboard_id/:start_rank/:n_players/:find_requester_rank /** ID of leaderboard to be fetched */ const leaderboard_id = Number(req.params['leaderboard_id']) as Leaderboard; /** Highest rank of player to fetch from leaderboard */ const start_rank = Number(req.params['start_rank']); /** Number of players to fetch from leaderboard */ const n_players = Number(req.params['n_players']); /** Whether the server should also look for and return the rank of the user making the request */ const find_requester_rank = Number(req.params['find_requester_rank']) as 0 | 1; if ( Number.isNaN(start_rank) || Number.isNaN(n_players) || Number.isNaN(leaderboard_id) || Number.isNaN(find_requester_rank) ) { res.status(404).json({ message: 'Request incorrectly formatted.' }); return; } if (n_players > MAX_N_PLAYERS_REQUEST_CAP) { res.status(404).json({ message: 'Too many leaderboard positions requested at once.' }); return; } /** Username of user whose global ranking should be returned. Set to undefined if its global rank should not be found. */ const requester_username = find_requester_rank && req.memberInfo?.signedIn ? req.memberInfo.username : undefined; // Query leaderboard database const top_players = getTopPlayersForLeaderboard(leaderboard_id, start_rank, n_players); if (top_players === undefined) { logEventsAndPrint( `Retrieval of top ${n_players} players from start rank ${start_rank} of leaderboard ${leaderboard_id} upon user request failed.`, 'errLog.txt', ); res.status(500).json({ message: 'Server error.' }); // Generic message for database retrieval failed return; } // Populate leaderboardData object with usernames and elos of players // Also look out for requester_username among usernames in order to set the value of requester_rank if possible let requester_rank: number | undefined = undefined; let running_rank = start_rank; const leaderboardData: Object[] = []; for (const player of top_players) { const record = getMemberDataByCriteria(['username'], 'user_id', player.user_id!); if (record === undefined) { logEventsAndPrint( `Username of user with user_id ${player.user_id} could not be found in members table, even though it was found in leaderboard table by getTopPlayersForLeaderboard().`, 'errLog.txt', ); continue; } const playerData = { username: record.username, elo: String(Math.round(player.elo!)), }; leaderboardData.push(playerData); if (record.username === requester_username) requester_rank = running_rank; // We can now set requester_rank without a seperate query running_rank++; } // Construct rank_string of user // If there is a requester_username, but requester_rank is still undefined, we need another database query let rank_string: string | undefined = undefined; rank_string_constructor: if (requester_username !== undefined && requester_rank === undefined) { const requesterRecord = getMemberDataByCriteria( ['user_id'], 'username', requester_username, ); if (requesterRecord === undefined) break rank_string_constructor; const requester_rank = getPlayerRankInLeaderboard(requesterRecord.user_id, leaderboard_id); if (requester_rank !== undefined) { rank_string = `#${requester_rank}`; // If the display elo contains a ?, then the rank_string should also contain a ? const requester_elo = getEloOfPlayerInLeaderboard( requesterRecord.user_id, leaderboard_id, ); // { value: number, confident: boolean } if (!requester_elo.confident) rank_string += '?'; } else rank_string = '?'; } else if (requester_username !== undefined) rank_string = `#${requester_rank}`; // case where the requester_username was already contained in the top leaderboard ranks const requesterData = { rank_string: rank_string, }; const sendData = { leaderboardData: leaderboardData, requesterData: requesterData, }; // Return data res.json(sendData); }; export { getLeaderboardData }; ================================================ FILE: src/server/api/MemberAPI.ts ================================================ // src/server/api/MemberAPI.ts import type { Request, Response } from 'express'; import { format, formatDistance } from 'date-fns'; import timeutil from '../../shared/util/timeutil.js'; import metadatautil from '../../shared/chess/util/metadatautil.js'; import { Leaderboards } from '../../shared/chess/variants/validleaderboard.js'; import { localeMap } from '../config/dateLocales.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getLanguageToServe } from '../utility/translate.js'; import { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js'; import { getPlayerLeaderboardRating, getEloOfPlayerInLeaderboard, getPlayerRankInLeaderboard, } from '../database/leaderboardsManager.js'; // Define the structure of the JSON response body interface MemberResponse { user_id: number; username: string; joined: string; seen: string; checkmates_beaten: string; ranked_elo: string; infinity_leaderboard_position: number | undefined; infinity_leaderboard_rating_deviation: number | undefined; email?: string; verified?: boolean; verified_notified?: boolean; } /** * API route: /member/:member/data * This is fetched from the profile page, * and serves info about the requested member. * * SHOULD ONLY ever return a JSON. */ const getMemberData = async (req: Request, res: Response): Promise => { // What member are we getting data from? const claimedUsername = req.params['member']; if (!claimedUsername) { logEventsAndPrint('No member username provided to MemberAPI.getMemberData', 'errLog.txt'); return res.status(400).json({ message: 'No member username provided' }); } const record = getMemberDataByCriteria( [ 'user_id', 'username', 'email', 'joined', 'is_verified', 'is_verification_notified', 'last_seen', 'checkmates_beaten', ], 'username', claimedUsername, ); if (record === undefined) return res.status(404).json({ message: 'Member not found' }); // Get the player's display elo string from the INFINITY leaderboard const ranked_elo = getEloOfPlayerInLeaderboard(record.user_id, Leaderboards.INFINITY); // { value: number, confident: boolean } // Get the player's position from the INFINITY leaderboard const infinity_leaderboard_position = getPlayerRankInLeaderboard( record.user_id, Leaderboards.INFINITY, ); // Get the player's RD from the INFINITY leaderboard let infinity_leaderboard_rating_deviation = getPlayerLeaderboardRating( record.user_id, Leaderboards.INFINITY, )?.rating_deviation; if (infinity_leaderboard_rating_deviation !== undefined) { infinity_leaderboard_rating_deviation = Math.round(infinity_leaderboard_rating_deviation); } // Load their data const joinedPhrase = format(new Date(record.joined), 'PP'); const lastSeenDate = new Date(timeutil.sqliteToISO(record.last_seen)); const language = getLanguageToServe(req); // Use type assertion here since we check for localeStr's existence in locales const seenPhrase = formatDistance(lastSeenDate, new Date(), { locale: localeMap[language], addSuffix: true, }); const sendData: MemberResponse = { user_id: record.user_id, username: record.username, joined: joinedPhrase, seen: seenPhrase, checkmates_beaten: record.checkmates_beaten, ranked_elo: metadatautil.getFormattedElo(ranked_elo), infinity_leaderboard_position, infinity_leaderboard_rating_deviation, }; // If they are the same person as who their requesting data, also include these. if (req.memberInfo === undefined) { logEventsAndPrint( 'req.memberInfo must be defined when requesting member data from API!', 'errLog.txt', ); return res.status(500).send('Internal Server Error'); } if ( req.memberInfo.signedIn && req.memberInfo.username.toLowerCase() === claimedUsername.toLowerCase() ) { // Their page sendData.email = record.email; // This is their account, include their email with the response sendData.verified = record.is_verified === 1; sendData.verified_notified = record.is_verification_notified === 1; // If they are verified but haven't been notified yet, this is the moment to do so. if (record.is_verified === 1 && record.is_verification_notified === 0) { // console.log(`Thanking member ${record.username} for verifying their account!`); try { // Mark them as notified in the database. updateMemberColumns(record.user_id, { is_verification_notified: 1 }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Failed to update member of ID "${record.user_id}" verification notified status: ${message}`, 'errLog.txt', ); } } else if (record.is_verified === 0) { // console.log(`Requesting member ${record.username} to verify their account!`); } } // Return data return res.json(sendData); }; export { getMemberData }; ================================================ FILE: src/server/api/NewsAPI.ts ================================================ // src/server/api/NewsAPI.ts /** * API endpoints for news-related functionality. */ import type { Request, Response } from 'express'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js'; import { countUnreadNews, getLatestNewsDate, getUnreadNewsDates } from '../utility/newsUtil.js'; /** * API endpoint to get the count of unread news posts for the current user. * Returns { count: number } or { count: 0 } if not logged in. */ function getUnreadNewsCount(req: Request, res: Response): void { // Check if user is authenticated if (!req.memberInfo?.signedIn) { // Not logged in - return 0 unread res.json({ count: 0 }); return; } const userId = req.memberInfo.user_id; // Get user's last read news date const record = getMemberDataByCriteria(['last_read_news_date'], 'user_id', userId); if (!record?.last_read_news_date) { // For some reason the cell was null or record not found res.json({ count: 0 }); return; } // Count unread news posts const unreadCount = countUnreadNews(record.last_read_news_date); res.json({ count: unreadCount }); } /** * Gets the list of unread news dates for the current user. * Returns { dates: string[] } with dates in YYYY-MM-DD format. */ function getUnreadNewsDatesEndpoint(req: Request, res: Response): void { if (!req.memberInfo?.signedIn) { // Not logged in - no unread news res.json({ dates: [] }); return; } const userId = req.memberInfo.user_id; // Get user's last read news date const record = getMemberDataByCriteria(['last_read_news_date'], 'user_id', userId); if (!record?.last_read_news_date) { // For some reason the cell was null or undefined res.json({ dates: [] }); return; } // Get unread news dates const unreadDates = getUnreadNewsDates(record.last_read_news_date); res.json({ dates: unreadDates }); } /** * Updates the user's last read news date to the current latest news post. * This should be called when the user visits the news page. */ function markNewsAsRead(req: Request, res: Response): void { if (!req.memberInfo || !req.memberInfo.signedIn) { // Not logged in - nothing to update res.status(200).json({ success: true }); return; } const userId = req.memberInfo.user_id; const latestNewsDate = getLatestNewsDate(); try { const result = updateMemberColumns(userId, { last_read_news_date: latestNewsDate }); if (result.changeMade) { res.status(200).json({ success: true }); } else { logEventsAndPrint( `Failed to update last read news date for member of ID "${userId}". No changes made. Do they exist?`, 'errLog.txt', ); res.status(500).json({ success: false, message: 'Failed to update last read news date.', }); } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating last read news date for member of ID "${userId}": ${message}`, 'errLog.txt', ); res.status(500).json({ success: false, message: `Server error updating last read news date`, }); } } export { getUnreadNewsCount, getUnreadNewsDatesEndpoint, markNewsAsRead }; ================================================ FILE: src/server/api/PracticeProgress.int.test.ts ================================================ // src/server/api/PracticeProgress.int.test.ts import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; import validcheckmates from '../../shared/chess/util/validcheckmates.js'; import { testRequest } from '../../tests/testRequest.js'; import integrationUtils from '../../tests/integrationUtils.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { generateTables, clearAllTables } from '../database/databaseTables.js'; // We'll use the first easy checkmate as our valid test case const VALID_CHECKMATE_ID = validcheckmates.validCheckmates.easy[0]; if (!VALID_CHECKMATE_ID) throw new Error('No valid checkmate IDs found for testing!'); describe('Practice Progress Integration', () => { // Runs once at the very start of this file beforeAll(() => { generateTables(); }); // Runs before EVERY single 'it' block beforeEach(() => { clearAllTables(); }); it('should reject requests with no body', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', cookie); expect(response.status).toBe(400); }); it('should reject requests with missing new_checkmate_beaten', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', cookie) .send({}); // No new_checkmate_beaten expect(response.status).toBe(400); }); it('should reject requests with non-string new_checkmate_beaten', async () => { const cookie = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', cookie.cookie) .send({ new_checkmate_beaten: 12345 }); // Non-string expect(response.status).toBe(400); }); it('should reject requests from unauthenticated users', async () => { const response = await testRequest() .post('/api/update-checkmatelist') .send({ new_checkmate_beaten: VALID_CHECKMATE_ID }); expect(response.status).toBe(401); }); it('should reject invalid checkmate IDs', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', cookie) .send({ new_checkmate_beaten: 'INVALID-ID-123' }); expect(response.status).toBe(400); // expect(response.body.message).toBe('Invalid checkmate ID'); }); it('should allow a logged-in user to save a new checkmate', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', user.cookie) .send({ new_checkmate_beaten: VALID_CHECKMATE_ID }); expect(response.status).toBe(200); // Check DB Side Effect const record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username); expect(record?.checkmates_beaten).toBe(VALID_CHECKMATE_ID); // Verify the response set the updated cookie const newCookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array expect( newCookies.some((c) => c.startsWith(`checkmates_beaten=${encodeURIComponent(VALID_CHECKMATE_ID)}`), ), ).toBe(true); }); it('should correctly store multiple checkmates', async () => { const user = await integrationUtils.createAndLoginUser(); const secondCheckmateId = validcheckmates.validCheckmates.easy[1]; if (!secondCheckmateId) throw new Error('Not enough valid checkmate IDs for this test!'); // 1. Submit First Checkmate await testRequest() .post('/api/update-checkmatelist') .set('Cookie', user.cookie) .send({ new_checkmate_beaten: VALID_CHECKMATE_ID }); // 2. Submit Second Checkmate const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', user.cookie) .send({ new_checkmate_beaten: secondCheckmateId }); expect(response.status).toBe(200); // DB should have both IDs stored correctly const record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username); expect(record?.checkmates_beaten).toBe([VALID_CHECKMATE_ID, secondCheckmateId].join(',')); }); it('should handle duplicate checkmate submissions gracefully', async () => { const user = await integrationUtils.createAndLoginUser(); // 1. Submit First Time await testRequest() .post('/api/update-checkmatelist') .set('Cookie', user.cookie) .send({ new_checkmate_beaten: VALID_CHECKMATE_ID }); // 2. Submit Same ID Again const response = await testRequest() .post('/api/update-checkmatelist') .set('Cookie', user.cookie) .send({ new_checkmate_beaten: VALID_CHECKMATE_ID }); // Should now be 204 No Content, indicating no change in state expect(response.status).toBe(204); // DB should still only have it once (no duplicates like "ID,ID") const record = getMemberDataByCriteria(['checkmates_beaten'], 'username', user.username); expect(record?.checkmates_beaten).toBe(VALID_CHECKMATE_ID); }); }); ================================================ FILE: src/server/api/PracticeProgress.ts ================================================ // src/server/api/PracticeProgress.ts /** * This script updates the checkmates_beaten list in the database when a user submits a newly completed checkmate */ import type { Request, Response } from 'express'; import type { ParsedCookies } from '../types.js'; import jsutil from '../../shared/util/jsutil.js'; import validcheckmates from '../../shared/chess/util/validcheckmates.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js'; // Functions ------------------------------------------------------------- /** * Middleware to set the checkmates_beaten cookie for logged-in users based on their memberInfo cookie. * Only sets the checkmates_beaten cookie on HTML requests (requests without an origin header). * * It is possible for the memberInfo cookie to be tampered with, but checkmates_beaten can be public information anyway. * We are reading the memberInfo cookie instead of verifying their session token * because that could take a little bit longer as it requires a database look up. * @param req - The Express request object. * @param res - The Express response object. * @param next - The Express next middleware function. */ function setPracticeProgressCookie(req: Request, res: Response, next: Function): void { // We don't have to worry about the request being for a resource because those have already been served. // The only scenario this request could be for now is an HTML or fetch API request // The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request. if (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie // We give everyone this cookie as soon as they login. // Since it is modifiable by JavaScript it's possible for them to // grab checkmates_beaten of other users this way, but there's no harm in that. const cookies: ParsedCookies = req.cookies; const memberInfoCookieStringified = cookies.memberInfo; if (memberInfoCookieStringified === undefined) return next(); // No cookie is present, not logged in let memberInfoCookie: { user_id: number; username: string }; try { memberInfoCookie = JSON.parse(memberInfoCookieStringified); } catch (error) { logEventsAndPrint( `memberInfo cookie was not JSON parse-able when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}" The error: ${(error as Error).stack}`, 'errLog.txt', ); return next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal } if (typeof memberInfoCookie !== 'object') { logEventsAndPrint( `memberInfo cookie did not parse into an object when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}"`, 'errLog.txt', ); return next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal } const user_id = memberInfoCookie.user_id; if (typeof user_id !== 'number') { logEventsAndPrint( `memberInfo cookie user_id property was not a number when attempting to set checkmates_beaten cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}"`, 'errLog.txt', ); return next(); // Don't set the checkmates_beaten cookie, but allow their request to continue as normal } const checkmates_beaten = getCheckmatesBeaten(user_id); // Fetch their checkmates_beaten from the database createPracticeProgressCookie(res, checkmates_beaten); // console.log(`Set checkmates_beaten cookie for member "${ensureJSONString(memberInfoCookie.username)}" for url: ` + req.url); next(); } /** * Sets the checkmates_beaten cookie for the user. * @param res - The Express response object. * @param checkmates_beaten - The checkmates_beaten object to be saved in the cookie. */ function createPracticeProgressCookie(res: Response, checkmates_beaten: string): void { // Set or update the checkmates_beaten cookie res.cookie('checkmates_beaten', checkmates_beaten, { httpOnly: false, secure: true, }); } /** * Deletes the checkmates_beaten progress cookie for the user. * Typically called when they log out. * Even though the cookie only lasts 10 seconds, this is still helpful * @param {Object} res - The Express response object. */ function deletePracticeProgressCookie(res: Response): void { res.clearCookie('checkmates_beaten', { httpOnly: false, secure: true, }); } /** * Fetches the checkmates_beaten for a given user from the database, as a delimited string. * @param userId - The ID of the user whose checkmates_beaten are to be fetched. * @returns - Returns the checkmates_beaten string if found, otherwise undefined. (e.g. "2Q-1k,3R-1k,1Q1R1B-1k") */ function getCheckmatesBeaten(userId: number): string { const record = getMemberDataByCriteria(['checkmates_beaten'], 'user_id', userId); return record?.checkmates_beaten ?? ''; } /** * Converts a string of checkmates_beaten delimited by commas into an array of strings. */ function checkmatesBeatenToStringArray(checkmates_beaten: string): string[] { return checkmates_beaten.match(/[^,]+/g) || []; // match() returns null if no matches } /** * Route that Handles a POST request to update user checkmates_beaten in the database. * @param req - Express request object * @param res - Express response object */ function postCheckmateBeaten(req: Request, res: Response): void { if (!req.memberInfo?.signedIn) { logEventsAndPrint( "User tried to save checkmates_beaten when they weren't signed in!", 'errLog.txt', ); res.status(401).json({ message: "Can't save checkmates_beaten, not signed in." }); return; } const { user_id, username } = req.memberInfo; const new_checkmate_beaten: string = req.body.new_checkmate_beaten; // Validate the new checkmate ID if (typeof new_checkmate_beaten !== 'string') { // Not a string res.status(400).json({ message: 'Invalid checkmate ID' }); return; } if (!Object.values(validcheckmates.validCheckmates).flat().includes(new_checkmate_beaten)) { // Not a valid checkmate res.status(400).json({ message: 'Invalid checkmate ID' }); return; } // Checkmate is valid... let checkmates_beaten: string = getCheckmatesBeaten(user_id); const checkmates_beaten_array: string[] = checkmatesBeatenToStringArray(checkmates_beaten); if (checkmates_beaten_array.includes(new_checkmate_beaten)) { // Already beaten res.status(204).json({ message: 'Checkmate already beaten' }); return; } // Checkmate not already beaten (until now)... // Update the new list checkmates_beaten_array.push(new_checkmate_beaten); checkmates_beaten = checkmates_beaten_array.join(','); try { // Save the new list to the database const result = updateMemberColumns(user_id, { checkmates_beaten }); // Send appropriate response if (result.changeMade) { logEvents( `Member "${username}" of id "${user_id}" has beaten practice checkmate ${new_checkmate_beaten}. Beaten count: ${checkmates_beaten_array.length}. New checkmates_beaten: ${checkmates_beaten}`, 'checkmates_beaten.txt', ); // Create a new cookie with the updated checkmate list for the user createPracticeProgressCookie(res, checkmates_beaten); res.status(200).json({ message: 'Checkmate recorded successfully' }); } else { logEventsAndPrint( `Failed to save new practice checkmate for member "${username}" id "${user_id}". No change made. Do they exist?`, 'errLog.txt', ); res.status(500).json({ message: 'Failed to update practice checkmate' }); } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating practice checkmate for member "${username}" of ID "${user_id}": ${message}`, 'errLog.txt', ); res.status(500).json({ message: 'Server error updating practice checkmate' }); } } export { setPracticeProgressCookie, deletePracticeProgressCookie, postCheckmateBeaten }; ================================================ FILE: src/server/api/Prefs.int.test.ts ================================================ // src/server/api/Prefs.int.test.ts import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; import { testRequest } from '../../tests/testRequest.js'; import integrationUtils from '../../tests/integrationUtils.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { generateTables, clearAllTables } from '../database/databaseTables.js'; /** An example of valid preferences. */ const VALID_PREFS_1 = { theme: 'wood_light', legal_moves: 'dots', animations: false, lingering_annotations: true, } as const; /** Another example of valid preferences. */ const VALID_PREFS_2 = { theme: 'sandstone', legal_moves: 'squares', animations: true, lingering_annotations: false, } as const; describe('Preferences Integration', () => { // Runs once at the very start of this file beforeAll(() => { generateTables(); }); // Runs before EVERY single 'it' block beforeEach(() => { clearAllTables(); }); it('should verify middleware sets preferences cookie on GET request', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; // 1. Manually set prefs in DB first (so we have something to fetch) // Since we can't easily inject into DB without the API, we'll use the API first await testRequest() .post('/api/set-preferences') .set('Cookie', cookie) .send({ preferences: VALID_PREFS_1 }); // 2. Now test the GET request (HTML request) const response = await testRequest() .get('/') // Hitting the homepage (or any HTML route) .set('Cookie', cookie); // .set('Accept', 'text/html'); // CAN'T KEEP THIS, because if `dist/` is not built, it will 404. Tests should NOT depend on the build process. // Luckily, the cookie is still set before then. // expect(response.status).toBe(200); const cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array // Verify 'preferences' cookie is set and matches what we saved const prefCookie = cookies.find((c) => c.startsWith('preferences=')); expect(prefCookie).toBeDefined(); const prefValue = JSON.parse(decodeURIComponent(prefCookie!.split(';')[0]!.split('=')[1]!)); expect(prefValue).toMatchObject(VALID_PREFS_1); }); it('should reject request with no body', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const response = await testRequest().post('/api/set-preferences').set('Cookie', cookie); expect(response.status).toBe(400); }); it('should reject request with missing preferences', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const response = await testRequest() .post('/api/set-preferences') .set('Cookie', cookie) .send({}); // No preferences expect(response.status).toBe(400); }); it('should reject requests from unauthenticated users', async () => { const response = await testRequest() .post('/api/set-preferences') .send({ preferences: VALID_PREFS_1 }); expect(response.status).toBe(401); }); it('should reject invalid preferences', async () => { const cookie = (await integrationUtils.createAndLoginUser()).cookie; const invalidPrefs = { theme: 'invalid-theme-name', legal_moves: 'triangles', // Invalid shape animations: 'yes', // Should be boolean }; const response = await testRequest() .post('/api/set-preferences') .set('Cookie', cookie) .send({ preferences: invalidPrefs }); expect(response.status).toBe(400); }); it('should allow logged-in user to save valid preferences', async () => { const user = await integrationUtils.createAndLoginUser(); const response = await testRequest() .post('/api/set-preferences') .set('Cookie', user.cookie) .send({ preferences: VALID_PREFS_1 }); expect(response.status).toBe(200); // Verify DB update const record = getMemberDataByCriteria(['preferences'], 'username', user.username); expect(record).toBeDefined(); const savedPrefs = record!.preferences === null ? null : JSON.parse(record!.preferences); expect(savedPrefs).toMatchObject(VALID_PREFS_1); }); it('should overwrite existing preferences', async () => { const user = await integrationUtils.createAndLoginUser(); // 1. Save initial preferences await testRequest() .post('/api/set-preferences') .set('Cookie', user.cookie) .send({ preferences: VALID_PREFS_1 }); // 2. Save new preferences to overwrite const response = await testRequest() .post('/api/set-preferences') .set('Cookie', user.cookie) .send({ preferences: VALID_PREFS_2 }); expect(response.status).toBe(200); // Verify DB update const record = getMemberDataByCriteria(['preferences'], 'username', user.username); expect(record).toBeDefined(); const savedPrefs = record!.preferences === null ? null : JSON.parse(record!.preferences); expect(savedPrefs).toMatchObject(VALID_PREFS_2); }); }); ================================================ FILE: src/server/api/Prefs.ts ================================================ // src/server/api/Prefs.ts /** * This script sets the preferences cookie on any request to an HTML file. * And it has an API for setting your preferences in the database. */ import type { NextFunction, Request, Response } from 'express'; import z from 'zod'; import themes from '../../shared/components/header/themes.js'; import jsutil from '../../shared/util/jsutil.js'; import { logZodError } from '../utility/zodlogger.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js'; // Types ------------------------------------------------------------------------------- type Preferences = z.infer; // Variables ----------------------------------------------------------------------------- /** Zod schema to validate preferences object structure. */ const prefsSchema = z .strictObject({ theme: z.string().refine((val) => themes.isThemeValid(val)), legal_moves: z.enum(['squares', 'dots']), animations: z.boolean(), lingering_annotations: z.boolean(), }) .partial(); /** The client has this long to read the cookie and update preferences in memory. */ const lifetimeOfPrefsCookieMillis = 1000 * 10; // 10 seconds // Functions ----------------------------------------------------------------------------- /** * Middleware to set the preferences cookie for logged-in users based on their memberInfo cookie. * Only sets the preferences cookie on HTML requests (requests without an origin header). * * It is possible for the memberInfo cookie to be tampered with, but preferences can be public information anyway. * We are reading the memberInfo cookie instead of verifying their session token * because that could take a little bit longer as it requires a database look up. */ function setPrefsCookie(req: Request, res: Response, next: NextFunction): void { // We don't have to worry about the request being for a resource because those have already been served. // The only scenario this request could be for now is an HTML or fetch API request // The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request. if (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie // We give everyone this cookie as soon as they login. // Since it is modifiable by JavaScript it's possible for them to // grab preferences of other users this way, but there's no harm in that. const cookies = req.cookies; const memberInfoCookieStringified = cookies['memberInfo']; if (memberInfoCookieStringified === undefined) return next(); // No cookie is present, not logged in let memberInfoCookie; // { user_id, username } try { memberInfoCookie = JSON.parse(memberInfoCookieStringified); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `memberInfo cookie was not JSON parse-able when attempting to set preferences cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}" The error: ${message}`, 'errLog.txt', ); return next(); // Don't set the preferences cookie, but allow their request to continue as normal } if (typeof memberInfoCookie !== 'object') { logEventsAndPrint( `memberInfo cookie did not parse into an object when attempting to set preferences cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}"`, 'errLog.txt', ); return next(); // Don't set the preferences cookie, but allow their request to continue as normal } const user_id = memberInfoCookie.user_id; if (typeof user_id !== 'number') { logEventsAndPrint( `memberInfo cookie user_id property was not a number when attempting to set preferences cookie. Maybe it was tampered? The cookie: "${jsutil.ensureJSONString(memberInfoCookieStringified)}"`, 'errLog.txt', ); return next(); // Don't set the preferences cookie, but allow their request to continue as normal } const preferences = getPrefs(user_id); // Fetch their preferences from the database if (!preferences) return next(); // No preferences set for this user, or the user doesn't exist. createPrefsCookie(res, preferences); // console.log(`Set preferences cookie for member "${ensureJSONString(memberInfoCookie.username)}" for url: ` + req.url); next(); } /** Sets the preferences cookie for the user. */ function createPrefsCookie(res: Response, preferences: Preferences): void { // Set or update the preferences cookie res.cookie('preferences', JSON.stringify(preferences), { httpOnly: false, secure: true, maxAge: lifetimeOfPrefsCookieMillis, }); } /** * Deletes the preferences cookie for the user. * Typically called when they log out. * Even though the cookie only lasts 10 seconds, this is still helpful */ function deletePreferencesCookie(res: Response): void { res.clearCookie('preferences', { httpOnly: false, secure: true, }); } /** * Fetches the preferences for a given user from the database. * @param userId - The ID of the user whose preferences are to be fetched. * @returns The preferences object if found, otherwise undefined. */ function getPrefs(userId: number): Preferences | undefined { const record = getMemberDataByCriteria(['preferences'], 'user_id', userId); if (record === undefined) return; if (record.preferences === null) return; return JSON.parse(record.preferences); } /** Route that Handles a POST request to update user preferences in the database. */ function postPrefs(req: Request, res: Response): void { if (!req.memberInfo?.signedIn) { logEventsAndPrint( "User tried to save preferences when they weren't signed in!", 'errLog.txt', ); res.status(401).json({ message: "Can't save preferences, not signed in." }); return; } const { user_id, username } = req.memberInfo; const preferences = req.body.preferences; // Validate preferences using Zod schema const parseResult = prefsSchema.safeParse(preferences); if (!parseResult.success) { logZodError( preferences, parseResult.error, `Member "${username}" of id "${user_id}" tried to save invalid preferences to the database.`, ); res.status(400).json({ message: 'Preferences not valid, cannot save on the server.' }); return; } try { // Update the preferences column in the database const result = updateMemberColumns(user_id, { preferences: JSON.stringify(parseResult.data), }); // Send appropriate response if (result.changeMade) { // console.log( // `Successfully saved member "${username}" of id "${user_id}"s user preferences.`, // ); res.status(200).json({ message: 'Preferences updated successfully' }); } else { logEventsAndPrint( `Failed to save preferences for member "${username}" id "${user_id}". No change made. Do they exist?`, 'errLog.txt', ); res.status(500).json({ message: 'Failed to update preferences' }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error occurred while saving preferences for member "${username}" of ID "${user_id}": ${message}`, 'errLog.txt', ); res.status(500).json({ message: 'Server error while updating preferences' }); } } export { setPrefsCookie, postPrefs, deletePreferencesCookie }; ================================================ FILE: src/server/app.ts ================================================ // src/server/app.ts /** * Defines and configures the Express application instance. */ import ejs from 'ejs'; import express from 'express'; import { initTranslations } from './config/i18n.js'; import { configureMiddleware } from './middleware/middleware.js'; const app = express(); // This ensures that req.ip will give us the real user's IP instead of the Cloudflare proxy's IP. app.set('trust proxy', 1); // '1' means trust the first proxy hop (Cloudflare) app.disable('x-powered-by'); // This removes the 'x-powered-by' header from all responses. // Set EJS as the view engine app.engine('html', ejs.renderFile); app.set('view engine', 'html'); // This is in here so integration tests work, as otherwise if // this is in server.js, i18next is never initialized for tests. initTranslations(); configureMiddleware(app); // Setup the middleware waterfall export default app; ================================================ FILE: src/server/config/certOptions.ts ================================================ // src/server/config/certOptions.ts import fs from 'fs'; import path from 'path'; const pathToCertFolder = path.resolve('cert'); // Resolve results in an absolute path /** * Retrieves SSL/TLS certificate options based on the application's * build environment, including the certificate and private key. */ export function getCertOptions(): { key: Buffer; cert: Buffer } { if (process.env['NODE_ENV'] !== 'production') { // Use self-signed certificates for development environment return { key: fs.readFileSync(path.join(pathToCertFolder, 'cert.key')), cert: fs.readFileSync(path.join(pathToCertFolder, 'cert.pem')), }; } else { // Use officially signed certificates for production environment return { key: fs.readFileSync(path.join(process.env['CERT_PATH'] ?? '', 'privkey.pem')), cert: fs.readFileSync(path.join(process.env['CERT_PATH'] ?? '', 'fullchain.pem')), }; } } ================================================ FILE: src/server/config/dateLocales.ts ================================================ // src/server/config/dateLocales.ts import type { Locale } from 'date-fns'; import de from 'date-fns/locale/de/index.js'; import fr from 'date-fns/locale/fr/index.js'; import pl from 'date-fns/locale/pl/index.js'; import es from 'date-fns/locale/es/index.js'; import el from 'date-fns/locale/el/index.js'; import ja from 'date-fns/locale/ja/index.js'; import ru from 'date-fns/locale/ru/index.js'; import it from 'date-fns/locale/it/index.js'; import hi from 'date-fns/locale/hi/index.js'; import ko from 'date-fns/locale/ko/index.js'; import tr from 'date-fns/locale/tr/index.js'; import fi from 'date-fns/locale/fi/index.js'; import enUS from 'date-fns/locale/en-US/index.js'; import ptBR from 'date-fns/locale/pt-BR/index.js'; import zhTW from 'date-fns/locale/zh-TW/index.js'; import zhCN from 'date-fns/locale/zh-CN/index.js'; import arSA from 'date-fns/locale/ar-SA/index.js'; /** Maps i18n language codes to date-fns locales. */ export const localeMap: Record = { 'en-US': enUS, 'es-ES': es, 'fr-FR': fr, 'pl-PL': pl, 'pt-BR': ptBR, 'zh-CN': zhCN, 'zh-TW': zhTW, 'de-DE': de, 'el-GR': el, 'ru-RU': ru, 'it-IT': it, 'fi-FI': fi, 'ja-JP': ja, 'ar-SA': arSA, 'hi-IN': hi, 'ko-KR': ko, 'tr-TR': tr, }; ================================================ FILE: src/server/config/generateCert.ts ================================================ // src/server/config/generateCert.ts import fs from 'fs'; import path from 'path'; import forge from 'node-forge'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const certDir = path.join(__dirname, '..', '..', '..', 'cert'); // Define the paths for the key and certificate files const keyPath = path.join(certDir, 'cert.key'); const certPath = path.join(certDir, 'cert.pem'); /** Generates a self-signed certificate. */ function generateSelfSignedCertificate(): void { const pki = forge.pki; const keys = pki.rsa.generateKeyPair(2048); const cert = pki.createCertificate(); cert.publicKey = keys.publicKey; cert.serialNumber = '01'; cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); const attrs = [ { name: 'commonName', value: 'localhost', }, ]; cert.setSubject(attrs); cert.setIssuer(attrs); cert.sign(keys.privateKey, forge.md.sha256.create()); // Convert the PEM-formatted keys to strings const privateKeyPem = pki.privateKeyToPem(keys.privateKey); const certPem = pki.certificateToPem(cert); // Write the private key and certificate to the specified paths fs.writeFileSync(keyPath, privateKeyPem); fs.writeFileSync(certPath, certPem); console.log('Generated self-signed certificate.'); } /** * Ensure that a self-signed certificate exists in the cert directory. * If cert.key and cert.pem do not exist, generate them. */ export function ensureSelfSignedCertificate(): void { // Create the cert directory if it doesn't exist fs.mkdirSync(certDir, { recursive: true }); if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return; // Self-signed certificate already exists generateSelfSignedCertificate(); } ================================================ FILE: src/server/config/i18n.ts ================================================ // src/server/config/i18n.ts import i18next from 'i18next'; import { LanguageDetector } from 'i18next-http-middleware'; import translationLoader from './translationLoader.js'; /** Initializes i18next for the server process, loading languages from .toml files. */ function initTranslations(): void { const translations = translationLoader.loadTranslations(); const supportedLngs = Object.keys(translations); i18next.use(LanguageDetector).init({ resources: translations, supportedLngs, defaultNS: 'default', // fallbackLng: DEFAULT_LANGUAGE, // Fallback is handled by deepMerge() in translationLoader // debug: true, // Enable debug mode to see logs for missing keys and other details }); } export { initTranslations }; ================================================ FILE: src/server/config/paths.ts ================================================ // src/server/config/paths.ts /** * This file defines absolute paths to important directories in the project */ import path from 'path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** Absolute path to the project-root `logs/` directory. */ const LOGS_DIR = path.join(__dirname, '..', '..', '..', 'logs'); export default { LOGS_DIR }; ================================================ FILE: src/server/config/setupDev.ts ================================================ // src/server/config/setupDev.ts import validcheckmates from '../../shared/chess/util/validcheckmates.js'; import { giveRole } from '../controllers/roles.js'; import { generateAccount } from '../controllers/createAccountController.js'; import { ensureSelfSignedCertificate } from './generateCert.js'; import { isUsernameTaken, updateMemberColumns } from '../database/memberManager.js'; import 'dotenv/config'; // Imports all properties of process.env, if it exists export function initDevEnvironment(): void { if (process.env['NODE_ENV'] === 'production') return; ensureSelfSignedCertificate(); ensureDevelopmentAccounts(); // Display the url to the page console.log(`Local website is hosted at https://localhost:${process.env['HTTPSPORT_LOCAL']}/`); } async function ensureDevelopmentAccounts(): Promise { if (!isUsernameTaken('owner')) { const user_id = await generateAccount({ username: 'Owner', email: 'email1', password: '1', autoVerify: true, }); giveRole(user_id, 'owner'); giveRole(user_id, 'admin'); // Give Owner checkmate progression for debugging purposes // Bronze // const checkmates_beaten = Object.values(validcheckmates.validCheckmates.easy).toString() // + "," + Object.values(validcheckmates.validCheckmates.medium).toString(); // Silver // const checkmates_beaten = Object.values(validcheckmates.validCheckmates.easy).toString() // + "," + Object.values(validcheckmates.validCheckmates.medium).toString() // + "," + Object.values(validcheckmates.validCheckmates.hard).toString(); // Gold const checkmates_beaten = Object.values(validcheckmates.validCheckmates).flat().join(','); updateMemberColumns(user_id, { checkmates_beaten }); } if (!isUsernameTaken('admin')) { const user_id = await generateAccount({ username: 'Admin', email: 'email5', password: '1', autoVerify: true, }); giveRole(user_id, 'admin'); } if (!isUsernameTaken('patron')) { const user_id = await generateAccount({ username: 'Patron', email: 'email2', password: '1', autoVerify: true, }); giveRole(user_id, 'patron'); } if (!isUsernameTaken('member')) { await generateAccount({ username: 'Member', email: 'email3', password: '1', autoVerify: true, }); } // Populate leaderboard with dummy accounts for testing // for (let i = 0; i < 230; i++) { // if (!doesMemberOfUsernameExist(`Player${i}`)) { // const user_id = (await generateAccount({ username: `Player${i}`, email: `playeremail${i}`, password: "1", autoVerify: true })).user_id; // addUserToLeaderboard(user_id, Leaderboards.INFINITY); // updatePlayerLeaderboardRating(user_id, Leaderboards.INFINITY, 1800 - 10 * i, 100 + i); // } // } } ================================================ FILE: src/server/config/translationLoader.ts ================================================ // src/server/config/translationLoader.ts /** * Handles loading and sanitizing translation TOML files. */ import fs from 'fs'; import path from 'path'; import * as z from 'zod'; import { marked } from 'marked'; import { fileURLToPath } from 'node:url'; import { parse, TomlTable } from 'smol-toml'; import { format, parseISO } from 'date-fns'; import { FilterXSS, IFilterXSSOptions } from 'xss'; import { localeMap } from './dateLocales.js'; import { DEFAULT_LANGUAGE } from '../utility/translate.js'; // Types --------------------------------------------------------------------- /** All translations for every single language. */ type Translations = Record; /** All translations for a single language. */ type LanguageTranslations = { default: Record }; const changelogSchema = z.record( z.string().refine((val) => Number.isInteger(Number(val)), { message: 'Key must be an integer string', }), z.object({ // note: , note: z.union([ z.string().min(1, 'Note cannot be empty'), z.array(z.string().min(1, 'Note cannot be empty')).min(1, 'Note cannot be empty'), ]), changes: z.array(z.string()).optional(), }), ); type Changelog = z.infer; // Constants ----------------------------------------------------------------- const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** The folder path containing translation TOML files. */ const translationsFolder = path.join(__dirname, '../../../translation'); /** The changelog file path for tracking the English TOML version changes. */ const changesFile = path.join(translationsFolder, 'changes.json'); /** The folder path containing news markdown files for various languages. */ const newsFolder = path.join(translationsFolder, 'news'); /** The folder path containing English markdown news posts. */ const englishNewsFolder = path.join(newsFolder, DEFAULT_LANGUAGE); const xss_options: IFilterXSSOptions = { // Allows using these html tags in translation key strings for formatting. whiteList: { em: [], strong: [], b: [], i: [], br: [], }, }; const custom_xss = new FilterXSS(xss_options); // Functions ----------------------------------------------------------------- /** Loads and processes all translation TOML files into one object. */ function loadTranslations(): Translations { const translations: Translations = {}; const tomlFiles = fs.readdirSync(translationsFolder).filter((f) => f.endsWith('.toml')); const changelog = loadChangelog(); tomlFiles.forEach((file) => { const languageCode = file.replace('.toml', ''); const tomlPath = path.join(translationsFolder, file); const toml = fs.readFileSync(tomlPath).toString(); // Load const toml_parsed = parse(toml); // Parse const toml_updated = removeOutdated(toml_parsed, changelog); // Version const toml_sanitized = html_escape(toml_updated); // Sanitize translations[languageCode] = { default: toml_sanitized }; }); // Deep-merge the English (fallback) translations into every other language so that // missing nested keys are always present. i18next's fallbackLng only handles leaf-key // lookups; when an EJS template calls t('some.section', { returnObjects: true }) it // receives the language's partial object with no further fallback for missing sub-trees. const englishTranslations = translations[DEFAULT_LANGUAGE]!.default; for (const [languageCode, languageTranslations] of Object.entries(translations)) { if (languageCode === DEFAULT_LANGUAGE) continue; translations[languageCode] = { default: deepMerge(englishTranslations, languageTranslations.default), }; } return translations; } /** * Deep-merges `source` into `target`, returning a new object. * Keys present in `source` but absent in `target` are copied from `source` (English fallback). * Keys present in both are recursively merged when both values are plain objects; * otherwise the `target` value takes precedence. */ function deepMerge(source: Record, target: Record): Record { const result: Record = { ...source }; for (const [key, targetValue] of Object.entries(target)) { const sourceValue = result[key]; if ( targetValue !== null && typeof targetValue === 'object' && !Array.isArray(targetValue) && sourceValue !== null && typeof sourceValue === 'object' && !Array.isArray(sourceValue) ) { result[key] = deepMerge(sourceValue, targetValue); } else { result[key] = targetValue; } } return result; } /** Loads the English TOML changelog file into an object. */ function loadChangelog(): Changelog { const changelogRaw = fs.readFileSync(changesFile).toString(); const changelogParsed = JSON.parse(changelogRaw); return changelogSchema.parse(changelogParsed); } /** * Loads news posts from markdown files into an object. * @param supportedLanguages - A list of all languages with a TOML file. * @returns An object mapping language codes to their compiled news HTML. */ function loadNews(supportedLanguages: string[]): Record { const newsPosts: Record = {}; /** Sorted English news posts filenames */ const englishNewsPosts = fs .readdirSync(englishNewsFolder) .filter((n) => n !== '.DS_Store') // Hidden macOS file .sort((a, b) => { const dateA = new Date(a.replace('.md', '')); const dateB = new Date(b.replace('.md', '')); return dateB.getTime() - dateA.getTime(); }); supportedLanguages.forEach((languageCode) => { // Generate News posts HTML for this language newsPosts[languageCode] = englishNewsPosts .map((fileName) => { const fullPath = path.join(newsFolder, languageCode, fileName); // Read news post (fallback to default language) const content = fs.existsSync(fullPath) ? fs.readFileSync(fullPath) : fs.readFileSync(path.join(englishNewsFolder, fileName)); // Compile markdown to HTML const parsedHTML = marked.parse(content.toString()); // Date Formatting const dateISO = fileName.replace('.md', ''); // YYYY-MM-DD const date = format(parseISO(dateISO), 'PP', { locale: localeMap[languageCode] }); return `
${parsedHTML}
`; }) .join('\n
\n'); }); return newsPosts; } /** Removes outdated translations from one language's toml object, according to the changelog. */ function removeOutdated(object: TomlTable, changelog: Changelog): TomlTable { const version = object['version'] as string; // Filter out versions that are older than version of current language const filtered_entries = Object.entries(changelog).filter( ([change]) => Number(version) < Number(change), ); // Collect all keys to be removed let key_strings: string[] = []; for (const [, value] of filtered_entries) { if (value.changes === undefined) continue; key_strings = key_strings.concat(value.changes); } key_strings = [...new Set(key_strings)]; // Remove duplicates let object_copy = object; for (const key_string of key_strings) { object_copy = remove_key(key_string, object_copy); } return object_copy; } /** * Removes keys from `object` based on string of format 'foo.bar'. * @param key_string - String representing key that has to be deleted in format 'foo.bar'. * @param object - Object that is target of the removal. * @returns Copy of `object` with deleted values * @example * const obj = { foo: { bar: 42, baz: 100 }, qux: 7 }; * const result = remove_key('foo.bar', obj); // { foo: { baz: 100 }, qux: 7 } */ function remove_key(key_string: string, object: Record): Record { const keys = key_string.split('.'); let currentObj = object; for (let i = 0; i < keys.length - 1; i++) { if (currentObj[keys[i]!] !== undefined) currentObj = currentObj[keys[i]!]; } if (currentObj[keys.at(-1)!] !== undefined) delete currentObj[keys.at(-1)!]; return object; } /** * Recursively traverses a data structure (array or object) and sanitizes all contained * strings using an XSS filter. This prevents malicious content from translation files * from being rendered in a user's browser. * @param value - The input value (e.g., the parsed content of a TOML file). * @returns A deep copy of the input with all string values sanitized. */ function html_escape(value: any): any { if (Array.isArray(value)) { const escaped = []; for (const member of value) { escaped.push(html_escape(member)); } return escaped; } if (value !== null && typeof value === 'object') { const escaped: Record = {}; for (const [valueKey, valueValue] of Object.entries(value)) { escaped[valueKey] = html_escape(valueValue); } return escaped; } if (typeof value === 'string') { return custom_xss.process(value); } return value; // numbers, booleans, etc. } // Exports ------------------------------------------------------------------- export default { loadTranslations, loadNews, }; ================================================ FILE: src/server/controllers/authController.ts ================================================ // src/server/controllers/authController.ts /** * This controller is used to process login form data, * returning tru if username and password is correct. * * This also rate limits a members login attempts. */ import type { Request, Response } from 'express'; import bcrypt from 'bcrypt'; import { logEvents } from '../middleware/logEvents.js'; import { getTranslationForReq } from '../utility/translate.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { getBrowserAgent, onCorrectPassword, onIncorrectPassword, rateLimitLogin, } from './authRatelimiter.js'; /** * Called when any fetch request submits login form data. * The req body needs to have the `username` and `password` properties. * If the req body does not have `username`, req.params must have the `member` property. * If the password is correct, this returns true. * Otherwise this sends a response to the client saying it was incorrect. * This is also rate limited. * @returns true if the password was correct */ async function testPasswordForRequest(req: Request, res: Response): Promise { if (!verifyBodyHasLoginFormData(req, res)) return false; // If false, it will have already sent a response. // eslint-disable-next-line prefer-const let { username: claimedUsername, password: claimedPassword } = req.body; claimedUsername = claimedUsername || req.params['member']; const record = getMemberDataByCriteria( ['user_id', 'username', 'hashed_password'], 'username', claimedUsername, ); if (record === undefined) { // User not found res.status(401).json({ message: getTranslationForReq('server.javascript.ws-invalid_username', req), }); // Unauthorized, username not found return false; } const browserAgent = getBrowserAgent(req, record.username); if (!rateLimitLogin(req, res, browserAgent)) return false; // They are being rate limited from enterring incorrectly too many times // Test the password const match = await bcrypt.compare(claimedPassword, record.hashed_password); if (!match) { logEvents(`Incorrect password for user ${record.username}!`, 'loginAttempts.txt'); res.status(401).json({ message: getTranslationForReq('server.javascript.ws-incorrect_password', req), }); // Unauthorized, password not found onIncorrectPassword(browserAgent, record.username); return false; } onCorrectPassword(browserAgent); return true; } /** * Tests if the request body has valid `username` and `password` properties. * If not, this auto-sends a response to the client with an error. * @returns true if the body is valid */ function verifyBodyHasLoginFormData(req: Request, res: Response): boolean { if (!req.body) { // Missing body console.log(`User sent a bad login request missing the body!`); res.status(400).send('Bad Request'); // 400 Bad request return false; } const { username, password } = req.body; if (!username || !password) { console.log( `User ${username} sent a bad login request missing either username or password!`, ); res.status(400).json({ message: 'Username and password are required.' }); // 400 Bad request return false; } if (typeof username !== 'string' || typeof password !== 'string') { console.log( `User ${username} sent a bad login request with either username or password not a string!`, ); res.status(400).json({ message: 'Username and password must be a string.' }); // 400 Bad request return false; } return true; } export { testPasswordForRequest }; ================================================ FILE: src/server/controllers/authRatelimiter.ts ================================================ // src/server/controllers/authRatelimiter.ts /** * The script rate limits login/authentication attempts by a combination of username and IP address */ import type { Request, Response } from 'express'; import { getClientIP } from '../utility/IP.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getTranslationForReq } from '../utility/translate.js'; // Types ---------------------------------------------------------------------------- type LoginAttemptData = { attempts: number; cooldownTimeSecs: number; lastAttemptTime: Date; deleteTimeoutID?: NodeJS.Timeout; }; // Variables ---------------------------------------------------------------------------- /** Maximum consecutive login attempts allowed for each username-IP * combination before they will be locked out temporarily. */ const maxLoginAttempts = 3; /** The amount of time the cooldown is incremented by, after failing by {@link maxLoginAttempts} *again*... */ const loginCooldownIncrementorSecs = 5; /** * A hash that stores login attempts for each ip and user. * `{ * "username_IP": { * attempts: 0, * cooldownTimeSecs: 0, * lastAttemptTime: 0, deleteTimeoutID, * } * }` */ const loginAttemptData: Record = {}; /** * The time, in milliseconds, to delete a browser agent from the * login attempt data, if they have stopped trying to login. */ const timeToDeleteBrowserAgentAfterNoAttemptsMillis = 1000 * 60 * 5; // 5 minutes // Functions ---------------------------------------------------------------------------- /** * Prevents a user-IP combination from entering login attempts too fast. * @returns true if the attempt is allowed */ function rateLimitLogin(req: Request, res: Response, browserAgent: string): boolean { const now = new Date(); loginAttemptData[browserAgent] = loginAttemptData[browserAgent] || { attempts: 0, cooldownTimeSecs: 0, lastAttemptTime: now, }; const timeSinceLastAttemptsSecs = (now.getTime() - loginAttemptData[browserAgent].lastAttemptTime.getTime()) / 1000; if (loginAttemptData[browserAgent].attempts < maxLoginAttempts) { incrementBrowserAgentLoginAttemptCounter(browserAgent, now); return true; // Attempt allowed } // Too many attempts! if (timeSinceLastAttemptsSecs <= loginAttemptData[browserAgent].cooldownTimeSecs) { // Still on cooldown let translation = getTranslationForReq('server.javascript.ws-login_failure_retry_in', req); const login_cooldown = Math.floor( loginAttemptData[browserAgent].cooldownTimeSecs - timeSinceLastAttemptsSecs, ); const seconds_plurality = login_cooldown === 1 ? getTranslationForReq('server.javascript.ws-second', req) : getTranslationForReq('server.javascript.ws-seconds', req); translation += ` ${login_cooldown} ${seconds_plurality}.`; res.status(401).json({ message: translation }); // "Failed to log in, try again in 3 seconds."" // Reset the timer to auto-delete them from the login attempt data // if they haven't tried in a while. // This is so it doesn't get cluttered over time // as more and more people try to login and fail. resetTimerToDeleteBrowserAgent(browserAgent); return false; // Attempt not allowed } // No longer on cooldown resetBrowserAgentLoginAttemptCounter(browserAgent); incrementBrowserAgentLoginAttemptCounter(browserAgent, now); return true; // Attempt allowed } /** * Generates a unique browser agent string using the request object and username. * @param req - The request object. * @param username - The username. * @returns The browser agent string, `${usernameLowercase}${clientIP}` */ function getBrowserAgent(req: Request, username: string): string { const clientIP = getClientIP(req); return `${username}${clientIP}`; } /** * Increments the login attempt counter in the login attempt data for a browser agent. * @param browserAgent - The browser agent string. * @param now - The current date and time. */ function incrementBrowserAgentLoginAttemptCounter(browserAgent: string, now: Date): void { loginAttemptData[browserAgent]!.attempts += 1; loginAttemptData[browserAgent]!.lastAttemptTime = now; // Reset the timer to auto-delete them from the login attempt data // if they haven't tried in a while. // This is so it doesn't get cluttered over time // as more and more people try to login and fail. resetTimerToDeleteBrowserAgent(browserAgent); } /** * Resets the login attempt counter in the login attempt data for a browser agent. * @param browserAgent - The browser agent string. */ function resetBrowserAgentLoginAttemptCounter(browserAgent: string): void { loginAttemptData[browserAgent]!.attempts = 0; } /** * Resets the timer to delete a browser agent from the login attempt data. * @param browserAgent - The browser agent string. */ function resetTimerToDeleteBrowserAgent(browserAgent: string): void { cancelTimerToDeleteBrowserAgent(browserAgent); startTimerToDeleteBrowserAgent(browserAgent); } /** * Cancels the timer to delete a browser agent from the login attempt data. * @param browserAgent - The browser agent string. */ function cancelTimerToDeleteBrowserAgent(browserAgent: string): void { clearTimeout(loginAttemptData[browserAgent]?.deleteTimeoutID); delete loginAttemptData[browserAgent]?.deleteTimeoutID; } /** * Starts the timer that will delete a browser agent from the login attempt data * after they have given up on trying passwords. * @param browserAgent - The browser agent string. */ function startTimerToDeleteBrowserAgent(browserAgent: string): void { loginAttemptData[browserAgent]!.deleteTimeoutID = setTimeout(() => { delete loginAttemptData[browserAgent]; console.log(`Allowing browser agent "${browserAgent}" to login without cooldown again!`); }, timeToDeleteBrowserAgentAfterNoAttemptsMillis); } /** * Handles the rate limiting scenario when an incorrect password is entered. * Temporarily locks them out if they've entered too many incorrect passwords. * @param browserAgent - The browser agent string. * @param username - The username. */ function onIncorrectPassword(browserAgent: string, username: string): void { if (loginAttemptData[browserAgent]!.attempts < maxLoginAttempts) return; // Don't lock them yet // Lock them! loginAttemptData[browserAgent]!.cooldownTimeSecs += loginCooldownIncrementorSecs; logEventsAndPrint( `${username} got login locked for ${loginAttemptData[browserAgent]!.cooldownTimeSecs} seconds`, 'loginAttempts.txt', ); } /** * Handles the rate limiting scenario when a correct password is entered. * Deletes their browser agent from the login attempt data. * @param browserAgent - The browser agent string. */ function onCorrectPassword(browserAgent: string): void { cancelTimerToDeleteBrowserAgent(browserAgent); // Delete now delete loginAttemptData[browserAgent]; } export { rateLimitLogin, onCorrectPassword, onIncorrectPassword, getBrowserAgent }; ================================================ FILE: src/server/controllers/authenticationTokens/accessTokenIssuer.ts ================================================ // src/server/controllers/authenticationTokens/accessTokenIssuer.ts // Route // Returns a new access token if refresh token hasn't expired. // Called by a fetch(). ALWAYS RETURN a json! import type { Request, Response } from 'express'; import { signAccessToken } from './tokenSigner.js'; /** * How long until the cookie containing their new access token * will last until expiring, in milliseconds. * This is NOT when the token itself expires, only the cookie. */ const expireTimeOfTokenCookieMillis = 1000 * 10; // 10 seconds /** * Called when the browser uses the /api/get-access-token API request. This reads any refresh token cookie present, * and gives them a new access token if they are signed in. * If they are not, it gives them a browser-id cookie to verify their identity. */ function accessTokenIssuer(req: Request, res: Response): void { if (!req.memberInfo || !req.memberInfo.signedIn) { res.status(403).json({ message: 'Invalid or missing refresh token (logged out), cannot issue access token.', }); // Forbidden return; } // Token is valid! Send them new access token! const { user_id, username, roles } = req.memberInfo; const accessToken = signAccessToken(user_id, username, roles); // SEND the token as a cookie! createAccessTokenCookie(res, accessToken); // 10 second expiry time res.json({ message: 'Issued access token!' }); // Their member information is now stored in a cookie when the refreshed token cookie is generated // console.log(`Issued access token for member "${username}" --------`); } /** Creates and sets an HTTP-only cookie containing the refresh token. */ function createAccessTokenCookie(res: Response, accessToken: string): void { // Cross-site usage requires we set sameSite to none! Also requires secure (https) true res.cookie('token', accessToken, { sameSite: 'none', secure: true, maxAge: expireTimeOfTokenCookieMillis, // 10 second time limit. JavaScript needs to read it in that time! }); } export { accessTokenIssuer }; ================================================ FILE: src/server/controllers/authenticationTokens/sessionManager.ts ================================================ // src/server/controllers/authenticationTokens/sessionManager.ts /** * This module handles the creation, renewal, and revocation of user login sessions. * It uses secure cookies and interacts with the `refreshTokenManager` for database operations. */ import type { Request, Response } from 'express'; import type { Role } from '../roles.js'; import type { RefreshTokenRecord } from '../../database/refreshTokenManager.js'; import { deletePreferencesCookie } from '../../api/Prefs.js'; import { deletePracticeProgressCookie } from '../../api/PracticeProgress.js'; import { refreshTokenExpiryMillis, signRefreshToken } from './tokenSigner.js'; import { addRefreshToken, markRefreshTokenAsConsumed } from '../../database/refreshTokenManager.js'; const minTimeToWaitToRenewRefreshTokensMillis = 1000 * 60 * 60 * 24; // 1 day // const minTimeToWaitToRenewRefreshTokensMillis = 1000 * 10; // 10s // Renewing & Revoking Sessions -------------------------------------------------------------------- /** Makes sure a user's session is still fresh, renewing it if it's older than a day. */ export function freshenSession( req: Request, res: Response, user_id: number, username: string, roles: Role[] | null, tokenRecord: RefreshTokenRecord, ): void { // If the token is already consumed (a new one was issued), // do not renew it again. Let this request finish using the "dying" token. if (tokenRecord.consumed_at) return; const timeSinceCreated = Date.now() - tokenRecord.created_at; if (timeSinceCreated < minTimeToWaitToRenewRefreshTokensMillis) return; // console.log( // `Renewing member "${username}"s session by issuing them new login cookies! -------`, // ); // Create the new token. const newToken = signRefreshToken(user_id, username, roles); // Mark old token as consumed so it has a short grace period before it is fully invalidated. markRefreshTokenAsConsumed(tokenRecord.token); // Add the new token to the database. addRefreshToken(req, user_id, newToken); // Send the new token to the user in their cookies. createSessionCookies(res, user_id, username, newToken); } /** * Creates a new login session for a user when they login. * @param req - The Request object. * @param res - The Response object. * @param user_id - The unique id of the user in the database. * @param username - The username of the user. * @param roles - The roles the user has. */ export function createNewSession( req: Request, res: Response, user_id: number, username: string, roles: Role[] | null, ): void { // The payload can be an object with their username and their roles. const refreshToken = signRefreshToken(user_id, username, roles); // Save the refresh token to the database addRefreshToken(req, user_id, refreshToken); createSessionCookies(res, user_id, username, refreshToken); } /** * Terminates the session of a client by deleting their session, preferences, and practice progress cookies. * * NOTE: This only clears the cookies from the user's browser. * To invalidate the token on the server side, you must also call `deleteRefreshToken(token)`. * This is typically done in a logout route handler. * @param res - The response object. */ export function revokeSession(res: Response): void { deleteSessionCookies(res); deletePreferencesCookie(res); // Even though this cookie expires after 10 seconds, it's good to delete it here anyway. deletePracticeProgressCookie(res); } // Cookies storing session information -------------------------------------------------------------------- /** * Creates and sets the cookies: * * `memberInfo` containing user info (user ID and username), * * `jwt` containing our refresh token. * @param res - The response object. * @param userId - The ID of the user. * @param username - The username of the user. * @param refreshToken - The refresh token to be stored in the cookie. */ function createSessionCookies( res: Response, userId: number, username: string, refreshToken: string, ): void { // Create and sets an HTTP-only cookie containing the refresh token. // Cross-site usage requires we set sameSite to none! Also requires secure (https) true. res.cookie('jwt', refreshToken, { httpOnly: true, sameSite: 'none', secure: true, maxAge: refreshTokenExpiryMillis, }); createMemberInfoCookie(res, userId, username); } /** * Creates and sets a cookie containing user info (user ID and username), * accessible by JavaScript, with the same expiration as the refresh token. * @param res - The response object. * @param userId - The ID of the user. * @param username - The username of the user. */ function createMemberInfoCookie(res: Response, userId: number, username: string): void { // Create an object with member info const now = Date.now(); const expires = now + refreshTokenExpiryMillis; const memberInfo = JSON.stringify({ user_id: userId, username, issued: now, expires }); // Set the cookie (readable by JavaScript, not HTTP-only). // Cross-site usage requires we set sameSite to 'None'! Also requires secure (https) true. res.cookie('memberInfo', memberInfo, { httpOnly: false, sameSite: 'none', secure: true, maxAge: refreshTokenExpiryMillis, }); } /** * Deletes the cookies that store session information. * @param res - The response object. */ function deleteSessionCookies(res: Response): void { // Clear the HTTP-only 'jwt' cookie by setting the same options as when it was created. res.clearCookie('jwt', { httpOnly: true, sameSite: 'none', secure: true }); // Clear the 'memberInfo' cookie by setting the same options as when it was created. res.clearCookie('memberInfo', { httpOnly: false, sameSite: 'none', secure: true }); } ================================================ FILE: src/server/controllers/authenticationTokens/tokenSigner.ts ================================================ // src/server/controllers/authenticationTokens/tokenSigner.ts /** * Tokens can be signed with the payload that includes any information we want! * We like to use user ID, username and roles. * * The benefit of signing access tokens with information is when we verify the tokens, * we don't have to do a database lookup to know who they are! */ import type { Role } from '../roles.js'; import jwt from 'jsonwebtoken'; import tokenConfig from '../../../shared/util/tokenConfig.js'; import 'dotenv/config'; // Imports all properties of process.env, if it exists /** The payload of the JWT token, containing user information. */ interface TokenPayload { user_id: number; username: string; roles: Role[] | null; } if (!process.env['ACCESS_TOKEN_SECRET']) throw new Error('Missing ACCESS_TOKEN_SECRET'); if (!process.env['REFRESH_TOKEN_SECRET']) throw new Error('Missing REFRESH_TOKEN_SECRET'); const ACCESS_TOKEN_SECRET = process.env['ACCESS_TOKEN_SECRET']; const REFRESH_TOKEN_SECRET = process.env['REFRESH_TOKEN_SECRET']; // Session tokens expiry times ------------------------------------------------------ const refreshTokenExpiryMillis = 1000 * 60 * 60 * 24 * 5; // 5 days // const refreshTokenExpiryMillis = 1000 * 20; // 20 seconds, for testing purposes. /** The window where a "consumed" token is still accepted. */ const refreshTokenGracePeriodMillis = 1000 * 10; // 10 seconds // Signing Tokens ------------------------------------------------------------------------------------ /** * Signs and generates an access token for the user. */ function signAccessToken(user_id: number, username: string, roles: Role[] | null): string { const payload = generatePayload(user_id, username, roles); const accessTokenExpirySecs = tokenConfig.ACCESS_TOKEN_EXPIRY_MILLIS / 1000; return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: accessTokenExpirySecs }); // Typically short-lived, for in-memory storage only. } /** * Signs and generates a refresh token for the user. * The refresh token is long-lived (hours-days) and should be stored in an httpOnly cookie (not accessible via JS). */ function signRefreshToken(user_id: number, username: string, roles: Role[] | null): string { const payload = generatePayload(user_id, username, roles); const refreshTokenExpirySecs = refreshTokenExpiryMillis / 1000; return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: refreshTokenExpirySecs }); // Longer-lived, stored in an httpOnly cookie. } /** Generates the payload object for a JWT based on the user ID and username. */ function generatePayload(user_id: number, username: string, roles: Role[] | null): TokenPayload { return { user_id, username, roles }; } export { refreshTokenExpiryMillis, refreshTokenGracePeriodMillis, signAccessToken, signRefreshToken, }; export type { TokenPayload }; ================================================ FILE: src/server/controllers/authenticationTokens/tokenValidator.ts ================================================ // src/server/controllers/authenticationTokens/tokenValidator.ts /** * This script tests provided tokens for validation, * returning the decoded user information if they are, * renews their session if possible, * and updates their last_seen property in the database. */ import jwt from 'jsonwebtoken'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; import { doesMemberOfIDExist, updateLastSeen } from '../../database/memberManager.js'; import { refreshTokenGracePeriodMillis, TokenPayload } from './tokenSigner.js'; import { deleteRefreshToken, findRefreshToken, updateRefreshTokenIP, type RefreshTokenRecord, } from '../../database/refreshTokenManager.js'; if (!process.env['ACCESS_TOKEN_SECRET']) throw new Error('Missing ACCESS_TOKEN_SECRET'); if (!process.env['REFRESH_TOKEN_SECRET']) throw new Error('Missing REFRESH_TOKEN_SECRET'); const ACCESS_TOKEN_SECRET = process.env['ACCESS_TOKEN_SECRET']; const REFRESH_TOKEN_SECRET = process.env['REFRESH_TOKEN_SECRET']; // Validating Tokens --------------------------------------------------------------------------------- /** * Checks if an access token is valid => not expired, * nor tampered, and the user account still exists. */ function isAccessTokenValid(token: string): | { isValid: true; payload: TokenPayload; } | { isValid: false; reason: string; } { // Decode the token const payload = decodeToken(token, false); if (!payload) return { isValid: false, reason: 'Token is expired or tampered.' }; try { // Check if the user account still exists. if (!doesMemberOfIDExist(payload.user_id)) return { isValid: false, reason: 'User account does not exist.' }; } catch (error: unknown) { // This block will catch any unexpected errors from database calls const message = error instanceof Error ? error.message : 'An unexpected error occurred.'; // Reject the token as invalid in this case return { isValid: false, reason: message }; } updateLastSeen(payload.user_id); return { isValid: true, payload }; } /** * Checks if a refresh token is valid. Not expired, nor tampered, and it's still * in the database (not manually invalidated by logging out, or deleting the account). * @param token * @param IP - Has a chance to not be defined on HTTP requests. * @returns */ function isRefreshTokenValid( token: string, IP?: string, ): | { isValid: true; payload: TokenPayload; tokenRecord: RefreshTokenRecord; } | { isValid: false; reason: string; } { // Decode the token const payload = decodeToken(token, true); if (!payload) return { isValid: false, reason: 'Token is expired or tampered.' }; let tokenRecord: RefreshTokenRecord | undefined; try { // Check against the database tokenRecord = resolveRefreshTokenRecord(token, IP); if (!tokenRecord) return { isValid: false, reason: 'Refresh token unable to be resolved in the database.', }; } catch (error) { // This block will catch any unexpected errors from database calls const errMsg = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error resolving refresh token in the database: ${errMsg}`, 'errLog.txt'); return { isValid: false, reason: 'An internal error occurred during validation.' }; } updateLastSeen(payload.user_id); return { isValid: true, payload, tokenRecord }; } /** * Checks if a specific refresh token is present in the database, and has not expired, * deleting it if it has expired, and updating its last used IP address if it has changed. * If not present, it means it has either expired, been manually invalidated by the user logging out, or deleting their account. * * Returns the token record if found and valid, otherwise undefined. */ function resolveRefreshTokenRecord(token: string, IP?: string): RefreshTokenRecord | undefined { // Find the token in the database. const tokenRecord = findRefreshToken(token); if (!tokenRecord) return; // Token must have been manually invalidated by the user logging out, or deleting their account. const now = Date.now(); // Check if it is naturally expired. if (tokenRecord.expires_at < now) { // The token is expired, remove it from the database for cleanup. deleteRefreshToken(token); return; } // Check if it was consumed (replaced) and the grace period has ended. if ( tokenRecord.consumed_at !== null && now - tokenRecord.consumed_at > refreshTokenGracePeriodMillis ) { // The token is "dead" (grace period over). Remove it from the database. deleteRefreshToken(token); return; } // Update the IP address if it has changed. const IP_New: string | null = IP || null; if (IP_New !== tokenRecord.ip_address) { updateRefreshTokenIP(token, IP_New); } return tokenRecord; } /** Extracts and decodes the payload from an access or refresh token. */ function decodeToken(token: string, isRefreshToken: boolean): TokenPayload | undefined { const secret = isRefreshToken ? REFRESH_TOKEN_SECRET : ACCESS_TOKEN_SECRET; try { // Decode the JWT and return the payload const jwtPayload = jwt.verify(token, secret) as jwt.JwtPayload; // Can cast here because we know we originally signed it as an object, not a string. return { user_id: jwtPayload['user_id'], username: jwtPayload['username'], roles: jwtPayload['roles'], }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); // Log the error event when verification fails logEventsAndPrint( `Failed to decode token (isRefreshToken: ${isRefreshToken}): ${errMsg}. Token: "${token}"`, 'errLog.txt', ); // Return undefined if verification fails (e.g., token is invalid or expired) return undefined; } } export { isAccessTokenValid, isRefreshTokenValid }; ================================================ FILE: src/server/controllers/awsWebhook.ts ================================================ // src/server/controllers/awsWebhook.ts /** * Controller to handle AWS SNS webhooks for SES bounce and complaint notifications. */ import type { Request, Response } from 'express'; import MessageValidator from 'sns-validator'; import { addToBlacklist } from '../database/blacklistManager.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; const validator = new MessageValidator(); /** * Handles incoming webhooks from AWS SNS. * VERIFIES SIGNATURE to ensure request is actually from AWS. */ export async function handleSesWebhook(req: Request, res: Response): Promise { const body = req.body; // Basic sanity check if (!body || !req.headers['x-amz-sns-message-type']) { console.error('[AWS WEBHOOK] Invalid request: missing body or headers'); res.status(400).send('Invalid request'); return; } // Verify the AWS Signature // We wrap the callback in a Promise to use await try { await new Promise((resolve, reject) => { validator.validate(body, (err, _message) => { if (err) reject(err); else resolve(); }); }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); logEvents( `[AWS WEBHOOK] Signature Verification Failed! Is this a hacker? Error: ${msg}`, 'awsNotifications.txt', ); // This likely means a hacker is trying to spoof a request res.status(401).send('Invalid signature'); return; } // console.log('[AWS WEBHOOK] Signature verified successfully.'); // If we get here, the request is guaranteed to be from Amazon. const messageType = body.Type; // Note: Validator might normalize keys, but usually Body.Type matches header // ------------------------------------------------------------------------- // CASE 1: Subscription Confirmation // ------------------------------------------------------------------------- if (messageType === 'SubscriptionConfirmation') { const subscribeUrl = body.SubscribeURL; console.log('[AWS WEBHOOK] Verifying subscription...'); if (subscribeUrl) { try { // We must perform a GET request to this URL to confirm we own the server await fetch(subscribeUrl); console.log('[AWS WEBHOOK] Subscription Confirmed!'); res.status(200).send('Confirmed'); return; } catch (err) { console.error('[AWS WEBHOOK] Confirmation failed:', err); res.status(500).send('Failed'); return; } } } // ------------------------------------------------------------------------- // CASE 2: Notification // ------------------------------------------------------------------------- else if (messageType === 'Notification') { // console.log('[AWS WEBHOOK] Processing notification...'); // Log entire message so we can learn unexpected structures logEvents(`[AWS WEBHOOK] Received Notification: ${body.Message}`, 'awsNotifications.txt'); let sesMessage; try { // AWS SNS wraps the actual SES JSON inside a string called "Message" // We must parse that inner string. sesMessage = JSON.parse(body.Message); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); logEventsAndPrint(`[AWS WEBHOOK] JSON Parse Error: ${msg}`, 'errLog.txt'); res.status(400).send('Bad JSON'); return; } const type = sesMessage.notificationType; // Handle Bounces if (type === 'Bounce') { const bounce = sesMessage.bounce; // We strictly ban Permanent bounces (User Unknown, etc) // Transient bounces (Mailbox Full) are usually safe to retry later, but banning them is safer. if (bounce.bounceType === 'Permanent') { // 'Permanent' or 'Transient' const recipients = bounce.bouncedRecipients; if (Array.isArray(recipients)) { recipients.forEach((recipient: any) => { const email = recipient.emailAddress; logEvents(`[AWS WEBHOOK] Hard Bounce: ${email}`, 'awsNotifications.txt'); // Add to our blacklist table (our db is synchronious, using better-sqlite3) addToBlacklist(email, 'bounce'); }); } } else { logEvents( `[AWS WEBHOOK] Bounce Type is not Permanent. No action taken: ${bounce.bounceType}`, 'awsNotifications.txt', ); } } // Handle Complaints (Spam Reports) else if (type === 'Complaint') { const recipients = sesMessage.complaint.complainedRecipients; if (Array.isArray(recipients)) { recipients.forEach((recipient: any) => { const email = recipient.emailAddress; logEvents(`[AWS WEBHOOK] Complaint: ${email}`, 'awsNotifications.txt'); addToBlacklist(email, 'spam_report'); }); } } else { logEventsAndPrint( `[AWS WEBHOOK] Unknown notification type: ${type}`, 'awsNotifications.txt', ); } } else { logEventsAndPrint( `[AWS WEBHOOK] Unknown message type: ${messageType}`, 'awsNotifications.txt', ); } // Always return 200 OK. // If we return 500, AWS will keep retrying to send us the same bounce event. res.status(200).send('OK'); } ================================================ FILE: src/server/controllers/browserIDManager.ts ================================================ // src/server/controllers/browserIDManager.ts import type { Request, Response, NextFunction } from 'express'; import uuid from '../../shared/util/uuid.js'; import { isBrowserIDBanned } from '../middleware/banned.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; const expireOfBrowserIDCookieMillis = 1000 * 60 * 60 * 24 * 7; // 7 days /** * Assigns/renews the browser-id cookie to all requests for an html file. * If they have an existing browser id, it renews it for 7 more days. * If they don't, it gives them a new browser id for 7 day. * @param req - The Express request object. * @param res - The Express response object. * @param next - The Express next middleware function. */ function assignOrRenewBrowserID(req: Request, res: Response, next: NextFunction): void { // We don't have to worry about the request being for a resource because those have already been served. // The only scenario this request could be for now is an HTML or fetch API request // The 'is-fetch-request' header is a custom header we add on all fetch requests to let us know is is a fetch request. if (req.headers['is-fetch-request'] === 'true' || !req.accepts('html')) return next(); // Not an HTML request (but a fetch), don't set the cookie const cookies = req.cookies; if (!cookies['browser-id']) giveBrowserID(req, res); else refreshBrowserID(req, res); next(); } function giveBrowserID(req: Request, res: Response): void { const cookieName = 'browser-id'; const id = uuid.generateID_Base62(6); // console.log(`Assigning new browser-id: "${id}" for url: ` + req.url + ' --------'); // Readable by server with web socket connections, NOT by javascript: MAX AGE IN MILLIS NOT SECS res.cookie(cookieName, id, { httpOnly: true, sameSite: 'none', secure: true, maxAge: expireOfBrowserIDCookieMillis /* 1 day */, }); } function refreshBrowserID(req: Request, res: Response): void { const cookieName = 'browser-id'; const cookies = req.cookies; const id = cookies[cookieName]!; if (isBrowserIDBanned(id)) return makeBrowserIDPermanent(req, res, id); // console.log(`Renewing browser-id: "${id}" for url: ` + req.url); // Readable by server with web socket connections, NOT by javascript res.cookie(cookieName, id, { httpOnly: true, sameSite: 'none', secure: true, maxAge: expireOfBrowserIDCookieMillis, }); } function makeBrowserIDPermanent(req: Request, res: Response, browserID: string): void { // Readable by server with web socket connections, NOT by javascript: MAX AGE IN MILLIS NOT SECS res.cookie('browser-id', browserID, { httpOnly: true, sameSite: 'none', secure: true, maxAge: Number.MAX_SAFE_INTEGER /* FOREVER!! */, }); const logThis = `Making banned browser-id PERMANENT: ${browserID} !!! ${req.headers.origin} ${req.method} ${req.url} ${req.headers['user-agent']}`; logEventsAndPrint(logThis, 'bannedIPLog.txt'); } export { assignOrRenewBrowserID }; ================================================ FILE: src/server/controllers/createAccountController.ts ================================================ // src/server/controllers/createAccountController.ts /* * This module handles create account form data, * verifying the data, creating the account, * and sending them a verification email. * * It also answers requests for whether * a specific username or email is available. */ import crypto from 'crypto'; import bcrypt from 'bcrypt'; // @ts-ignore this package has no types import emailValidator from 'node-email-verifier'; import { Request, Response } from 'express'; import { RegExpMatcher, englishDataset, englishRecommendedTransformers } from 'obscenity'; import validators from '../../shared/util/validators.js'; import { handleLogin } from './loginController.js'; import { isBlacklisted } from '../database/blacklistManager.js'; import { getTranslationForReq } from '../utility/translate.js'; import { sendEmailConfirmation } from './emailController.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { addUser, isEmailTaken, isUsernameTaken, SQLITE_CONSTRAINT_ERROR, } from '../database/memberManager.js'; // Variables ------------------------------------------------------------------------- /** * The number of times to SALT passwords before storing in the database. * * Consider moving SALT_ROUNDS to a config file or environment variable */ const PASSWORD_SALT_ROUNDS: number = 10; /** * Initialize the obscenity profanity matcher. * Uses the English dataset with recommended transformers. */ const profanityMatcher = new RegExpMatcher({ ...englishDataset.build(), ...englishRecommendedTransformers, }); // Functions ------------------------------------------------------------------------- /** * This route is called whenever the user clicks "Create Account" */ async function createNewMember(req: Request, res: Response): Promise { if (!req.body) { console.log(`User sent a bad create account request missing the whole body!`); res.status(400).send('Bad request'); // 400 Bad request return; } // Honeypot Bot Catcher: `recovery` — if present, return generic success. const recoveryEmail: string = typeof req.body.recovery === 'string' ? req.body.recovery.trim() : ''; if (recoveryEmail.length > 0) { const username = typeof req.body.username === 'string' ? req.body.username : '[empty]'; logEventsAndPrint( `Bot signup detected! IP: ${req.ip}, Username: ${username}, User-Agent: ${req.get('User-Agent')}`, 'newMemberLog.txt', ); // Return a normal-looking success so bot doesn't adapt res.status(200).json({ success: true, created: true }); return; } // First make sure we have all 3 variables. // eslint-disable-next-line prefer-const let { username, email, password }: { username: string; email: string; password: string } = req.body; if (typeof username !== 'string' || typeof email !== 'string' || typeof password !== 'string') { console.error( 'We received request to create new member without all supplied username, email, and password!', ); res.status(400).redirect('/400'); // Bad request return; } // Make the email lowercase, so we don't run into problems with seeing if capitalized emails are taken! email = email.toLowerCase(); // First we make checks on the username... // These 'return's are so that we don't send duplicate responses, AND so we don't create the member anyway. if (!doUsernameValidation(username, req, res)) return; if (!(await doEmailValidation(email, req, res))) return; if (!doPasswordFormatChecks(password, req, res)) return; try { await generateAccount({ username, email, password }); } catch (error: unknown) { let message = error instanceof Error ? error.message : 'An unexpected error occurred.'; // Detect the specific constraint error message that can be thrown if (message === SQLITE_CONSTRAINT_ERROR) message = 'The username or email has just been taken.'; res.status(500).json({ error: 'Could not generate account. ' + message }); return; } // Create new login session! They just created an account, so log them in! // This will handle our response/redirect too for us! handleLogin(req, res); } /** * Generate an account only from the provided username, email, and password. * Regex tests are skipped. * @returns If it was a success, the row ID of where the member was inserted (same as their user_id). * * @throws If account creation fails for any reason. */ async function generateAccount({ username, email, password, autoVerify = false, }: { username: string; email: string; password: string; autoVerify?: boolean; }): Promise { // Use bcrypt to hash & salt password const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); // Passes 10 salt rounds. (standard) const { is_verified, verification_code, is_verification_notified } = autoVerify ? { is_verified: 1 as 0 | 1, verification_code: null, is_verification_notified: 1 as 0 | 1, } : { // Don't auto verify them is_verified: 0 as 0 | 1, verification_code: crypto.randomBytes(24).toString('base64url'), is_verification_notified: 0 as 0 | 1, }; const user_id = addUser( username, email, hashedPassword, is_verified, verification_code, is_verification_notified, ); logEvents(`Created new member: ${username}`, 'newMemberLog.txt'); // SEND EMAIL CONFIRMATION if (!autoVerify) sendEmailConfirmation(user_id); return user_id; } /** * Route that's called whenever the client unfocuses the email input field. * This tells them whether the email is valid or not. */ async function checkEmailValidity(req: Request, res: Response): Promise { const lowercaseEmail = req.params['email']!.toLowerCase(); if (isEmailTaken(lowercaseEmail)) { res.json({ valid: false, reason: getTranslationForReq('server.javascript.ws-email_in_use', req), }); return; } if (isBlacklisted(lowercaseEmail)) { res.json({ valid: false, reason: getTranslationForReq('server.javascript.ws-email_blacklisted', req), }); return; } if (!(await isEmailDNSValid(lowercaseEmail))) { res.json({ valid: false, reason: getTranslationForReq('server.javascript.ws-email_domain_invalid', req), }); return; } // Both checks pass res.json({ valid: true }); } /** * Route handler to check if a username is available to use (not taken, reserved, or baaaad word). * The request parameters MUST contain the username to test! (different from the body) * * We send the client the object: `{ allowed: true, reason: '' } | { allowed: false, reason: string }` */ function checkUsernameAvailable(req: Request, res: Response): void { const username = req.params['username']!; const usernameLowercase = username.toLowerCase(); let allowed = true; let reason = ''; if (isUsernameTaken(username)) { allowed = false; reason = getTranslationForReq('server.javascript.ws-username_taken', req); } if (checkProfanity(usernameLowercase)) { allowed = false; reason = getTranslationForReq('server.javascript.ws-username_bad_word', req); } // we only check if it's reserved and ignore any other possible reasons it might not be a valid username if ( validators.validateUsername(username) === validators.UsernameValidationResult.UsernameIsReserved ) { allowed = false; reason = getTranslationForReq('create-account.javascript.js-username_reserved', req); } res.json({ allowed, reason, }); return; } /** Returns true if the username passes all the checks required before account generation. */ function doUsernameValidation(username: string, req: Request, res: Response): boolean { const result = validators.validateUsername(username); if (result !== validators.UsernameValidationResult.Ok) { switch (result) { case validators.UsernameValidationResult.UsernameTooShort: case validators.UsernameValidationResult.UsernameTooLong: res.status(400).json({ message: getTranslationForReq( 'create-account.javascript.js-username_length', req, ), }); return false; case validators.UsernameValidationResult.OnlyLettersAndNumbers: res.status(400).json({ message: getTranslationForReq('server.javascript.ws-username_letters', req), }); return false; case validators.UsernameValidationResult.UsernameIsReserved: res.status(409).json({ conflict: getTranslationForReq('server.javascript.ws-username_taken', req), }); // Code for reserved (but the users don't know that!) return false; default: res.status(400).json({ message: 'Username is not valid, but the server could not determine why.', }); return false; } } // Then check if the name's taken const usernameLowercase = username.toLowerCase(); // Make sure the username isn't taken!! if (isUsernameTaken(username)) { res.status(409).json({ conflict: getTranslationForReq('server.javascript.ws-username_taken', req), }); return false; } // Lastly check for profain words if (checkProfanity(usernameLowercase)) { res.status(409).json({ conflict: getTranslationForReq('server.javascript.ws-username_bad_word', req), }); return false; } return true; // Everything's good, no conflicts! } /** * Returns true if profanity/offensive language is found in the string. * Uses the obscenity package with English dataset and recommended transformers. */ function checkProfanity(string: string): boolean { return profanityMatcher.hasMatch(string); } /** Returns true if the email passes all the checks required for account generation. */ async function doEmailValidation(string: string, req: Request, res: Response): Promise { const result = validators.validateEmail(string); if (result !== validators.EmailValidationResult.Ok) { switch (result) { case validators.EmailValidationResult.InvalidFormat: res.status(400).json({ message: getTranslationForReq('server.javascript.ws-email_invalid', req), }); return false; case validators.EmailValidationResult.EmailTooLong: res.status(400).json({ message: getTranslationForReq('server.javascript.ws-email_too_long', req), }); return false; default: res.status(400).json({ message: 'Email is not valid, but the server could not determine why.', }); return false; } } if (isEmailTaken(string)) { res.status(409).json({ conflict: getTranslationForReq('server.javascript.ws-email_in_use', req), }); return false; } if (isBlacklisted(string)) { const errMessage = `Blacklisted email ${string} tried to create an account!`; logEventsAndPrint(errMessage, 'blacklistLog.txt'); res.status(409).json({ conflict: getTranslationForReq('server.javascript.ws-email_blacklisted', req), }); return false; } if (!(await isEmailDNSValid(string))) { res.status(400).json({ message: getTranslationForReq('server.javascript.ws-email_domain_invalid', req), }); return false; } return true; } /** * Checks an email address's MX records to see if it is valid */ async function isEmailDNSValid(email: string): Promise { try { return await emailValidator(email, { checkMx: true }); } catch (error) { const err = error as Error; // Type assertion logEventsAndPrint( `Error when validating domain for email "${email}": ${err.stack}`, 'errLog.txt', ); return true; // Default to true to avoid blocking users. } } function doPasswordFormatChecks(password: string, req: Request, res: Response): boolean { const result = validators.validatePassword(password); if (result !== validators.PasswordValidationResult.Ok) { switch (result) { case validators.PasswordValidationResult.PasswordTooShort: case validators.PasswordValidationResult.PasswordTooLong: res.status(400).json({ message: getTranslationForReq('server.javascript.ws-password_length', req), }); return false; case validators.PasswordValidationResult.PasswordIsPassword: res.status(400).json({ message: getTranslationForReq('server.javascript.ws-password_password', req), }); return false; default: res.status(400).json({ message: 'Password is not valid, but the server could not determine why.', }); return false; } } return true; } export { createNewMember, checkEmailValidity, checkUsernameAvailable, generateAccount, doPasswordFormatChecks, PASSWORD_SALT_ROUNDS, profanityMatcher, }; ================================================ FILE: src/server/controllers/createAccountController.unit.test.ts ================================================ // src/server/controllers/createAccountController.unit.test.ts /** * Tests for the profanity filter used in account creation. * * This test suite verifies that the obscenity package correctly identifies * profane content in usernames during account creation. */ import { describe, it, expect } from 'vitest'; import { profanityMatcher } from './createAccountController'; // Import the identical one used in the controller /** * Helper function to check profanity (same logic as in createAccountController) */ function checkProfanity(string: string): boolean { return profanityMatcher.hasMatch(string); } describe('Profanity Filter', () => { describe('Basic profanity detection', () => { it('should detect common profane words', () => { expect(checkProfanity('fuck')).toBe(true); expect(checkProfanity('shit')).toBe(true); expect(checkProfanity('bitch')).toBe(true); expect(checkProfanity('ass')).toBe(true); }); it('should detect profanity regardless of case', () => { expect(checkProfanity('FUCK')).toBe(true); expect(checkProfanity('FuCk')).toBe(true); expect(checkProfanity('sHiT')).toBe(true); }); it('should detect profanity within usernames', () => { expect(checkProfanity('userfuck123')).toBe(true); expect(checkProfanity('shit4brains')).toBe(true); expect(checkProfanity('mybitch')).toBe(true); }); }); describe('Variant detection', () => { it('should detect common profanity variants', () => { // Obscenity package handles these with its transformers // Note: symbols are currently not allowed in usernames. expect(checkProfanity('f*ck')).toBe(true); expect(checkProfanity('sh!t')).toBe(true); expect(checkProfanity('b1tch')).toBe(true); }); it('should detect leetspeak variants', () => { expect(checkProfanity('fuk')).toBe(true); expect(checkProfanity('fvck')).toBe(true); }); }); describe('Clean usernames', () => { it('should allow clean usernames', () => { expect(checkProfanity('john123')).toBe(false); expect(checkProfanity('player1')).toBe(false); expect(checkProfanity('cooluser')).toBe(false); expect(checkProfanity('chessmaster')).toBe(false); }); it('should allow usernames with words that contain profanity substrings but are not profane', () => { // The obscenity package is smart enough to handle these cases expect(checkProfanity('password')).toBe(false); expect(checkProfanity('classic')).toBe(false); expect(checkProfanity('assassin')).toBe(false); }); it('should allow numbers and alphanumeric combinations', () => { expect(checkProfanity('user123')).toBe(false); expect(checkProfanity('abc123xyz')).toBe(false); expect(checkProfanity('player9000')).toBe(false); }); it('should allow usernames with profaine substrings in non-profane words', () => { expect(checkProfanity('passage')).toBe(false); expect(checkProfanity('classical')).toBe(false); expect(checkProfanity('assistant')).toBe(false); }); }); describe('Edge cases', () => { it('should handle empty strings', () => { expect(checkProfanity('')).toBe(false); }); it('should handle single characters', () => { expect(checkProfanity('a')).toBe(false); expect(checkProfanity('1')).toBe(false); }); it('should handle special characters only', () => { expect(checkProfanity('!@#$%')).toBe(false); }); it('should handle long usernames with profanity', () => { expect(checkProfanity('verylongusernamewithfuckprofanity')).toBe(true); }); }); describe('Performance', () => { it('should handle multiple checks efficiently', () => { const testUsernames = [ 'user1', 'user2', 'user3', 'cleanuser', 'chessplayer', 'john123', 'jane456', 'player789', 'gamer1000', 'testuser', ]; const startTime = Date.now(); testUsernames.forEach((username) => { checkProfanity(username); }); const endTime = Date.now(); // Should complete quickly expect(endTime - startTime).toBeLessThan(10); }); }); }); ================================================ FILE: src/server/controllers/deleteAccountController.ts ================================================ // src/server/controllers/deleteAccountController.ts /** * This module handles account deletion. */ import type { Request, Response } from 'express'; import { revokeSession } from './authenticationTokens/sessionManager.js'; import { getTranslationForReq } from '../utility/translate.js'; import { testPasswordForRequest } from './authController.js'; import { closeAllSocketsOfMember } from '../socket/socketManager.js'; import { isMemberInSomeActiveGame } from '../game/gamemanager/gamemanager.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { deleteUser, getMemberDataByCriteria } from '../database/memberManager.js'; // Constants ------------------------------------------------------------------------- /** * A list of all valid reasons to delete an account. * These reasons are stored in the deleted_members table in the database. */ const validDeleteReasons = [ 'unverified', // They failed to verify after 3 days 'user request', // They deleted their own account, or requested it to be deleted. 'security', // A choice by server admins, for security purpose. 'rating abuse', // Unfairly boosted their own elo with a throwaway account ] as const; /** A valid account deletion reason. */ export type DeleteReason = (typeof validDeleteReasons)[number]; // Functions ------------------------------------------------------------------------- /** * Route that removes a user account if they request to delete it. * Checks if there password was correct first. * @param req - The request object. * @param res - The response object. */ async function removeAccount(req: Request, res: Response): Promise { const claimedUsername = req.params['member']; // case-insensitive username if (!claimedUsername) { res.status(400).send('Username required'); return; } // The delete account request doesn't come with the username already in the body, so we set that here. req.body.username = claimedUsername; if (!(await testPasswordForRequest(req, res))) { // It will have already sent a response logEvents( `Incorrect password for user "${claimedUsername}" attempting to remove account!`, 'loginAttempts.txt', ); return; } // Get user_id and case-sensitive username from database const record = getMemberDataByCriteria(['user_id', 'username'], 'username', claimedUsername); if (record === undefined) { logEventsAndPrint( `Unable to find member of claimed username "${claimedUsername}" after a correct password to delete their account!`, 'errLog.txt', ); return; } // Do not allow account deletion if user is currently playing a game // THIS DOES NOT PREVENT AN ADMIN MANUALLY DELETING THEIR ACCOUNT // If that is done while they are in the middle of a rated game, // errors will happen when the game is deleted. if (isMemberInSomeActiveGame(record.username)) { logEventsAndPrint( `User ${record.username} requested account deletion while being listed in some active game.`, 'deletedAccounts.txt', ); res.status(403).json({ message: getTranslationForReq('server.javascript.ws-deleting_account_in_game', req), }); return; } // DELETE ACCOUNT.. // Close their sockets, delete their invites, delete their session cookies... revokeSession(res); const reason_deleted = 'user request'; try { deleteAccount(record.user_id, reason_deleted); logEvents( `Deleted account of user_id (${record.user_id}) for reason (${reason_deleted}).`, 'deletedAccounts.txt', ); res.send('OK'); // 200 is default code return; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Can't delete account of user_id (${record.user_id}) after a correct password entered: ${errorMessage}`, 'errLog.txt', ); res.status(404).json({ message: getTranslationForReq('server.javascript.ws-deleting_account_not_found', req), }); return; } } /** * Deletes a user's account by user_id, * terminates all their login session, * and closes all their open websockets. * * @throws If the delete reason is invalid, or if a database error occurs during the deletion process. */ function deleteAccount(user_id: number, reason_deleted: string): void { if (!isValidDeleteReason(reason_deleted)) { throw Error(`Delete reason (${reason_deleted}) is invalid.`); } deleteUser(user_id, reason_deleted); // Close their sockets, delete their invites... closeAllSocketsOfMember(user_id, 1008, 'Logged out'); // Account deleting automatically invalidates all their sessions, // because their refresh tokens are deleted. // However, they will have to refresh the page for their page and navigation links to update. } /** Type Guard: Checks if a string is a valid DeleteReason. */ function isValidDeleteReason(reason: string): reason is DeleteReason { return validDeleteReasons.some((r) => r === reason); } export { removeAccount, deleteAccount }; ================================================ FILE: src/server/controllers/deployController.ts ================================================ // src/server/controllers/deployController.ts /** * Handles server lifecycle endpoints called by the GitHub Actions deploy workflow. * * All endpoints in this file are authenticated via the X-Restart-Secret header, * which must match the RESTART_SECRET environment variable. */ import type { Request, Response } from 'express'; import { performBackup } from '../database/backupManager.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; /** * POST /api/prepare-restart * * Called by the GitHub Actions deploy workflow before `pm2 reload`. * The runner must wait for HTTP 200 before proceeding so all pre-deploy work * (currently: DB backup) completes before the process is reloaded. */ async function handlePrepareRestart(req: Request, res: Response): Promise { const secret = process.env['RESTART_SECRET']; if (!secret) { logEventsAndPrint( 'POST /api/prepare-restart called but RESTART_SECRET is not set.', 'errLog.txt', ); res.status(500).send('Endpoint is not configured.'); return; } if (req.headers['x-restart-secret'] !== secret) { res.status(403).send('Forbidden.'); return; } try { await performBackup(); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Pre-deploy DB backup failed: ${message}`, 'errLog.txt'); res.status(500).send('Pre-deploy backup failed.'); return; } res.status(200).send('Ready for restart.'); } export { handlePrepareRestart }; ================================================ FILE: src/server/controllers/emailController.ts ================================================ // src/server/controllers/emailController.ts /* * This module constructs and dispatches application emails: * password resets, account verification, and rating abuse alerts. * * It also handles the API endpoint for resending verification emails. */ import type { Request, Response } from 'express'; import mailer from '../utility/mailer.js'; import { getAppBaseUrl } from '../utility/urlUtils.js'; import { isBlacklisted } from '../database/blacklistManager.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; // --- Helper Functions --- function createEmailHtmlWrapper(title: string, contentHtml: string): string { return `

${title}

${contentHtml}
`; } // --- Email Sending Functions --- async function sendPasswordResetEmail(recipientEmail: string, resetUrl: string): Promise { const content = `

We received a request to reset the password for your account.

Please click the button below to set a new password. This link will expire in 1 hour.

Reset Password

If you did not request a password reset, you can safely ignore this email.

`; try { const sent = await mailer.send({ to: recipientEmail, subject: 'Your Password Reset Request', html: createEmailHtmlWrapper('Password Reset Request', content), }); if (sent) { // console.log(`Password reset email sent to ${recipientEmail}`); } else { console.log(`Password Reset Link: ${resetUrl}`); } } catch (err) { const errorMessage = err instanceof Error ? err.stack : String(err); logEventsAndPrint(`Error sending password reset email: ${errorMessage}`, 'errLog.txt'); throw new Error('Unexpected transporter error sending password reset email.'); } } /** * Sends an account verification email to the specified member, * IF they are not blacklisted. * @param user_id - The ID of the user to send the verification email to. */ async function sendEmailConfirmation(user_id: number): Promise { const record = getMemberDataByCriteria( ['username', 'email', 'is_verified', 'verification_code'], 'user_id', user_id, ); if (record === undefined) { logEventsAndPrint( `Unable to send email confirmation for non-existent member of id (${user_id})!`, 'errLog.txt', ); return; } if (isBlacklisted(record.email)) { logEventsAndPrint( `[BLOCKED] Skipping email confirmation to ${record.email} (Blacklisted)`, 'blacklistLog.txt', ); return; } // Check the new 'is_verified' column directly. if (record.is_verified === 1) { // console.log( // `User ${record.username} (ID: ${user_id}) is already verified. Skipping email confirmation.`, // ); return; } // An unverified user MUST have a verification code. if (!record.verification_code) { logEventsAndPrint( `User ${record.username} (ID: ${user_id}) is unverified but has no verification code. Cannot send email.`, 'errLog.txt', ); return; } try { // Construct verification URL using the new 'verification_code' column const baseUrl = getAppBaseUrl(); const verificationUrl = new URL( `${baseUrl}/verify/${record.username.toLowerCase()}/${record.verification_code}`, ).toString(); const content = `

Thank you, ${record.username}, for creating an account. Please click the button below to verify your account.

If this takes you to the login page, then as soon as you log in, your account will be verified.

Verify Account

If this wasn't you, please ignore this email.

`; const sent = await mailer.send({ to: record.email, subject: 'Verify Your Account', html: createEmailHtmlWrapper('Welcome to InfiniteChess.org!', content), }); if (sent) { // console.log(`Verification email sent to member ${record.username} of ID ${user_id}!`); } else { console.log(`Verification Link: ${verificationUrl}`); } } catch (e) { const errorMessage = e instanceof Error ? e.stack : String(e); logEventsAndPrint( `Error during sendEmailConfirmation for user_id (${user_id}): ${errorMessage}`, 'errLog.txt', ); } } /** API to resend the verification email. */ function requestConfirmEmail(req: Request, res: Response): void { if (!req.memberInfo?.signedIn) { res.status(401).json({ message: 'You must be signed in to perform this action.' }); return; } // We know the member url param is defined because this route is only used when it is present. const usernameParam = req.params['member']!; const { user_id, username } = req.memberInfo; if (username.toLowerCase() !== usernameParam.toLowerCase()) { const errText = `Member "${username}" (ID: ${user_id}) attempted to send verification email for user (${usernameParam})!`; logEventsAndPrint(errText, 'hackLog.txt'); res.status(403).json({ sent: false, message: 'Forbidden' }); return; } // Send the email (fire-and-forget, no need to await here as we respond to the user immediately) sendEmailConfirmation(user_id); res.json({ sent: true }); } /** * API to send an email warning about rating abuse to our own infinite chess email address * @param messageSubject - email subject text * @param messageText - email body text */ async function sendRatingAbuseEmail(messageSubject: string, messageText: string): Promise { try { const sent = await mailer.send({ to: mailer.FROM ?? '', subject: messageSubject, text: messageText, }); if (sent) { // console.log(`Rating abuse warning email sent successfully to ${mailer.FROM}.`); } else { console.log("Didn't send rating abuse email."); } } catch (e) { const errorMessage = e instanceof Error ? e.stack : String(e); void logEventsAndPrint( `Error during the sending of rating abuse email with subject "${messageSubject}": ${errorMessage}`, 'errLog.txt', ); } } // --- Exports --- export { sendPasswordResetEmail, sendEmailConfirmation, requestConfirmEmail, sendRatingAbuseEmail }; ================================================ FILE: src/server/controllers/loginController.int.test.ts ================================================ // src/server/controllers/loginController.int.test.ts import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; import { testRequest } from '../../tests/testRequest.js'; import { generateAccount } from './createAccountController.js'; import { generateTables, clearAllTables } from '../database/databaseTables.js'; describe('Login Controller Integration', () => { // Runs once at the very start of this file beforeAll(() => { generateTables(); }); // Runs before EVERY single 'it' block beforeEach(() => { clearAllTables(); }); it('should reject login with no body', async () => { const response = await testRequest().post('/auth').send(); // No body expect(response.status).toBe(400); }); it('should reject login with missing username', async () => { const response = await testRequest().post('/auth').send({ username: 'OnlyUserNoPass' }); // Missing password expect(response.status).toBe(400); }); it('should reject login with missing password', async () => { const response = await testRequest().post('/auth').send({ password: 'OnlyPassNoUser' }); // Missing username expect(response.status).toBe(400); }); it('should reject login with non-string username', async () => { const response = await testRequest() .post('/auth') .send({ username: 12345, password: 'SomePassword' }); // Non-string username expect(response.status).toBe(400); }); it('should reject login with non-string password', async () => { const response = await testRequest() .post('/auth') .send({ username: 'SomeUser', password: 67890 }); // Non-string password expect(response.status).toBe(400); }); it('should reject login for non-existent user', async () => { const response = await testRequest() .post('/auth') .send({ username: 'GhostUser', password: 'password123' }); expect(response.status).toBe(401); }); it('should reject login with incorrect password', async () => { // 1. Setup await generateAccount({ username: 'RealUser', email: 'test@example.com', password: 'CorrectPassword!', autoVerify: true, }); // 2. Test const response = await testRequest() .post('/auth') .send({ username: 'RealUser', password: 'WRONG_PASSWORD' }); expect(response.status).toBe(401); }); it('should login successfully with correct credentials', async () => { // 1. Setup await generateAccount({ username: 'RealUser', email: 'test@example.com', password: 'CorrectPassword!', autoVerify: true, }); // 2. Test const response = await testRequest() .post('/auth') .send({ username: 'RealUser', password: 'CorrectPassword!' }); expect(response.status).toBe(200); // Ensure that the session cookies are set const cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array expect(cookies).toBeDefined(); expect(cookies.some((c) => c.startsWith('jwt='))).toBe(true); expect(cookies.some((c) => c.startsWith('memberInfo='))).toBe(true); }); }); ================================================ FILE: src/server/controllers/loginController.ts ================================================ // src/server/controllers/loginController.ts /** * This controller is used when a client logs in. * * This rate limits a members login attempts, * and when they successfully login: * * Creates a new login session, * and updates last_seen and login_count in their profile. */ import type { Request, Response } from 'express'; import { createNewSession } from './authenticationTokens/sessionManager.js'; import { testPasswordForRequest } from './authController.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria, updateLoginCountAndLastSeen } from '../database/memberManager.js'; /** * Called when the login page submits login form data. * Tests their username and password. If correct, it logs * them in, generates tokens for them, and updates their member variables. * THIS SHOULD ALWAYS send a json response, because the errors we send are displayed on the page. */ async function handleLogin(req: Request, res: Response): Promise { // Initial check - if this fails, it sends a response and returns. if (!(await testPasswordForRequest(req, res))) return; // Correct password... try { const usernameCaseInsensitive = req.body.username; // We already know this property is present on the request const record = getMemberDataByCriteria( ['user_id', 'username', 'roles'], 'username', usernameCaseInsensitive, ); if (record === undefined) { // This is a critical internal inconsistency. logEventsAndPrint( `User "${usernameCaseInsensitive}" not found by username after a successful password check! This indicates a data integrity issue.`, 'errLog.txt', ); // Send a generic error to the client, as this is a server-side problem. res.status(500).json({ message: 'Login failed due to an internal server error. Please try again later.', }); return; } // The roles fetched from the database is a stringified json string array, parse it here! const parsedRoles = record.roles !== null ? JSON.parse(record.roles) : null; createNewSession(req, res, record.user_id, record.username, parsedRoles); res.status(200).json({ message: 'Logged in successfully.' }); // These operations are "fire and forget" in terms of the client response updateLoginCountAndLastSeen(record.user_id); logEvents(`Logged in member "${record.username}".`, 'loginAttempts.txt'); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the detailed error for server-side debugging. logEventsAndPrint( `Error during handleLogin for user "${req.body.username}": ${message}`, 'errLog.txt', ); // Send a generic error response to the client. // Avoid sending detailed error messages to the client for security reasons. // Check if a response has already been sent to avoid "Error [ERR_HTTP_HEADERS_SENT]" if (!res.headersSent) { res.status(500).json({ message: 'Login failed due to an unexpected error. Please try again.', }); } } } export { handleLogin }; ================================================ FILE: src/server/controllers/logoutController.ts ================================================ // src/server/controllers/logoutController.ts import type { Request, Response } from 'express'; import { revokeSession } from '../controllers/authenticationTokens/sessionManager.js'; import { deleteRefreshToken } from '../database/refreshTokenManager.js'; import { closeAllSocketsOfSession } from '../socket/socketManager.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; /** Handles member logout by revoking the session and deleting the refresh token. */ async function handleLogout(req: Request, res: Response): Promise { // Delete the refresh token cookie... // On client, also delete the accessToken const cookies = req.cookies; const refreshToken = cookies['jwt']; if (typeof refreshToken !== 'string') return res.redirect('/'); // Cookie already deleted. (Already logged out) // Delete their existing session cookies WHETHER OR NOT they // are signed in, because they may THINK they are... revokeSession(res); if (!req.memberInfo?.signedIn) { // Existing refresh token cookie was invalid (tampered, expired, manually invalidated, or account deleted) res.redirect('/'); return; } try { // Now invalidate the refresh token from the database by deleting it. deleteRefreshToken(refreshToken); } catch (e) { const message = e instanceof Error ? e.message : String(e); logEventsAndPrint( `Critical error when logging out member "${req.memberInfo.username}": ${message}`, 'errLog.txt', ); res.status(500).json({ message: 'Server Error' }); return; } closeAllSocketsOfSession(refreshToken, 1008, 'Logged out'); res.redirect('/'); logEvents(`Logged out member "${req.memberInfo.username}".`, 'loginAttempts.txt'); } export { handleLogout }; ================================================ FILE: src/server/controllers/passwordResetController.ts ================================================ // src/server/controllers/passwordResetController.ts import crypto from 'crypto'; import bcrypt from 'bcrypt'; import { Request, Response } from 'express'; import db from '../database/database.js'; import { getAppBaseUrl } from '../utility/urlUtils.js'; import { isBlacklisted } from '../database/blacklistManager.js'; import { getTranslationForReq } from '../utility/translate.js'; import { sendPasswordResetEmail } from './emailController.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { deleteAllRefreshTokensForUser } from '../database/refreshTokenManager.js'; import { doPasswordFormatChecks, PASSWORD_SALT_ROUNDS } from './createAccountController.js'; const PASSWORD_RESET_TOKEN_EXPIRY_MILLIS: number = 1000 * 60 * 60; // 1 Hour /** Route for when a user REQUESTS a password reset email. */ async function handleForgotPasswordRequest(req: Request, res: Response): Promise { const { email } = req.body; if (!email || typeof email !== 'string') { res.status(400).json({ message: 'Email is required and must be a string.' }); return; } try { // 1. Find user by email (case-insensitive) const member = db.get<{ user_id: number }>( 'SELECT user_id FROM members WHERE email = ? COLLATE NOCASE', [email], ); if (member) { // User exists, proceed with password reset flow const userId: number = member.user_id; // 2. Invalidate old tokens db.run('DELETE FROM password_reset_tokens WHERE user_id = ?', [userId]); // 3. Make sure they aren't blacklisted if (isBlacklisted(email)) { logEventsAndPrint( `User has a blacklisted email ${email} when attempting to request a password reset!`, 'blacklistLog.txt', ); res.status(409).json({ message: getTranslationForReq('server.javascript.ws-email_blacklisted', req), }); return; } // 4. Generate plain token const plainToken: string = crypto.randomBytes(32).toString('base64url'); // 5. Hash the plain token const hashedTokenForDb: string = await bcrypt.hash(plainToken, PASSWORD_SALT_ROUNDS); // 6. Set expiration (e.g., ~1 hour from now in milliseconds) const expiresAt: number = Date.now() + PASSWORD_RESET_TOKEN_EXPIRY_MILLIS; // 7. Store new token in the database db.run( 'INSERT INTO password_reset_tokens (user_id, hashed_token, expires_at) VALUES (?, ?, ?)', [userId, hashedTokenForDb, expiresAt], ); // 8. Construct reset URL using the utility const baseUrl = getAppBaseUrl(); const resetUrl = new URL(`${baseUrl}/reset-password/${plainToken}`).toString(); // 9. Log the email send attempt logEvents( `Sending password reset email to user_id (${userId})...`, 'loginAttempts.txt', ); // 10. Send email (must have its own error handling since we're not await'ing an async method!!) sendPasswordResetEmail(email, resetUrl).catch((err) => { const errorMessage = err instanceof Error ? err.stack : String(err); logEventsAndPrint( `Background password reset email send failed for user_id (${userId}), email (${email}): ${errorMessage}`, 'errLog.txt', ); }); } else { logEventsAndPrint( `No member exists with the email (${email}). Not sending password reset email.`, 'loginAttempts.txt', ); } // ALWAYS return a generic success message to prevent email enumeration. res.status(200).json({ message: getTranslationForReq('server.javascript.ws-password-reset-link-sent', req), }); } catch (error) { const errorMessage: string = 'Forgot password database error: ' + (error instanceof Error ? error.message : String(error)); logEventsAndPrint(errorMessage, 'errLog.txt'); res.status(500).json({ message: 'An error occurred while processing your request. Please try again later.', }); return; } } type TokenRecord = { user_id: number; hashed_token: string }; /** * Route for when a user SENDS the password change API. * Changes their password in the database. */ async function handleResetPassword(req: Request, res: Response): Promise { const { token, password } = req.body; // 1. Basic Input Validation if (!token || !password) { res.status(400).json({ message: 'Token and new password are required.' }); return; } if (typeof token !== 'string') { res.status(400).json({ message: 'Token must be a string.' }); return; } if (typeof password !== 'string') { res.status(400).json({ message: 'Password must be a string.' }); return; } // Password strength rules (e.g., length) if (!doPasswordFormatChecks(password, req, res)) return; try { // 2. Find a matching, unexpired token. // Since we stored a HASH, we cannot query by the plain token directly. // We must fetch potential tokens and compare them one by one. const now = Date.now(); const potentialTokens = db.all( 'SELECT user_id, hashed_token FROM password_reset_tokens WHERE expires_at > ?', [now], ); let validTokenRecord: TokenRecord | null = null; for (const record of potentialTokens) { const isMatch = await bcrypt.compare(token, record.hashed_token); if (isMatch) { validTokenRecord = record; break; // Found our match, exit the loop } } // 3. Handle Invalid or Expired Token if (!validTokenRecord) { logEvents(`Invalid or expired password reset token used.`, 'loginAttempts.txt'); res.status(400).json({ message: getTranslationForReq( 'server.javascript.ws-password-reset-token-invalid', req, ), }); return; } // 4. Hash the New Password const hashedNewPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); const userId = validTokenRecord.user_id; const usedHashedToken = validTokenRecord.hashed_token; // Store for use in transaction // 5. Update the User's Password in the database. // At the same time, invalidate the used token. const resetTransaction = db.transaction(() => { // Step 1: Update the User's Password const updateResult = db.run( 'UPDATE members SET hashed_password = ? WHERE user_id = ?', [hashedNewPassword, userId], ); if (updateResult.changes === 0) { // If the user doesn't exist, we must throw an error // to force the transaction to roll back. throw new Error( `Failed to update password for user_id (${userId}), user may not exist.`, ); } // Step 2: Invalidate/Delete the used token db.run('DELETE FROM password_reset_tokens WHERE hashed_token = ?', [usedHashedToken]); }); // Execute the transaction. If any part of it throws an error, // the entire transaction is rolled back automatically. resetTransaction(); // 6. Terminate all of the user's active sessions. // Recommended for security. deleteAllRefreshTokensForUser(userId); // Optional but recommended: Send a confirmation email that the password was changed. // 7. Send Success Response res.status(200).json({ message: getTranslationForReq('server.javascript.ws-password-change-success', req), }); // 8. Log the successful password reset logEvents(`Password reset successful for user_id (${userId})`, 'loginAttempts.txt'); } catch (error) { const errorMessage: string = 'Reset password error: ' + (error instanceof Error ? error.message : String(error)); logEventsAndPrint(errorMessage, 'errLog.txt'); res.status(500).json({ message: 'An internal error occurred. Please try again later.' }); } } export { handleForgotPasswordRequest, handleResetPassword }; ================================================ FILE: src/server/controllers/roles.ts ================================================ // src/server/controllers/roles.ts /** * This module handles the addition * and removal of roles from members. */ import { logEventsAndPrint } from '../middleware/logEvents.js'; import { getMemberDataByCriteria, updateMemberColumns } from '../database/memberManager.js'; /** * All possible roles, IN ORDER FROM LEAST TO MOST IMPORTANCE! * The ordering determines admin's capabilities in the admin console. */ const validRoles = ['patron', 'admin', 'owner'] as const; /** A valid role of a user. */ export type Role = (typeof validRoles)[number]; /** * Adds a specified role to a member's roles list. * @param userId - The user ID of the member. * @param role - The role to add (e.g., 'owner', 'patron'). */ function giveRole(userId: number, role: Role): void { // Fetch the member's current roles from the database const memberData = getMemberDataByCriteria(['roles'], 'user_id', userId); if (!memberData) { logEventsAndPrint( `Cannot give role "${role}" to user of ID "${userId}" when they don't exist!`, 'errLog.txt', ); return; } const roles: Role[] = memberData.roles === null ? [] : JSON.parse(memberData.roles); // ['role1','role2', ...] // If the role already exists, return early if (roles.includes(role)) { logEventsAndPrint( `Role "${role}" already exists for member with user ID "${userId}".`, 'errLog.txt', ); return; } // Add the new role to the roles array roles.push(role); try { // Save the updated roles back to the database const result = updateMemberColumns(userId, { roles: JSON.stringify(roles) }); if (result.changeMade) { logEventsAndPrint( `Added role "${role}" to member with ID "${userId}".`, 'loginAttempts.txt', ); } else { logEventsAndPrint( `Failed to add role "${role}" to member with ID "${userId}".`, 'errLog.txt', ); } } catch (error) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error adding role "${role}" to member of ID "${userId}": ${message}`, 'errLog.txt', ); } } /** * Returns true if roles1 contains at least one role that is higher in priority than the highest role in roles2. * * If so, the user with roles1 would be able to perform destructive commands on user with roles2. * @param roles1 - List of roles for the first user. * @param roles2 - List of roles for the second user. */ function areRolesHigherInPriority(roles1: Role[] | null, roles2: Role[] | null): boolean { // Make sure they are not null const r1: Role[] = roles1 || []; const r2: Role[] = roles2 || []; let roles1HighestPriority = -1; // -1 is the same as someone with zero roles r1.forEach((role) => { const priorityOfRole = validRoles.indexOf(role); if (priorityOfRole > roles1HighestPriority) roles1HighestPriority = priorityOfRole; }); let roles2HighestPriority = -1; // -1 is the same as someone with zero roles r2.forEach((role) => { const priorityOfRole = validRoles.indexOf(role); if (priorityOfRole > roles2HighestPriority) roles2HighestPriority = priorityOfRole; }); return roles1HighestPriority > roles2HighestPriority; } export { giveRole, areRolesHigherInPriority }; ================================================ FILE: src/server/controllers/verifyAccountController.ts ================================================ // src/server/controllers/verifyAccountController.ts /** * This controller handles verifying accounts, either manually or via an email link. */ import type { Request, Response } from 'express'; import { getTranslationForReq } from '../utility/translate.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { AddVerificationToAllSocketsOfMember } from '../socket/socketManager.js'; import { getMemberDataByCriteria, MemberRecord, updateMemberColumns, } from '../database/memberManager.js'; // Functions ------------------------------------------------------------------------- /** * Route that verifies an account when the user clicks the link in the email. * If they are not signed in, this forwards them to the login page. */ export async function verifyAccount(req: Request, res: Response): Promise { if (!req.memberInfo) { logEventsAndPrint('req.memberInfo must be defined for verify account route!', 'errLog.txt'); res.status(500).redirect('/500'); return; } const claimedUsername = req.params['member']!; const claimedCode = req.params['code']!; const record = getMemberDataByCriteria( ['user_id', 'username', 'is_verified', 'verification_code'], 'username', claimedUsername, ); if (record === undefined) { // User not found. Can happen if they click the link after their account was auto-deleted for never verifying. logEvents( `Invalid account verification link! User "${claimedUsername}" doesn't exist.`, 'loginAttempts.txt', ); res.status(400).redirect(`/400`); // Bad request return; } if (!req.memberInfo.signedIn) { // Not logged in logEvents( `Forwarding user '${record.username}' to login before they can verify!`, 'loginAttempts.txt', ); // Redirect them to the login page, BUT add a query parameter with the original verification url they were visiting! const redirectTo = encodeURIComponent(req.originalUrl); res.redirect(`/login?redirectTo=${redirectTo}`); return; } if (req.memberInfo.username !== record.username) { // Forbid them if they are logged in and NOT who they're wanting to verify! logEventsAndPrint( `Member "${req.memberInfo.username}" of ID "${req.memberInfo.user_id}" attempted to verify member "${record.username}"!`, 'loginAttempts.txt', ); res.status(403).send( getTranslationForReq('server.javascript.ws-forbidden_wrong_account', req), ); return; } // Ignore if already verified. if (record.is_verified === 1) { logEvents( `Member "${record.username}" of ID ${record.user_id} is already verified!`, 'loginAttempts.txt', ); res.redirect(`/member/${record.username.toLowerCase()}`); return; } // Check if the verification code matches! if (claimedCode !== record.verification_code) { logEventsAndPrint( `Invalid account verification link! User "${record.username}" had an incorrect code`, 'loginAttempts.txt', ); res.status(400).redirect(`/400`); return; } // VERIFY THEM.. const result = _executeVerificationUpdate(record.user_id, record.username); if (result.success) { logEvents( `Verified member ${record.username}'s account! ID ${record.user_id}`, 'loginAttempts.txt', ); res.redirect(`/member/${record.username.toLowerCase()}`); } else { logEventsAndPrint( `Verification failed for "${claimedUsername}" due to: ${result.reason}`, 'errLog.txt', ); res.status(500).redirect(`/member/${record.username.toLowerCase()}`); } } /** * Manually verifies a user by their email. DOES NOT CHECK PERMISSIONS. * @param email The email of the account to verify. * @returns A success or failure object. */ export function manuallyVerifyUser( email: string, ): { success: true; username: string } | { success: false; reason: string } { const record = getMemberDataByCriteria(['user_id', 'username', 'is_verified'], 'email', email); if (record === undefined) { return { success: false, reason: `User with email "${email}" doesn't exist.` }; } if (record.is_verified === 1) { return { success: false, reason: `User with email "${email}" is already verified.` }; } // VERIFY THEM.. const result = _executeVerificationUpdate(record.user_id, record.username); if (result.success) { logEvents( `Manually verified account of user with email "${email}"! ID ${record.user_id}`, 'loginAttempts.txt', ); return { success: true, username: record.username }; } else { return { success: false, reason: result.reason }; } } /** * Core logic to update the database to mark a user as verified. * @param user_id The ID of the user to verify. * @param username The username of the user to verify (for logging). * @returns A success or failure object. */ function _executeVerificationUpdate( user_id: number, username: string, ): { success: true } | { success: false; reason: string } { AddVerificationToAllSocketsOfMember(user_id); const changes: Partial = { is_verified: 1, verification_code: null, // Set to 0 so they will see the "Thank you" message next time they visit their profile is_verification_notified: 0, }; try { const result = updateMemberColumns(user_id, changes); if (!result.changeMade) { logEventsAndPrint( `Failed to verify member "${username}" of ID "${user_id}": No change made. Do they exist?`, 'errLog.txt', ); return { success: false, reason: 'Failed to update user verification.' }; } return { success: true }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error verifying member "${username}" of ID "${user_id}": ${message}`, 'errLog.txt', ); return { success: false, reason: 'Server error during user verification.' }; } } ================================================ FILE: src/server/database/backupManager.ts ================================================ // src/server/database/backupManager.ts /** * This module handles automated SQLite database backups. * * It uses SQLite's Online Backup API (via better-sqlite3's db.backup()) * to produce a single consistent .db snapshot while the database is live. */ import fs from 'fs'; import path from 'path'; import { format } from 'date-fns'; import { fileURLToPath } from 'url'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const BACKUPS_DIR = path.join(__dirname, '../../../backups'); const MAX_BACKUP_AGE_MS = 1000 * 60 * 60 * 24 * 30; // 30 days const BACKUP_INTERVAL_MS = 1000 * 60 * 60 * 24; // 24 hours /** The in-flight backup promise, or null if no backup is currently running. */ let activeBackup: Promise | null = null; // Functions ------------------------------------------------------------------------- /** Schedules a database backup to run once every 24 hours. */ function startDailyBackups(): void { setInterval(async () => { try { await performBackup(); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Daily database backup failed: ${message}`, 'errLog.txt'); } }, BACKUP_INTERVAL_MS); } /** * Creates a timestamped backup of the database in the `backups/` directory, * then purges any backups older than 30 days. * If a backup is already in progress, returns the same promise so callers join it. * @throws If the SQLite backup or directory creation fails. */ function performBackup(): Promise { if (activeBackup !== null) return activeBackup; activeBackup = doBackup().finally(() => { activeBackup = null; }); return activeBackup; } /** * The actual backup implementation. * @throws If the SQLite backup or directory creation fails. */ async function doBackup(): Promise { if (process.env['NODE_ENV'] === 'test') return; // In-memory DB — nothing to back up. fs.mkdirSync(BACKUPS_DIR, { recursive: true }); const dateFormatted = format(new Date(), 'yyyy-MM-dd_HH-mm-ss'); const destPath = path.join(BACKUPS_DIR, `database-${dateFormatted}.db`); const start = Date.now(); await db.backup(destPath); const elapsed = Date.now() - start; console.log(`Database backup created: ${path.basename(destPath)} (${elapsed}ms)`); purgeOldBackups(); } /** Deletes backup files in `backups/` that are older than 30 days. */ function purgeOldBackups(): void { try { const now = Date.now(); for (const file of fs.readdirSync(BACKUPS_DIR)) { if (!file.endsWith('.db')) continue; const filePath = path.join(BACKUPS_DIR, file); const stat = fs.statSync(filePath); if (now - stat.mtimeMs > MAX_BACKUP_AGE_MS) { fs.unlinkSync(filePath); } } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); void logEventsAndPrint(`Error purging old db backups: ${message}`, 'errLog.txt'); } } export { startDailyBackups, performBackup }; ================================================ FILE: src/server/database/blacklistManager.ts ================================================ // src/server/database/blacklistManager.ts import db from './database.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; /** Adds an email to the blacklist, if it isn't already. */ export function addToBlacklist(email: string, reason: string): void { try { // Uses INSERT OR IGNORE so it doesn't crash if the email is already blacklisted. db.run(`INSERT OR IGNORE INTO email_blacklist (email, reason) VALUES (?, ?)`, [ email, reason, ]); logEvents(`Added ${email} to blacklist for reason: ${reason}`, 'blacklistLog.txt'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logEventsAndPrint(`Database error when blacklisting email ${email}: ${msg}`, 'errLog.txt'); } } /** Removes an email from the blacklist, if it exists. */ export function removeFromBlacklist(email: string): void { try { // Won't error if the email doesn't exist. db.run(`DELETE FROM email_blacklist WHERE email = ?`, [email]); logEvents(`Removed ${email} from blacklist`, 'blacklistLog.txt'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logEventsAndPrint( `Database error when removing email ${email} from blacklist: ${msg}`, 'errLog.txt', ); } } /** * Checks if an email is in the blacklist. * Returns true if blacklisted, false otherwise. */ export function isBlacklisted(email: string): boolean { try { // We select '1' just to see if a row exists. // db.get returns the row object (truthy) or undefined (falsy). const result = db.get<{ '1': number }>(`SELECT 1 FROM email_blacklist WHERE email = ?`, [ email, ]); return !!result; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); logEventsAndPrint( `Database error when checking blacklist for email ${email}: ${msg}`, 'errLog.txt', ); // Fail safe: If DB errors, assume NOT blacklisted so we don't block legitimate users // (or return true if we want to be ultra-safe/paranoid) return false; } } ================================================ FILE: src/server/database/cleanupTasks.ts ================================================ // src/server/database/cleanupTasks.ts /** * This script contains methods for periodically * cleaning up each table in the database of stale data. */ import timeutil from '../../shared/util/timeutil.js'; import db from './database.js'; import { deleteAccount } from '../controllers/deleteAccountController.js'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; import { refreshTokenGracePeriodMillis } from '../controllers/authenticationTokens/tokenSigner.js'; /** The maximum time an account is allowed to remain unverified before the server will delete it from Database. */ const maxExistenceTimeForUnverifiedAccountMillis = 1000 * 60 * 60 * 24 * 3; // 3 days // const maxExistenceTimeForUnverifiedAccountMillis = 1000 * 40; // 30 seconds const CLEANUP_INTERVAL_MS = 1000 * 60 * 60 * 24; // 24 hours // const CLEANUP_INTERVAL_MS = 1000 * 20; // 20 seconds for dev testing function startPeriodicDatabaseCleanupTasks(): void { performCleanupTasks(); // Run immediately to clean up now. setInterval(() => performCleanupTasks(), CLEANUP_INTERVAL_MS); } function performCleanupTasks(): void { checkDatabaseIntegrity(); deleteExpiredPasswordResetTokens(); cleanUpExpiredRefreshTokens(); removeOldUnverifiedMembers(); } // ======================================================== /** Checks the integrity of the SQLite database and logs it to the error log if the check fails. */ function checkDatabaseIntegrity(): void { try { const result = db.get<{ integrity_check: string }>('PRAGMA integrity_check;'); if (result?.integrity_check !== 'ok') logEventsAndPrint( `Database integrity check failed: ${result?.integrity_check} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`, 'errLog.txt', ); // else console.log('Database integrity check passed.'); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error performing database integrity check: ${errorMessage} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`, 'errLog.txt', ); } } /** Periodically deletes expired password reset tokens from the database. */ function deleteExpiredPasswordResetTokens(): void { // console.log('Running cleanup of expired password reset tokens.'); try { const now = Date.now(); const result = db.run('DELETE FROM password_reset_tokens WHERE expires_at < ?', [now]); if (result.changes > 0) { console.log(`Cleanup: Deleted ${result.changes} expired password reset tokens.`); } } catch (error) { const errorMessage = 'Failed to delete expired password reset tokens: ' + (error instanceof Error ? error.message : String(error)); logEventsAndPrint(errorMessage, 'errLog.txt'); } } /** * Deletes invalid refresh tokens: * 1. Tokens that have naturally expired. * 2. Tokens that were consumed (replaced) more than a short grace period ago. */ function cleanUpExpiredRefreshTokens(): void { try { const now = Date.now(); const consumptionThreshold = now - refreshTokenGracePeriodMillis; const query = ` DELETE FROM refresh_tokens WHERE expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?) `; const result = db.run(query, [now, consumptionThreshold]); if (result.changes > 0) { logEventsAndPrint( `Cleanup: Deleted ${result.changes} expired/consumed refresh tokens.`, 'tokenCleanupLog.txt', ); } } catch (error) { const errorMessage = 'Failed to delete expired refresh tokens: ' + (error instanceof Error ? error.message : String(error)); logEventsAndPrint(errorMessage, 'errLog.txt'); } } /** * Removes unverified members who have not verified their account for more than 3 days. * * FUTURE: If the user has zero game records in the database, we could skip adding * their user_id to the deleted_members table, allowing us to reuse that id. */ function removeOldUnverifiedMembers(): void { // console.log("Checking for old unverified accounts to remove."); try { // Calculate the cutoff time. const cutoffTimestamp = Date.now() - maxExistenceTimeForUnverifiedAccountMillis; const cutoffDateString = timeutil.timestampToSqlite(cutoffTimestamp); const membersToDelete = db.all<{ user_id: number }>( ` SELECT user_id FROM members WHERE is_verified = 0 AND joined < ? `, [cutoffDateString], ); if (membersToDelete.length === 0) return; // Nothing to do. const reason_deleted = 'unverified'; // Iterate through the IDs and delete each account. for (const member of membersToDelete) { try { deleteAccount(member.user_id, reason_deleted); logEvents( `Removed old unverified account with ID: ${member.user_id}`, 'deletedAccounts.txt', ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `FAILED to remove old unverified account with ID (${member.user_id}): ${message}`, 'errLog.txt', ); } } console.log(`Cleanup: Removed ${membersToDelete.length} unverified account(s).`); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error removing old unverified accounts: ${errorMessage}`, 'errLog.txt'); } } // ========================================================= export { startPeriodicDatabaseCleanupTasks }; ================================================ FILE: src/server/database/database.ts ================================================ // src/server/database/database.ts /* * This module provides utility functions for managing SQLite database operations * using the `better-sqlite3` library. * * It supports executing SQL queries, retrieving results (single or multiple rows), * caching prepared statements for performance, and handling database transactions. */ import path from 'path'; import Database from 'better-sqlite3'; import { fileURLToPath } from 'url'; // Get the current file path and derive the directory (ESM doesn't support __dirname) const __filename: string = fileURLToPath(import.meta.url); const __dirname: string = path.dirname(__filename); // Create or connect to the SQLite database file const dbLocation: string = process.env['NODE_ENV'] === 'test' ? ':memory:' // For integration tests, use in-memory database : path.join(__dirname, '../../../', 'database.db'); // Normal database file const db = new Database(dbLocation); // const db = new Database(dbLocation, { verbose: console.log }); // Optional for logging queries // Enable WAL (Write-Ahead Logging) mode for better concurrency and crash safety. // Writers no longer block readers, and the main database file is never modified mid-write. db.pragma('journal_mode = WAL'); // With WAL, NORMAL synchronous is safe and faster than the default FULL. // WAL provides its own durability guarantees that make FULL redundant. db.pragma('synchronous = NORMAL'); // Variables ---------------------------------------------------------------------------------------------- // Prepared statements cache const stmtCache: Record = {}; // Query Calls -------------------------------------------------------------------------------------------- // Utility function to retrieve or prepare statements function prepareStatement(query: string): Database.Statement { if (!stmtCache[query]) { // console.log(`Added statement to stmtCache: "${query}"`); stmtCache[query] = db.prepare(query); } return stmtCache[query]; } type SupportedColumnTypes = string | number | boolean | null; /** * Executes a given SQL query with optional parameters and returns the result. * @param {string} query - The SQL query to be executed. * @param {Array} [params=[]] - An array of parameters to bind to the query. * @returns {object} - The result of the query execution. */ function run(query: string, params: SupportedColumnTypes[] = []): Database.RunResult { const stmt = prepareStatement(query); return stmt.run(...params); } /** * Retrieves a single row from the database for a given SQL query. * @param query - The SQL query to be executed. * @param [params=[]] - An array of parameters to bind to the query. * @returns - The row object if found, otherwise undefined. */ function get(query: string, params: SupportedColumnTypes[] = []): T | undefined { const stmt = prepareStatement(query); return stmt.get(...params) as T | undefined; } /** * Retrieves all rows from the database for a given SQL query. * @param query - The SQL query to be executed. * @param [params=[]] - An array of parameters to bind to the query. * @returns - An array of row objects. */ function all(query: string, params: SupportedColumnTypes[] = []): T[] { const stmt = prepareStatement(query); return stmt.all(...params) as T[]; } /** Closes the database connection. */ function close(): void { db.close(); // console.log('Closed database.'); } /** * Creates a consistent point-in-time backup of the database to the given file path * using SQLite's Online Backup API. Safe to call while the database is open and being written to. * @param destPath - Absolute path for the destination backup file. */ async function backup(destPath: string): Promise { await db.backup(destPath); } /** Checks if a column exists in a table. */ function columnExists(tableName: string, columnName: string): boolean { try { // PRAGMA queries are special and should not use the statement cache. // We access the raw db instance's prepare method directly. const result = db .prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name = ?`) .get(tableName, columnName); return !!result; } catch (error) { console.error(`Error checking if column ${columnName} exists in ${tableName}:`, error); return false; } } /** * Creates a transaction function that wraps the given callback in a database transaction. * The callback will be executed atomically - either all operations succeed or all are rolled back. * * @template Args - The argument types for the transaction function * @template Return - The return type of the transaction function * @param callback - The function to execute within the transaction context * @returns A transaction function that executes the callback atomically * * @example * ```typescript * const transferFunds = transaction((fromId: number, toId: number, amount: number) => { * run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]); * run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]); * }); * * // Execute the transaction * transferFunds(1, 2, 100); * ``` */ function transaction( callback: (..._args: Args) => Return, ): (..._args: Args) => Return { return db.transaction(callback); } export default { run, get, all, close, backup, columnExists, transaction, }; ================================================ FILE: src/server/database/databaseTables.ts ================================================ // src/server/database/databaseTables.ts /** * This script creates our database tables if they aren't already present. */ import db from './database.js'; import { startDailyBackups } from './backupManager.js'; import { startPeriodicDatabaseCleanupTasks } from './cleanupTasks.js'; import { startPeriodicLeaderboardRatingDeviationUpdate } from './leaderboardsManager.js'; // Variables ----------------------------------------------------------------------------------- const user_id_upper_cap: number = 14_776_336; // 62**4: Limit of unique user id with 4-digit base-62 user ids! const game_id_upper_cap: number = 14_776_336; // 62**4: Limit of unique game id with 4-digit base-62 game ids! /** All unique columns of the members table. Each of these would be valid to search for to find a single member. */ const uniqueMemberKeys: string[] = ['user_id', 'username', 'email']; /** All columns of the members table. Each of these would be valid to retrieve from any member. */ const allMemberColumns: string[] = [ 'user_id', 'username', 'username_history', 'email', 'hashed_password', 'roles', 'joined', 'last_seen', 'preferences', 'login_count', 'checkmates_beaten', 'is_verified', 'verification_code', 'is_verification_notified', 'last_read_news_date', ]; /** All columns of the player_stats table. Each of these would be valid to retrieve from any member. */ const _allPlayerStatsColumns: string[] = [ 'user_id', 'moves_played', 'game_count', 'game_count_rated', 'game_count_casual', 'game_count_public', 'game_count_private', 'game_count_wins', 'game_count_losses', 'game_count_draws', 'game_count_aborted', 'game_count_wins_rated', 'game_count_losses_rated', 'game_count_draws_rated', 'game_count_wins_casual', 'game_count_losses_casual', 'game_count_draws_casual', ]; /** All columns of the player_stats table. Each of these would be valid to retrieve from any member. */ const allPlayerGamesColumns: string[] = [ 'user_id', 'game_id', 'player_number', 'score', 'clock_at_end_millis', 'elo_at_game', 'elo_change_from_game', ]; /** All columns of the games table. Each of these would be valid to retrieve from any game. */ const allGamesColumns: string[] = [ 'game_id', 'date', 'base_time_seconds', 'increment_seconds', 'variant', 'rated', 'leaderboard_id', 'private', 'result', 'termination', 'move_count', 'time_duration_millis', 'icn', ]; /** All columns of the rating_abuse table. Each of these would be valid to retrieve from any member and/or leaderboard. */ const allRatingAbuseColumns: string[] = [ 'user_id', 'leaderboard_id', 'game_count_since_last_check', 'last_alerted_at', ]; // Functions ----------------------------------------------------------------------------------- /** Creates the tables in our database if they do not exist. */ function generateTables(): void { // Members table db.run(` CREATE TABLE IF NOT EXISTS members ( user_id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL COLLATE NOCASE, email TEXT UNIQUE NOT NULL, hashed_password TEXT NOT NULL, roles TEXT, joined TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, login_count INTEGER NOT NULL DEFAULT 0, is_verified INTEGER NOT NULL DEFAULT 0, verification_code TEXT, is_verification_notified INTEGER NOT NULL DEFAULT 0, preferences TEXT, username_history TEXT, checkmates_beaten TEXT NOT NULL DEFAULT '', last_read_news_date TEXT ); `); // Deleted Members table db.run(` CREATE TABLE IF NOT EXISTS deleted_members ( user_id INTEGER PRIMARY KEY, reason_deleted TEXT NOT NULL -- "unverified" / "user request" / "security" / "rating abuse" ); `); // Leaderboards table db.run(` CREATE TABLE IF NOT EXISTS leaderboards ( user_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE, leaderboard_id INTEGER NOT NULL, -- Each leaderboard's id and variants are declared in the code elo REAL NOT NULL, rating_deviation REAL NOT NULL, -- Add other Glicko fields if needed (volatility) rd_last_update_date TIMESTAMP, PRIMARY KEY (user_id, leaderboard_id) -- Composite key essential ); `); // Indexes for leaderboards table // To quickly get all leaderboards for a specific user db.run(`CREATE INDEX IF NOT EXISTS idx_leaderboards_user ON leaderboards (user_id);`); // To quickly get rankings for a specific leaderboard (ESSENTIAL) db.run( `CREATE INDEX IF NOT EXISTS idx_leaderboards_leaderboard_elo ON leaderboards (leaderboard_id, elo DESC);`, ); // Games table db.run(` CREATE TABLE IF NOT EXISTS games ( game_id INTEGER PRIMARY KEY, date TIMESTAMP NOT NULL, base_time_seconds INTEGER, -- null if untimed increment_seconds INTEGER, -- null if untimed variant TEXT NOT NULL, rated BOOLEAN NOT NULL CHECK (rated IN (0, 1)), -- Ensures only 0 or 1 leaderboard_id INTEGER, -- Specified only if the variant belongs to a leaderboard, ignoring whether the game was rated private BOOLEAN NOT NULL CHECK (private IN (0, 1)), -- Ensures only 0 or 1 result TEXT NOT NULL, termination TEXT NOT NULL, move_count INTEGER NOT NULL, time_duration_millis INTEGER, -- Number of milliseconds that the game lasted in total on the server. Null if info is missing. icn TEXT NOT NULL -- Also includes clock timestamps after each move -- Add a CHECK constraint to ensure consistency: -- EITHER both are NULL (untimed) OR both are NOT NULL and >= 0 (timed) CHECK ( (base_time_seconds IS NULL AND increment_seconds IS NULL) OR (base_time_seconds > 0 AND increment_seconds >= 0) ) ); `); // Create an index on the date column of the games table for faster queries db.run(`CREATE INDEX IF NOT EXISTS idx_games_date ON games (date DESC);`); // Player Games Table db.run(` CREATE TABLE IF NOT EXISTS player_games ( user_id INTEGER NOT NULL, -- Account deletion does not delete rows in this table game_id INTEGER NOT NULL REFERENCES games(game_id) ON DELETE CASCADE, player_number INTEGER NOT NULL, -- 1 => White 2 => Black score REAL, -- 1 => Win 0.5 => Draw 0 => Loss NULL => Aborted clock_at_end_millis INTEGER, -- Number of milliseconds that player still has left on his clock when the game ended. Null if game has no clock or info is missing. elo_at_game REAL, -- Specified if they have a rating for the leaderboard, ignoring whether the game was rated elo_change_from_game REAL, -- Specified only if the game was rated PRIMARY KEY (user_id, game_id) -- Ensures unique link ); `); // Create an index for efficiently finding players in a specific game db.run(`CREATE INDEX IF NOT EXISTS idx_player_games_game ON player_games (game_id);`); // Player Stats table db.run(` CREATE TABLE IF NOT EXISTS player_stats ( user_id INTEGER PRIMARY KEY REFERENCES members(user_id) ON DELETE CASCADE, moves_played INTEGER NOT NULL DEFAULT 0, game_count INTEGER NOT NULL DEFAULT 0, game_count_rated INTEGER NOT NULL DEFAULT 0, game_count_casual INTEGER NOT NULL DEFAULT 0, game_count_public INTEGER NOT NULL DEFAULT 0, game_count_private INTEGER NOT NULL DEFAULT 0, game_count_wins INTEGER NOT NULL DEFAULT 0, game_count_losses INTEGER NOT NULL DEFAULT 0, game_count_draws INTEGER NOT NULL DEFAULT 0, game_count_aborted INTEGER NOT NULL DEFAULT 0, game_count_wins_rated INTEGER NOT NULL DEFAULT 0, game_count_losses_rated INTEGER NOT NULL DEFAULT 0, game_count_draws_rated INTEGER NOT NULL DEFAULT 0, game_count_wins_casual INTEGER NOT NULL DEFAULT 0, game_count_losses_casual INTEGER NOT NULL DEFAULT 0, game_count_draws_casual INTEGER NOT NULL DEFAULT 0 ); `); // Rating Abuse table db.run(` CREATE TABLE IF NOT EXISTS rating_abuse ( user_id INTEGER NOT NULL, leaderboard_id INTEGER NOT NULL, game_count_since_last_check INTEGER, last_alerted_at TIMESTAMP, PRIMARY KEY (user_id, leaderboard_id), FOREIGN KEY (user_id, leaderboard_id) REFERENCES leaderboards(user_id, leaderboard_id) ON DELETE CASCADE ); `); // To quickly get all rating_abuse entries for a specific user db.run(`CREATE INDEX IF NOT EXISTS idx_rating_abuse_user ON rating_abuse (user_id);`); // Password Reset Tokens table db.run(` CREATE TABLE IF NOT EXISTS password_reset_tokens ( hashed_token TEXT PRIMARY KEY NOT NULL, user_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE, expires_at INTEGER NOT NULL, -- Unix timestamp (milliseconds) created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER) * 1000) -- Unix timestamp (milliseconds) ); `); // Indexes for password_reset_tokens table db.run(`CREATE INDEX IF NOT EXISTS idx_prt_user_id ON password_reset_tokens (user_id);`); db.run(`CREATE INDEX IF NOT EXISTS idx_prt_expires_at ON password_reset_tokens (expires_at);`); // Refresh Tokens table db.run(` CREATE TABLE IF NOT EXISTS refresh_tokens ( token TEXT PRIMARY KEY NOT NULL, user_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE, created_at INTEGER NOT NULL, -- Unix timestamp (milliseconds) expires_at INTEGER NOT NULL, -- Unix timestamp (milliseconds) consumed_at INTEGER, -- Allows a grace period for using old tokens when renewing sessions ip_address TEXT ); `); // Indexes for refresh_tokens table db.run(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id);`); db.run( `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);`, ); // Editor Saves table db.run(` CREATE TABLE IF NOT EXISTS editor_saves ( user_id INTEGER NOT NULL REFERENCES members(user_id) ON DELETE CASCADE, name TEXT NOT NULL, piece_count INTEGER NOT NULL, timestamp INTEGER NOT NULL, icn TEXT NOT NULL, compression TEXT NOT NULL DEFAULT 'none', pawn_double_push INTEGER NOT NULL CHECK (pawn_double_push IN (-1, 0, 1)), castling INTEGER NOT NULL CHECK (castling IN (-1, 0, 1)), PRIMARY KEY (user_id, name) ); `); // Blacklisted Emails table db.run(` CREATE TABLE IF NOT EXISTS email_blacklist ( email TEXT PRIMARY KEY NOT NULL, reason TEXT NOT NULL, -- e.g. 'bounce', 'spam_report', 'banned' created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); // Live Games table — persists active games across server restarts db.run(` CREATE TABLE IF NOT EXISTS live_games ( game_id INTEGER PRIMARY KEY, time_created INTEGER NOT NULL, variant TEXT NOT NULL, clock TEXT NOT NULL, rated BOOLEAN NOT NULL CHECK (rated IN (0, 1)), private BOOLEAN NOT NULL CHECK (private IN (0, 1)), moves TEXT NOT NULL DEFAULT '', color_ticking INTEGER, clock_snapshot_time INTEGER, draw_offer_state INTEGER, conclusion_condition TEXT, conclusion_victor INTEGER, time_ended INTEGER, afk_resign_time INTEGER, delete_time INTEGER, position_pasted BOOLEAN NOT NULL DEFAULT 0 CHECK (position_pasted IN (0, 1)), validate_moves BOOLEAN NOT NULL DEFAULT 1 CHECK (validate_moves IN (0, 1)) ); `); // Live Player Games table — per-player state for active games db.run(` CREATE TABLE IF NOT EXISTS live_player_games ( game_id INTEGER NOT NULL REFERENCES live_games(game_id) ON DELETE CASCADE, player_number INTEGER NOT NULL, user_id INTEGER, browser_id TEXT NOT NULL, elo TEXT, last_draw_offer_ply INTEGER, time_remaining_ms INTEGER, disconnect_cushion_end_time INTEGER, disconnect_resign_time INTEGER, disconnect_by_choice INTEGER CHECK (disconnect_by_choice IN (0, 1)), PRIMARY KEY (game_id, player_number) ); `); db.run(`CREATE INDEX IF NOT EXISTS idx_live_player_games_game ON live_player_games (game_id);`); } // /** // * Deletes a table from the database by its name. // * @param tableName - The name of the table to delete. // */ // function deleteTable(tableName: string): void { // try { // // Prepare the SQL query to drop the table // const deleteTableSQL = `DROP TABLE IF EXISTS ${tableName};`; // // Run the query // db.run(deleteTableSQL); // console.log(`Table ${tableName} deleted successfully.`); // } catch (error) { // console.error(`Error deleting table ${tableName}:`, error); // } // } // deleteTable('test'); function initDatabase(): void { generateTables(); startPeriodicDatabaseCleanupTasks(); startPeriodicLeaderboardRatingDeviationUpdate(); startDailyBackups(); } /** Wipes all data from all tables. ONLY call in a test environment! */ function clearAllTables(): void { if (process.env['NODE_ENV'] !== 'test') { return console.error('CANNOT CLEAR DATABASE TABLES OUTSIDE OF TEST ENVIRONMENT!'); } // Get all table names dynamically const tables = db.all<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", ); // Disable foreign keys temporarily to avoid constraint errors (e.g. deleting Parent before Child) db.run('PRAGMA foreign_keys = OFF'); // Wrap deletions in a transaction for speed const wipeTransaction = db.transaction(() => { for (const table of tables) { db.run(`DELETE FROM ${table.name}`); } }); wipeTransaction(); // Re-enable foreign keys db.run('PRAGMA foreign_keys = ON'); } export { user_id_upper_cap, game_id_upper_cap, uniqueMemberKeys, allMemberColumns, allPlayerGamesColumns, allGamesColumns, allRatingAbuseColumns, initDatabase, generateTables, clearAllTables, }; ================================================ FILE: src/server/database/editorSavesManager.ts ================================================ // src/server/database/editorSavesManager.ts /** * This module manages saved positions in the editor_saves table. */ import type { RunResult } from 'better-sqlite3'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Types ------------------------------------------------------------------------------- /** Represents a saved position list record (name, piece_count, timestamp). */ type EditorSavesListRecord = { name: string; piece_count: number; timestamp: number; }; /** Represents a saved position ICN record (icn, pawn_double_push, castling, compression). */ type EditorSavesIcnRecord = { timestamp: number; compression: string; icn: string; /** -1 = Indeterminate tristate */ pawn_double_push: -1 | 0 | 1; /** -1 = Indeterminate tristate */ castling: -1 | 0 | 1; }; // Constants --------------------------------------------------------------------------------- /** Maximum number of saved positions allowed per user */ const MAX_SAVED_POSITIONS = 50; /** Error message for when the user's save quota is exceeded. */ const QUOTA_EXCEEDED_ERROR = 'QUOTA_EXCEEDED'; // Methods ----------------------------------------------------------------------------- /** * Retrieves all saved positions for a given user_id. * Returns only name, piece_count, and timestamp columns. * @param user_id - The user ID * @returns An array of saved positions. * @throws A database error occurred while managing editor saves. */ function getAllSavedPositionsForUser(user_id: number): EditorSavesListRecord[] { try { const query = `SELECT name, piece_count, timestamp FROM editor_saves WHERE user_id = ?`; return db.all(query, [user_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error retrieving saved positions for user_id ${user_id}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while managing editor saves.'); } } /** * Adds a new saved position to the editor_saves table, * enforcing the maximum saved positions quota per user. * If a position with the same name already exists, it will be overwritten. * @param user_id - The user ID who owns the position * @param name - The name of the saved position * @param piece_count - The client-provided piece count of the position * @param timestamp - The timestamp when the position was saved * @param icn - The ICN notation of the position * @param compression - The compression mode used for the ICN * @param pawn_double_push - Whether the pawn double push gamerule is enabled, or undefined if indeterminate * @param castling - Whether the castling gamerule is enabled, or undefined if indeterminate * @returns The RunResult. * @throws QUOTA_EXCEEDED if the user has reached the maximum saved positions, or a generic database error. */ function addSavedPosition( user_id: number, name: string, piece_count: number, timestamp: number, icn: string, compression: string, pawn_double_push?: boolean, castling?: boolean, ): RunResult { try { const transaction = db.transaction(() => { // Check if a position with the same name already exists const existingPosition = db.get<{ name: string }>( `SELECT name FROM editor_saves WHERE user_id = ? AND name = ?`, [user_id, name], ); // Get count within the transaction, only if it's a new position if (!existingPosition) { const countResult = db.get<{ count: number }>( `SELECT COUNT(*) as count FROM editor_saves WHERE user_id = ?`, [user_id], ); const currentCount = countResult?.count ?? 0; // Check quota if (currentCount >= MAX_SAVED_POSITIONS) { // Throw an error to roll back the transaction throw new Error(QUOTA_EXCEEDED_ERROR); } } // Insert the record (overwrites any existing one) const insertQuery = ` INSERT OR REPLACE INTO editor_saves (user_id, name, piece_count, timestamp, icn, compression, pawn_double_push, castling) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `; return db.run(insertQuery, [ user_id, name, piece_count, timestamp, icn, compression, // Encode tristate pawn_double_push === undefined ? -1 : pawn_double_push ? 1 : 0, castling === undefined ? -1 : castling ? 1 : 0, ]); }); return transaction(); } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); // Re-throw quota exceeded errors as-is (expected business logic failure) if (errMsg === QUOTA_EXCEEDED_ERROR) { throw error; } // Log and throw generic error for all other database errors logEventsAndPrint( `Error adding saved position for user_id ${user_id} with name "${name}": ${errMsg}`, 'errLog.txt', ); throw new Error('A database error occurred while managing editor saves.'); } } /** * Retrieves the ICN notation, pawn_double_push, and castling for a specific saved position by name and user_id. * @param name - The position name * @param user_id - The user ID who owns the position * @returns The ICN record if found and owned by the user, otherwise undefined. * @throws A database error occurred while managing editor saves. */ function getSavedPositionICN(name: string, user_id: number): EditorSavesIcnRecord | undefined { try { const query = `SELECT timestamp, icn, compression, pawn_double_push, castling FROM editor_saves WHERE name = ? AND user_id = ?`; return db.get(query, [name, user_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error retrieving ICN for name "${name}" and user_id ${user_id}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while managing editor saves.'); } } /** * Deletes a saved position by name and user_id. * Will fail to delete if the user_id doesn't match the position owner. * @param name - The position name * @param user_id - The user ID who owns the position * @returns The RunResult containing the number of changes. * @throws A database error occurred while managing editor saves. */ function deleteSavedPosition(name: string, user_id: number): RunResult { try { const query = `DELETE FROM editor_saves WHERE name = ? AND user_id = ?`; return db.run(query, [name, user_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error deleting position "${name}" for user_id ${user_id}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while managing editor saves.'); } } export default { // Constants MAX_SAVED_POSITIONS, QUOTA_EXCEEDED_ERROR, // Methods getAllSavedPositionsForUser, addSavedPosition, getSavedPositionICN, deleteSavedPosition, }; ================================================ FILE: src/server/database/gamesManager.ts ================================================ // src/server/database/gamesManager.ts /** * This script handles queries to the games table. */ import jsutil from '../../shared/util/jsutil.js'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed import { allGamesColumns, game_id_upper_cap } from './databaseTables.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete games record. */ export interface GamesRecord { game_id: number; date: string; base_time_seconds: number | null; increment_seconds: number | null; variant: string; /** 0 => false 1 => true */ rated: 0 | 1; leaderboard_id: number | null; /** 0 => false 1 => true */ private: 0 | 1; result: string; termination: string; move_count: number; time_duration_millis: number | null; icn: string; } type GamesColumn = keyof GamesRecord; // Methods -------------------------------------------------------------------------------------------- /** * Generates a game_id **UNIQUE** to all other game ids in the games table. * @returns - A unique game_id. */ function genUniqueGameID(): number { let id: number; do { id = generateRandomGameId(); } while (isGameIdTaken(id)); return id; } /** * Generates a random game_id. DOES NOT test if it's taken already. * @returns - A random game_id. */ function generateRandomGameId(): number { // Generate a random number between 0 and game_id_upper_cap return Math.floor(Math.random() * game_id_upper_cap); } /** * Checks if a given game_id exists in the games table. * @param game_id - The game_id to check. * @returns - Returns true if the game_id exists, false otherwise. */ function isGameIdTaken(game_id: number): boolean { try { const query = 'SELECT 1 FROM games WHERE game_id = ?'; // Execute query to check if the game_id exists in the games table const row = db.get<{ '1': number }>(query, [game_id]); // If a row is found, the game_id exists return row !== undefined; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error if the query fails logEventsAndPrint( `Error checking if game_id "${game_id}" is taken: ${message}`, 'errLog.txt', ); return false; // Return false if an error occurs } } /** * Fetches specified columns of a single game from the games table based on game_id * @param game_id - The game_id of the game * @param columns - The columns to retrieve (e.g., ['game_id', 'date', 'rated']). * @returns An object containing the requested columns, or undefined if no match is found. */ function getGameData( game_id: number, columns: K[], ): Pick | undefined { // Guard clauses... Validating the arguments... if (!Array.isArray(columns)) { logEventsAndPrint( `When getting game data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } if ( !columns.every((column) => typeof column === 'string' && allGamesColumns.includes(column)) ) { logEventsAndPrint( `Invalid columns requested from games table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } // Arguments are valid, move onto the SQL query... // Construct SQL query const query = `SELECT ${columns.join(', ')} FROM games WHERE game_id = ?`; try { // Execute the query and fetch result const row = db.get>(query, [game_id]); // If no row is found, return undefined if (!row) { logEventsAndPrint( `No matches found in games table for game_id = ${game_id}.`, 'errLog.txt', ); return undefined; } // Return the fetched row (single object) return row; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error and return undefined logEventsAndPrint( `Error executing query when getting game data of game_id ${game_id}: ${message}. The query: "${query}"`, 'errLog.txt', ); return undefined; } } /** * Fetches specified columns of multiple games from the games table based on list of game_ids * @param game_id_list - A list of game_ids * @param columns - The columns to retrieve (e.g., ['game_id', 'date', 'rated']). * @returns An array of objects with the requested columns, or undefined if no matches found. */ function getMultipleGameData( game_id_list: number[], columns: K[], ): Pick[] | undefined { // Guard clauses... Validating the arguments... if (!Array.isArray(columns)) { logEventsAndPrint( `When getting game data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } if ( !columns.every((column) => typeof column === 'string' && allGamesColumns.includes(column)) ) { logEventsAndPrint( `Invalid columns requested from games table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } // Arguments are valid, move onto the SQL query... // Construct SQL query const placeholders = game_id_list.map(() => '?').join(', '); const query = `SELECT ${columns.join(', ')} FROM games WHERE game_id IN (${placeholders})`; try { // Execute the query and fetch result const rows = db.all>(query, game_id_list); // If no rows found, return undefined if (!rows || rows.length === 0) { logEventsAndPrint( `No matches found in games table for game_ids: ${jsutil.ensureJSONString(game_id_list)}.`, 'errLog.txt', ); return undefined; } // Return the fetched rows (single object) return rows; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error and return undefined logEventsAndPrint( `Error executing query for game_ids ${jsutil.ensureJSONString(game_id_list)}: ${message}. Query: "${query}"`, 'errLog.txt', ); return undefined; } } // Exports -------------------------------------------------------------------------------------------- export { genUniqueGameID, getGameData, getMultipleGameData }; ================================================ FILE: src/server/database/leaderboardsManager.ts ================================================ // src/server/database/leaderboardsManager.ts /** * This script handles queries to the leaderboards table. */ import type { RunResult } from 'better-sqlite3'; // Import necessary types import type { Rating } from '../../shared/types.js'; import type { Leaderboard } from '../../shared/chess/variants/validleaderboard.js'; import db from './database.js'; import { getTrueRD } from '../game/gamemanager/ratingcalculation.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed import { DEFAULT_LEADERBOARD_ELO, UNCERTAIN_LEADERBOARD_RD, RD_UPDATE_FREQUENCY, } from '../game/gamemanager/ratingcalculation.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete leaderboard entry record. */ interface LeaderboardEntry { user_id: number; leaderboard_id: number; elo: number; rating_deviation: number; rd_last_update_date: string | null; // Can be null if no games played yet // Consider adding volatility if you use it in Glicko-2 } // Methods -------------------------------------------------------------------------------------------- /** * The core logic for adding a user to a leaderboard. * This function is "unsafe" as it throws errors on failure, making it * suitable for use inside a database transaction. * @throws {SqliteError} If the database query fails. The error's `code` property * can be checked for specific constraints like 'SQLITE_CONSTRAINT_PRIMARYKEY'. */ function addUserToLeaderboard( user_id: number, leaderboard_id: Leaderboard, elo: number, rd: number, ): RunResult { const query = ` INSERT INTO leaderboards ( user_id, leaderboard_id, elo, rating_deviation -- rd_last_update_date will be NULL by default ) VALUES (?, ?, ?, ?) `; // This will throw on failure, which is what we want for a transaction. return db.run(query, [user_id, leaderboard_id, elo, rd]); } /** * Updates the rating values for a player on a specific leaderboard. * This function throws errors on failure, making it suitable for use * inside a database transaction which can catch the error and roll back. * Callers outside of transactions should implement their own error handling. * @throws {Error} If the user is not found or if the database query fails. */ function updatePlayerLeaderboardRating( user_id: number, leaderboard_id: Leaderboard, elo: number, rd: number, ): RunResult { const query = ` UPDATE leaderboards SET elo = ?, rating_deviation = ?, rd_last_update_date = CURRENT_TIMESTAMP -- Automatically update timestamp on rating change WHERE user_id = ? AND leaderboard_id = ? `; const result = db.run(query, [elo, rd, user_id, leaderboard_id]); // If the UPDATE affected no rows, it's a critical failure for a transaction. // We must throw an error to trigger a rollback. if (result.changes === 0) { throw new Error( `User with ID "${user_id}" not found on leaderboard "${leaderboard_id}" for update.`, ); } return result; } /** * Checks if a player exists on a specific leaderboard. * Relies on the composite primary key (user_id, leaderboard_id). * @param user_id - The ID of the user to check. * @param leaderboard_id - The ID of the leaderboard to check within. * @returns True if the player exists on the specified leaderboard, false otherwise (including on error). */ function isPlayerInLeaderboard(user_id: number, leaderboard_id: Leaderboard): boolean { // Query to select a constant '1' if a matching row exists. // LIMIT 1 ensures the database can stop searching after finding the first match. // This is efficient, especially with the primary key index. const query = ` SELECT 1 FROM leaderboards WHERE user_id = ? AND leaderboard_id = ? LIMIT 1; `; try { const result = db.get<{ '1': 1 }>(query, [user_id, leaderboard_id]); // If db.get returns anything (even an object like { '1': 1 }), it means a row was found. // If no row is found, db.get returns undefined. // The double negation (!!) converts a truthy value (the result object) to true, // and a falsy value (undefined) to false. return !!result; } catch (error: unknown) { // Log any potential database errors during the check const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error checking existence of user "${user_id}" on leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); // On error, we cannot confirm existence, so return false. return false; } } /** The return type of {@link getPlayerLeaderboardRating} */ type PlayerLeaderboardRating = { elo: number; rating_deviation: number; rd_last_update_date: string | null; // Can be null if no games played yet }; /** * The core logic for getting a player's rating. Throws on failure. * @throws {SqliteError} If the database query fails. */ function getPlayerLeaderboardRating_core( user_id: number, leaderboard_id: Leaderboard, ): PlayerLeaderboardRating | undefined { const query = ` SELECT elo, rating_deviation, rd_last_update_date FROM leaderboards WHERE user_id = ? AND leaderboard_id = ? `; // This will throw an error if the query fails. return db.get(query, [user_id, leaderboard_id]); } /** * Safely gets the rating values for a player on a specific leaderboard. * This wraps the core logic in a try/catch block to prevent crashes. * @returns The player's leaderboard entry object or undefined if not found or on error. */ function getPlayerLeaderboardRating( user_id: number, leaderboard_id: Leaderboard, ): PlayerLeaderboardRating | undefined { try { return getPlayerLeaderboardRating_core(user_id, leaderboard_id); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error for debugging purposes logEventsAndPrint( `Error getting leaderboard rating data for member "${user_id}" on leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); return undefined; } } /** * Gets all leaderboard entries for a specific user. * @param user_id - The id for the user * @returns An array of the user's leaderboard entries across all leaderboards, potentially empty. */ function _getAllUserLeaderboardEntries(user_id: number): LeaderboardEntry[] { // New function leveraging the idx_leaderboards_user index const query = ` SELECT leaderboard_id, elo, rating_deviation, rd_last_update_date FROM leaderboards WHERE user_id = ? ORDER BY leaderboard_id ASC -- Optional: order for consistency `; try { const entries = db.all(query, [user_id]) as LeaderboardEntry[]; return entries; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error getting all leaderboard entries for user "${user_id}": ${message}`, 'errLog.txt', ); return []; // Return an empty array on error } } /** * Gets the top N players for a specific leaderboard by elo, starting from a given rank. * @param leaderboard_id - The id for the specific leaderboard. * @param start_rank - The 1-based rank to start from (e.g. 1 = top player, 2 = second-best, etc.) * @param n_players - The maximum number of players to retrieve, starting from start_rank * @returns An array of top player leaderboard entries, potentially empty. */ function getTopPlayersForLeaderboard( leaderboard_id: Leaderboard, start_rank: number, n_players: number, ): LeaderboardEntry[] { // Changed table name, column names, ORDER BY column, added WHERE clause for leaderboard_id const offset = Math.max(0, start_rank - 1); // SQL OFFSET is 0-based const query = ` SELECT user_id, elo, rating_deviation, rd_last_update_date FROM leaderboards WHERE leaderboard_id = ? AND rating_deviation <= ? -- Disregard any members with a too high RD ORDER BY elo DESC LIMIT ? OFFSET ? `; try { // Execute the query with leaderboard_id, n_players and offset parameters // Added leaderboard_id to parameters const top_players = db.all(query, [ leaderboard_id, UNCERTAIN_LEADERBOARD_RD, n_players, offset, ]) as LeaderboardEntry[]; return top_players; // Returns an array (potentially empty) } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Updated log message logEventsAndPrint( `Error getting top "${n_players}" players starting at rank "${start_rank}" for leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); return []; // Return an empty array on error } } /** * Gets the rank (position) of a specific user within a specific leaderboard based on Elo. * Rank 1 is the highest Elo. Uses RANK() to handle ties (tied players share the same rank, * but the next rank number is skipped, creating potential gaps, e.g., 1, 1, 3). * @param user_id - The ID of the user whose rank is needed. * @param leaderboard_id - The ID of the leaderboard to check. * @returns The user's rank (1-based) as a number, or undefined if the user is not found * on that leaderboard or if an error occurs. */ function getPlayerRankInLeaderboard( user_id: number, leaderboard_id: Leaderboard, ): number | undefined { // This query uses a Common Table Expression (CTE) and the RANK window function. // 1. Filter `leaderboards` to only include rows for the specific `leaderboard_id`. // 2. Calculate `RANK() OVER (ORDER BY elo DESC)`. // RANK assigns the same rank to ties, but skips subsequent ranks // (e.g., if 2 players tie for 1st, the next rank is 3). // 3. Select the calculated `rank` for the specific `user_id`. const query = ` WITH RankedPlayers AS ( SELECT user_id, RANK() OVER (ORDER BY elo DESC) as rank FROM leaderboards WHERE leaderboard_id = ? -- Filter for the specific leaderboard FIRST AND (rating_deviation <= ? OR user_id = ?) -- Disregard any other users with a too high RD ) SELECT rank FROM RankedPlayers WHERE user_id = ?; -- Then find the rank for the specific user `; try { // Execute the query, expecting at most one row containing the rank const result = db.get<{ rank: number }>(query, [ leaderboard_id, UNCERTAIN_LEADERBOARD_RD, user_id, user_id, ]); // If a result is found, return the rank, otherwise return undefined return result?.rank; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log message remains appropriate logEventsAndPrint( `Error getting rank for user "${user_id}" on leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); return undefined; // Return undefined on error } } // Helper Functions ---------------------------------------------------------------------------------- /** * Returns the elo of a player on a specific leaderboard, or their elo if they were * to join it now, and whether we are confident about it. * @param user_id - The id for the user * @param leaderboard_id - The id for the specific leaderboard. * @returns The player's leaderboard elo and whether we are confident about it. */ function getEloOfPlayerInLeaderboard(user_id: number, leaderboard_id: Leaderboard): Rating { const rating_values = getPlayerLeaderboardRating(user_id, leaderboard_id); // { user_id, elo, rating_deviation, rd_last_update_date } | undefined if (!rating_values) return { value: DEFAULT_LEADERBOARD_ELO, confident: false }; // No rating, return un-confident default elo const confident = rating_values.rating_deviation <= UNCERTAIN_LEADERBOARD_RD; return { value: rating_values.elo, confident }; } // Regular Table Utility Functions ------------------------------------------------------------------- /** Calls updateAllRatingDeviationsofLeaderboardTable() every {@link RD_UPDATE_FREQUENCY} milliseconds */ function startPeriodicLeaderboardRatingDeviationUpdate(): void { setInterval(updateAllRatingDeviationsofLeaderboardTable, RD_UPDATE_FREQUENCY); } /** Retrieves all entries of the leaderboards table and updates their RD */ function updateAllRatingDeviationsofLeaderboardTable(): void { const query = `SELECT * FROM leaderboards`; try { const entries = db.all(query); for (const entry of entries) { const updatedRD = getTrueRD(entry.rating_deviation, entry.rd_last_update_date); updatePlayerLeaderboardRating( entry.user_id, entry.leaderboard_id as Leaderboard, entry.elo, updatedRD, ); } logEventsAndPrint( `Updated all rating deviations in leaderboard table.`, 'leaderboardLog.txt', ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating all rating deviations in leaderboard table: ${message}`, 'errLog.txt', ); } } // Exports -------------------------------------------------------------------------------------------- export { addUserToLeaderboard, updatePlayerLeaderboardRating, isPlayerInLeaderboard, getPlayerLeaderboardRating, getPlayerLeaderboardRating_core, getTopPlayersForLeaderboard, getPlayerRankInLeaderboard, getEloOfPlayerInLeaderboard, startPeriodicLeaderboardRatingDeviationUpdate, }; ================================================ FILE: src/server/database/liveGamesManager.ts ================================================ // src/server/database/liveGamesManager.ts /** * This script manages the live_games table, which persists active game state * across server restarts. One row per active game. */ import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete live_games record. */ export interface LiveGamesRecord extends LiveGameData { game_id: number; } /** Live game data columns, excluding the primary key. */ export interface LiveGameData { time_created: number; variant: string; clock: string; /** 0 = casual, 1 = rated */ rated: 0 | 1; /** 0 = public, 1 = private */ private: 0 | 1; moves: string; color_ticking: number | null; clock_snapshot_time: number | null; draw_offer_state: number | null; conclusion_condition: string | null; conclusion_victor: number | null; time_ended: number | null; afk_resign_time: number | null; delete_time: number | null; /** 0 = false, 1 = true */ position_pasted: 0 | 1; /** 0 = false, 1 = true */ validate_moves: 0 | 1; } // SQL Queries --------------------------------------------------------------------------------------- const INSERT_QUERY = ` INSERT INTO live_games ( game_id, time_created, variant, clock, rated, private, moves, color_ticking, clock_snapshot_time, draw_offer_state, conclusion_condition, conclusion_victor, time_ended, afk_resign_time, delete_time, position_pasted, validate_moves ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const DELETE_QUERY = `DELETE FROM live_games WHERE game_id = ?`; const SELECT_ALL_QUERY = `SELECT * FROM live_games`; // Methods -------------------------------------------------------------------------------------------- /** * Inserts a new live game row into the database. * @param record - The complete live_games record to insert. */ function insertLiveGame(record: LiveGamesRecord): void { try { db.run(INSERT_QUERY, [ record.game_id, record.time_created, record.variant, record.clock, record.rated, record.private, record.moves, record.color_ticking, record.clock_snapshot_time, record.draw_offer_state, record.conclusion_condition, record.conclusion_victor, record.time_ended, record.afk_resign_time, record.delete_time, record.position_pasted, record.validate_moves, ]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error inserting live game ${record.game_id}: ${message}`, 'errLog.txt'); } } /** * Updates specific columns of a live game. * @param game_id - The game to update. * @param updates - An object containing only the columns to update and their new values. */ function updateLiveGame(game_id: number, updates: Partial): void { const entries = Object.entries(updates); if (entries.length === 0) return; const setClauses = entries.map(([col]) => `${col} = ?`).join(', '); const values = entries.map(([, val]) => val); const query = `UPDATE live_games SET ${setClauses} WHERE game_id = ?`; try { db.run(query, [...values, game_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error updating live game ${game_id}: ${message}`, 'errLog.txt'); } } /** * Deletes a live game row (cascades to live_player_games). * @param game_id - The game to delete. */ function deleteLiveGame(game_id: number): void { try { db.run(DELETE_QUERY, [game_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error deleting live game ${game_id}: ${message}`, 'errLog.txt'); } } /** * Retrieves all live game rows. Used on server startup to restore games. * @returns An array of all live_games records. */ function getAllLiveGames(): LiveGamesRecord[] { try { return db.all(SELECT_ALL_QUERY); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error retrieving all live games: ${message}`, 'errLog.txt'); return []; } } // Exports -------------------------------------------------------------------------------------------- export { insertLiveGame, updateLiveGame, deleteLiveGame, getAllLiveGames }; ================================================ FILE: src/server/database/livePlayerGamesManager.ts ================================================ // src/server/database/livePlayerGamesManager.ts /** * This script manages the live_player_games table, which persists per-player * state for active games across server restarts. One row per player per game. */ import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete live_player_games record. */ export interface LivePlayerGamesRecord extends LivePlayerData { game_id: number; player_number: number; } /** Per-player live game data columns, excluding the composite key fields. */ export interface LivePlayerData extends LivePlayerDisconnectData { user_id: number | null; browser_id: string; elo: string | null; last_draw_offer_ply: number | null; time_remaining_ms: number | null; } /** Disconnect-state columns shared by live_player_games rows. */ export interface LivePlayerDisconnectData { disconnect_cushion_end_time: number | null; disconnect_resign_time: number | null; /** 0 = network interruption (60s), 1 = intentional (20s). NULL if connected. */ disconnect_by_choice: 0 | 1 | null; } // SQL Queries --------------------------------------------------------------------------------------- const INSERT_QUERY = ` INSERT INTO live_player_games ( game_id, player_number, user_id, browser_id, elo, last_draw_offer_ply, time_remaining_ms, disconnect_cushion_end_time, disconnect_resign_time, disconnect_by_choice ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const SELECT_BY_GAME_QUERY = `SELECT * FROM live_player_games WHERE game_id = ? ORDER BY player_number`; // Methods -------------------------------------------------------------------------------------------- /** * Inserts a new live player game row into the database. * @param record - The complete live_player_games record to insert. */ function insertLivePlayerGame(record: LivePlayerGamesRecord): void { try { db.run(INSERT_QUERY, [ record.game_id, record.player_number, record.user_id, record.browser_id, record.elo, record.last_draw_offer_ply, record.time_remaining_ms, record.disconnect_cushion_end_time, record.disconnect_resign_time, record.disconnect_by_choice, ]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error inserting live player game (game_id=${record.game_id}, player=${record.player_number}): ${message}`, 'errLog.txt', ); } } /** * Updates specific columns of a player's live game record. * @param game_id - The game ID. * @param player_number - The player number to update. * @param updates - An object containing only the columns to update and their new values. */ function updateLivePlayerGame( game_id: number, player_number: number, updates: Partial, ): void { const entries = Object.entries(updates); if (entries.length === 0) return; const setClauses = entries.map(([col]) => `${col} = ?`).join(', '); const values = entries.map(([, val]) => val ?? null); const query = `UPDATE live_player_games SET ${setClauses} WHERE game_id = ? AND player_number = ?`; try { db.run(query, [...values, game_id, player_number]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating live player game (game_id=${game_id}, player=${player_number}): ${message}`, 'errLog.txt', ); } } /** * Retrieves all player rows for a given live game. Used on server startup. * @param game_id - The game ID. * @returns An array of live_player_games records for this game. */ function getLivePlayerGamesForGame(game_id: number): LivePlayerGamesRecord[] { try { return db.all(SELECT_BY_GAME_QUERY, [game_id]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error retrieving live player games for game ${game_id}: ${message}`, 'errLog.txt', ); return []; } } // Exports -------------------------------------------------------------------------------------------- export { insertLivePlayerGame, updateLivePlayerGame, getLivePlayerGamesForGame }; ================================================ FILE: src/server/database/memberManager.ts ================================================ // src/server/database/memberManager.ts /** * This script handles almost all of the queries we use to interact with the members table! */ import type { DeleteReason } from '../controllers/deleteAccountController.js'; import { SqliteError } from 'better-sqlite3'; import jsutil from '../../shared/util/jsutil.js'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { allMemberColumns, uniqueMemberKeys, user_id_upper_cap } from './databaseTables.js'; // Types --------------------------------------------------------------------- /** Structure of a complete member record. */ export interface MemberRecord { user_id: number; username: string; email: string; hashed_password: string; roles: string | null; joined: string; last_seen: string; login_count: number; is_verified: 0 | 1; verification_code: string | null; is_verification_notified: 0 | 1; preferences: string | null; username_history: string | null; checkmates_beaten: string; last_read_news_date: string | null; } type MembersColumn = keyof MemberRecord; // Constants ---------------------------------------------------------- /** SQLite constraint error code constant */ const SQLITE_CONSTRAINT_ERROR = 'SQLITE_CONSTRAINT'; /** Custom error message for user not found during deletion */ const USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND'; // Create / Delete Member methods --------------------------------------------------------------------------------------- /** * Creates a new account. This is the single, authoritative function for user creation. * It atomically inserts records into both the `members` and `player_stats` tables * within a single database transaction, ensuring data integrity. * @param username The user's username. * @param email The user's email. * @param hashedPassword The user's hashed password. * @param is_verified The verification status. * @param verification_code The unique code for verification, if they are not yet verified. * @param is_verification_notified The verified notification status. * @returns The user_id of the newly created user. * * @throws If the insertion fails (e.g., due to constraint violation or other unexpected error). */ function addUser( username: string, email: string, hashedPassword: string, is_verified: 0 | 1, verification_code: string | null, is_verification_notified: 0 | 1, ): number { // prettier-ignore const createAccountTransaction = db.transaction<[{ username: string; email: string; hashedPassword: string; is_verified: 0 | 1; verification_code: string | null; is_verification_notified: 0 | 1 }], number>((userData) => { // Step 1: Generate a unique user ID. const userId = genUniqueUserID(); // Step 2: Set initial last_read_news_date to current date so new users don't see all news as unread const currentDate = new Date().toISOString().split('T')[0]!; // 'YYYY-MM-DDThh:mm:ss.sssZ' -> 'YYYY-MM-DD' // Step 3: Insert into the members table. const membersQuery = ` INSERT INTO members ( user_id, username, email, hashed_password, is_verified, verification_code, is_verification_notified, last_read_news_date ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `; const params = [ userId, userData.username, userData.email, userData.hashedPassword, userData.is_verified, userData.verification_code, userData.is_verification_notified, currentDate, ]; db.run(membersQuery, params); // Step 4: Insert into the 'player_stats' table. const statsQuery = `INSERT INTO player_stats (user_id) VALUES (?)`; db.run(statsQuery, [userId]); // If both inserts succeed, the transaction will commit and return the new user_id. return userId; }); try { return createAccountTransaction({ username, email, hashedPassword, is_verified, verification_code, is_verification_notified, }); } catch (error: unknown) { const detailedError = error instanceof SqliteError ? error.message : String(error); logEventsAndPrint( `Account creation transaction for "${username}" failed and was rolled back: ${detailedError}`, 'errLog.txt', ); let genericError: string = 'A database error occurred.'; // Generic error message to avoid leaking details if (error instanceof SqliteError && error.code === SQLITE_CONSTRAINT_ERROR) genericError = SQLITE_CONSTRAINT_ERROR; throw Error(genericError); // Rethrow with the generic error message, or specific constraint error } } // setTimeout(() => { console.log(addUser('na3v534', 'tes3t5em3a4il3', 'password', null)); }, 1000); // Set timeout needed so user_id_upper_cap is initialized before this function is called. /** * Deletes a user from the members table and adds them to the deleted_members table. * @param user_id - The ID of the user to delete. * @param reason_deleted - The reason the user is being deleted. * @returns A result object: { success: true } on success, or { success: false, reason: string } on failure. * * @throws If a database error occurs during the deletion process. */ function deleteUser(user_id: number, reason_deleted: DeleteReason): void { // Create a transaction function. better-sqlite3 will wrap the execution // of this function in BEGIN/COMMIT/ROLLBACK statements. const deleteTransaction = db.transaction<[number, string], void>((id, reason) => { // Step 1: Delete the user from the main 'members' table const deleteQuery = 'DELETE FROM members WHERE user_id = ?'; const deleteResult = db.run(deleteQuery, [id]); // If no user was deleted, they didn't exist. Throw an error to // abort the transaction and prevent any further action. if (deleteResult.changes === 0) throw new Error(USER_NOT_FOUND_ERROR); // Step 2: Add their user_id to the 'deleted_members' table // If this fails (e.g., UNIQUE constraint), it will also throw an error // and cause the entire transaction (including the DELETE) to roll back. const insertQuery = 'INSERT INTO deleted_members (user_id, reason_deleted) VALUES (?, ?)'; db.run(insertQuery, [id, reason]); }); try { // Execute the transaction deleteTransaction(user_id, reason_deleted); } catch (error: unknown) { // The transaction was rolled back due to an error inside it. const errorMessage = error instanceof Error ? error.message : String(error); // Detailed error for logging let detailedError = `Delete user transaction for ID (${user_id}) for reason (${reason_deleted}) failed and was rolled back: ${errorMessage}`; // Handle any other unexpected database errors (like UNIQUE constraint) if (error instanceof SqliteError && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { detailedError = `Delete user transaction for ID (${user_id}) for reason (${reason_deleted}) failed and was rolled back because they already exist in the deleted_members tables, but the user was not deleted from the members table.`; } logEventsAndPrint(detailedError, 'errLog.txt'); // Generic error message for return value let genericError = 'A database error occurred.'; // Handle our custom "user not found" error if (error instanceof Error && error.message === USER_NOT_FOUND_ERROR) genericError = USER_NOT_FOUND_ERROR; throw Error(genericError); // Rethrow with the generic error message } } // console.log(deleteUser(3887110, 'security')); // General SELECT/UPDATE methods --------------------------------------------------------------------------------------- /** * Helper for validating the common arguments used for querying member data. * @param columns - The list of columns to retrieve (e.g., ['checkmates_beaten']). * @param searchKey - The database column to search by (e.g., 'username'). * @param searchValues - An array of values to search for (e.g., ['user1', 'user2']). * @throws Error if any validation fails. */ function validateMemberQueryArgs( columns: string[], searchKey: string, searchValues: (string | number)[], ): void { // 1. Validate Columns if ( !Array.isArray(columns) || columns.length === 0 || !columns.every((column) => typeof column === 'string' && allMemberColumns.includes(column)) ) { logEventsAndPrint( `Invalid columns requested from members table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); throw new Error('Invalid columns parameter.'); } // 2. Validate Search Key if (typeof searchKey !== 'string' || !uniqueMemberKeys.includes(searchKey)) { logEventsAndPrint( `Invalid search key for members table "${searchKey}". Must be one of: ${uniqueMemberKeys.join(', ')}`, 'errLog.txt', ); throw new Error('Invalid search key.'); } // 3. Validate Search Values if ( !Array.isArray(searchValues) || searchValues.length === 0 || !searchValues.every((value) => typeof value === 'string' || typeof value === 'number') ) { logEventsAndPrint( `Invalid search values for members table: ${jsutil.ensureJSONString(searchValues)}`, 'errLog.txt', ); throw new Error('Invalid search values.'); } } /** * Fetches specified columns of a single member from the database based on user_id, username, or email. * @param columns - The columns to retrieve (e.g., ['checkmates_beaten']). * @param searchKey - The search key to use. (e.g. 'username') * @param searchValue - The value to search for (e.g. 'user123'). * @returns An object containing the requested columns, or undefined if no match is found. * @throws If invalid parameters are provided, or if a database error occurs during the query. */ function getMemberDataByCriteria( columns: K[], searchKey: MembersColumn, searchValue: string | number, ): Pick | undefined { // Runtime validation validateMemberQueryArgs(columns, searchKey, [searchValue]); const query = `SELECT ${columns.join(', ')} FROM members WHERE ${searchKey} = ?`; try { // Execute the query and fetch result return db.get>(query, [searchValue]); } catch (error: unknown) { // Log the error and rethrow a generic error const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error getting member data by criteria: ${message}`, 'errLog.txt'); throw new Error('A database error occured.'); } } /** * Fetches specified columns of multiple members from the database based on a list of user_ids, usernames, or emails. * @param columns - The columns to retrieve (e.g., ['user_id', 'username', 'roles']). * @param searchKey - The search key to use (e.g., 'checkmates_beaten'). * @param searchValueList - The value to search for, can be a list of user IDs, usernames, or emails. * @returns An array of member records. * @throws If invalid parameters are provided, or if a database error occurs during the query. */ function getMultipleMemberDataByCriteria( columns: K[], searchKey: MembersColumn, searchValueList: string[] | number[], ): Pick[] { // Runtime validation validateMemberQueryArgs(columns, searchKey, searchValueList); // Construct SQL query const placeholders = searchValueList.map(() => '?').join(', '); const query = ` SELECT ${columns.join(', ')} FROM members WHERE ${searchKey} IN (${placeholders}) `; try { // Execute the query and fetch result return db.all>(query, searchValueList); } catch (error: unknown) { // Log the error and rethrow a generic error const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error getting MULTIPLE member data by criteria: ${message}`, 'errLog.txt', ); throw new Error('A database error occured.'); } } /** * Updates specified columns for a member based on their user ID. * @param user_id - The user ID of the member to update. * @param columnsAndValues - An object mapping column names to their new values. * @returns A result object indicating if a change was made, which if not, may indicate the user_id does not exist. * @throws If invalid parameters are provided, or if a database error occurs. */ function updateMemberColumns( user_id: number, columnsAndValues: Partial, ): { changeMade: boolean } { // Validate that we have columns to update if (typeof columnsAndValues !== 'object' || columnsAndValues === null) { logEventsAndPrint( `Invalid columnsAndValues provided when updating member of ID "${user_id}": ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); throw new Error('Invalid update parameters.'); } const columns = Object.keys(columnsAndValues); const values = Object.values(columnsAndValues); // Validate they are all valid database columns if ( columns.length === 0 || !columns.every((col) => allMemberColumns.includes(col)) || !values.every((val) => typeof val === 'string' || typeof val === 'number' || val === null) ) { logEventsAndPrint( `Invalid columns or values provided when updating member of ID "${user_id}": ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); throw new Error('Invalid update parameters.'); } // Dynamically build the SET part of the query const setStatements = columns.map((column) => `${column} = ?`).join(', '); const query = `UPDATE members SET ${setStatements} WHERE user_id = ?`; try { // Execute the update query, appending user_id as the last parameter const result = db.run(query, [...values, user_id]); return { changeMade: result.changes > 0 }; } catch (error: unknown) { // Log the error and rethrow a generic error const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating columns ${jsutil.ensureJSONString(columnsAndValues)} for user ID "${user_id}": ${message}`, 'errLog.txt', ); throw new Error('A database error occurred.'); } } // Login Count & Last Seen --------------------------------------------------------------------------------------- /** * Increments the login count and updates the last_seen column for a member based on their user ID. * @param userId - The user ID of the member. */ function updateLoginCountAndLastSeen(userId: number): void { // SQL query to update the login_count and last_seen fields const query = ` UPDATE members SET login_count = login_count + 1, last_seen = CURRENT_TIMESTAMP WHERE user_id = ? `; try { // Execute the query with the provided userId const result = db.run(query, [userId]); // Log if no changes were made if (result.changes === 0) logEventsAndPrint( `No changes made when updating login_count and last_seen for member of id "${userId}"!`, 'errLog.txt', ); } catch (error: unknown) { // Log the error for debugging purposes const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating login_count and last_seen for member of id "${userId}": ${message}`, 'errLog.txt', ); } } /** * Updates the last_seen column for a member based on their user ID. * @param userId - The user ID of the member. */ function updateLastSeen(userId: number): void { // SQL query to update the last_seen field const query = ` UPDATE members SET last_seen = CURRENT_TIMESTAMP WHERE user_id = ? `; try { // Execute the query with the provided userId const result = db.run(query, [userId]); // Log if no changes were made if (result.changes === 0) logEventsAndPrint( `No changes made when updating last_seen for member of id "${userId}"!`, 'errLog.txt', ); } catch (error: unknown) { // Log the error for debugging purposes const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error updating last_seen for member of id "${userId}": ${message}`, 'errLog.txt', ); } } // Utility ----------------------------------------------------------------------------------- /** * Generates a unique user_id that no other member has ever used. * @throws If a database error occurs during uniqueness checks. */ function genUniqueUserID(): number { let id: number; do { id = Math.floor(Math.random() * user_id_upper_cap); } while (isUserIdTaken(id)); return id; } /** * Checks if a member of a given id exists in the members table. * IGNORES whether the deleted_members table may contain the user_id. * @param user_id - The user ID to check. * @returns Returns true if the member exists, false otherwise. * * @throws If a database error occurs during the check. */ function doesMemberOfIDExist(user_id: number): boolean { try { const query = 'SELECT EXISTS(SELECT 1 FROM members WHERE user_id = ?) AS found'; // Execute query to check if the user_id exists in the members table const row = db.get<{ found: 0 | 1 }>(query, [user_id]); // row.found will be 0 or 1 return Boolean(row?.found); } catch (error: unknown) { // Log the error if the query fails const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error checking if member of user_id (${user_id}) exists: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred.'); // Rethrow generic error } } /** * Checks if a given user_id exists in the members table OR deleted_members table. * @param userId - The user ID to check. * @returns Returns true if the user_id has been used, false otherwise. * * @throws If a database error occurs during the check. */ function isUserIdTaken(userId: number): boolean { try { const query = ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = ?) OR EXISTS(SELECT 1 FROM deleted_members WHERE user_id = ?) AS found `; // Execute query to check if the user_id exists in the members table const row = db.get<{ found: 0 | 1 }>(query, [userId, userId]); // row.found will be 0 or 1 return Boolean(row?.found); } catch (error: unknown) { // Log the error if the query fails const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error checking if user_id (${userId}) has been used: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred.'); // Rethrow generic error } } // console.log("taken? " + isUserIdTaken(14443702)); /** * Checks if a member with the given username exists in the members table (case-insensitive, * a username is taken even if it has the same spelling but different capitalization). * @param username - The username to check. * @returns Returns true if the username exists, false otherwise. */ function isUsernameTaken(username: string): boolean { // SQL query to check if a username exists in the 'members' table const query = 'SELECT 1 FROM members WHERE username = ?'; try { // Execute the query with the username parameter const row = db.get<{ '1': 1 }>(query, [username]); // If a row is found, the username exists return row !== undefined; } catch (error: unknown) { // Log the error for debugging purposes const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error checking if username "${username}" is taken: ${message}`, 'errLog.txt', ); // Return false if there's an error (indicating the username is not found) return false; } } /** * Checks if a member with the given email exists in the members table. * @param email - The email to check, in LOWERCASE. * @returns Returns true if the email exists, false otherwise. */ function isEmailTaken(email: string): boolean { // SQL query to check if an email exists in the 'members' table const query = 'SELECT 1 FROM members WHERE email = ?'; try { // Execute the query with the email parameter const row = db.get<{ '1': 1 }>(query, [email]); // If a row is found, the email exists return row !== undefined; } catch (error: unknown) { // Log error if the query fails const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error checking if email "${email}" exists: ${message}`, 'errLog.txt'); return false; // Return false if there's an error } } // Exports ----------------------------------------------------------------------------- export { SQLITE_CONSTRAINT_ERROR, addUser, deleteUser, getMemberDataByCriteria, getMultipleMemberDataByCriteria, updateMemberColumns, updateLoginCountAndLastSeen, updateLastSeen, doesMemberOfIDExist, isUsernameTaken, isEmailTaken, }; ================================================ FILE: src/server/database/playerGamesManager.ts ================================================ // src/server/database/playerGamesManager.ts /** * This script handles queries to the player_games table. */ import type { Player } from '../../shared/chess/util/typeutil.js'; import jsutil from '../../shared/util/jsutil.js'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed import { allPlayerGamesColumns } from './databaseTables.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete player_games record. */ export interface PlayerGamesRecord { user_id: number; game_id: number; player_number: Player; score: number | null; clock_at_end_millis: number | null; elo_at_game: number | null; elo_change_from_game: number | null; } type PlayerGamesColumn = keyof PlayerGamesRecord; // Methods -------------------------------------------------------------------------------------------- /** * Gets player_games entries for all opponents of a specific user for a list of specific games * @param user_id - The user_id of the player * @param game_id_list - A list of game_ids * @param columns - The columns to retrieve (e.g., ['user_id', 'player_number']) * @returns An array of objects with the requested columns from player_games. */ function getOpponentsOfUserFromGames( user_id: number, game_id_list: number[], columns: K[], ): Pick[] { // Guard clauses... Validating the arguments... if (!Array.isArray(columns)) { logEventsAndPrint( `When getting player_games data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return []; } if ( !columns.every( (column) => typeof column === 'string' && allPlayerGamesColumns.includes(column), ) ) { logEventsAndPrint( `Invalid columns requested from player_games table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return []; } // Construct SQL query const placeholders = game_id_list.map(() => '?').join(', '); const query = ` SELECT ${columns.join(', ')} FROM player_games WHERE user_id != ? AND game_id IN (${placeholders}) `; try { // Execute the query and fetch result const rows = db.all>(query, [user_id, ...game_id_list]); // If no rows found, return undefined if (!rows || rows.length === 0) { logEventsAndPrint( `No matches found in player_games table for game_ids: ${jsutil.ensureJSONString(game_id_list)}.`, 'errLog.txt', ); return []; } // Return the fetched rows (single object) return rows; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error getting all player_games entries for game_id_list "${jsutil.ensureJSONString(game_id_list)}": ${message}`, 'errLog.txt', ); return []; } } /** * Retrieves the most recent N rated entries for a user on a specific leaderboard, returning only the specified columns from player_games. * Aborted games (where score is null) are skipped. * @param user_id - The ID of the user * @param leaderboard_id - The ID of the leaderboard to filter rated games * @param limit - Maximum number of recent games to fetch * @param columns - Array of column names from player_games to return (e.g., ['game_id', 'score']). * @returns Array of objects containing only the requested columns. */ function getRecentNRatedGamesForUser( user_id: number, leaderboard_id: number, limit: number, columns: K[], ): Pick[] { // Validate columns argument if (!Array.isArray(columns)) { logEventsAndPrint( `When fetching recent games, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return []; } if (!columns.every((col) => typeof col === 'string' && allPlayerGamesColumns.includes(col))) { logEventsAndPrint( `Invalid columns requested from player_games table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return []; } // Dynamically build SELECT clause from requested columns const selectClause = columns.map((col) => `pg.${col}`).join(', '); // Only include rated, non-aborted games on the specified leaderboard, sorted by game date const query = ` SELECT ${selectClause} FROM player_games pg JOIN games g ON g.game_id = pg.game_id WHERE pg.user_id = ? AND g.rated = 1 AND g.leaderboard_id = ? AND pg.score IS NOT NULL ORDER BY g.date DESC LIMIT ? `; try { // Bind parameters: user, leaderboard, and limit return db.all>(query, [user_id, leaderboard_id, limit]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error fetching recent rated games for user ${user_id} on leaderboard ${leaderboard_id}: ${message}`, 'errLog.txt', ); return []; } } // Exports -------------------------------------------------------------------------------------------- export { getOpponentsOfUserFromGames, // Commented out to emphasize this should not ever have to be used: // updatePlayerGamesColumns, getRecentNRatedGamesForUser, }; ================================================ FILE: src/server/database/ratingAbuseManager.ts ================================================ // src/server/database/ratingAbuseManager.ts /** * This script handles queries to the rating_abuse table. */ import type { RunResult } from 'better-sqlite3'; // Import necessary types import jsutil from '../../shared/util/jsutil.js'; import db from './database.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; // Adjust path if needed import { allRatingAbuseColumns } from './databaseTables.js'; // Types ---------------------------------------------------------------------------------------------- /** Structure of a complete rating_abuse record. */ interface RatingAbuseRecord { user_id: number; leaderboard_id: number; game_count_since_last_check: number | null; last_alerted_at: string | null; } type RatingAbuseColumn = keyof RatingAbuseRecord; /** The result of add/update operations */ type ModifyQueryResult = { success: true; result: RunResult } | { success: false; reason: string }; // Methods -------------------------------------------------------------------------------------------- /** * Adds an entry to the rating_abuse table * @param user_id - The id for the user * @param leaderboard_id - The id for the specific leaderboard * @returns A result object indicating success or failure. */ function addEntryToRatingAbuseTable(user_id: number, leaderboard_id: number): ModifyQueryResult { const query = ` INSERT INTO rating_abuse ( user_id, leaderboard_id ) VALUES (?, ?) `; // Only inserting user_id and leaderboard_id is needed if others have DB defaults or may be NULL try { // Execute the query with the provided values const result = db.run(query, [user_id, leaderboard_id]); // Return success result return { success: true, result }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error for debugging purposes logEventsAndPrint( `Error adding entry to rating_abuse table for user "${user_id}" and leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); // Return an error message // Check for specific constraint errors if possible (e.g., FOREIGN KEY failure) let reason = 'Failed to add entry to rating_abuse table.'; if (error instanceof Error && 'code' in error) { // Example check for better-sqlite3 specific error codes if (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { reason = '(User ID, Leaderboard ID) does not exist in the leaderboards table.'; } else if ( error.code === 'SQLITE_CONSTRAINT_UNIQUE' || error.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ) { reason = '(User ID, Leaderboard ID) already exists in the rating_abuse table.'; } } return { success: false, reason }; } } /** * Checks if an entry exists in the rating_abuse table. * Relies on the composite primary key (user_id, leaderboard_id). * @param user_id - The ID of the user to check. * @param leaderboard_id - The ID of the leaderboard to check within. * @returns True if the player exists on the specified leaderboard, false otherwise (including on error). */ function isEntryInRatingAbuseTable(user_id: number, leaderboard_id: number): boolean { // Query to select a constant '1' if a matching row exists. // LIMIT 1 ensures the database can stop searching after finding the first match. // This is efficient, especially with the primary key index. const query = ` SELECT 1 FROM rating_abuse WHERE user_id = ? AND leaderboard_id = ? LIMIT 1; `; try { const result = db.get<{ '1': 1 }>(query, [user_id, leaderboard_id]); // If db.get returns anything (even an object like { '1': 1 }), it means a row was found. // If no row is found, db.get returns undefined. // The double negation (!!) converts a truthy value (the result object) to true, // and a falsy value (undefined) to false. return !!result; } catch (error: unknown) { // Log any potential database errors during the check const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Error checking existence of rating_abuse entry for user "${user_id}" on leaderboard "${leaderboard_id}": ${message}`, 'errLog.txt', ); // On error, we cannot confirm existence, so return false. return false; } } /** * Fetches specified columns of a single (user_id, leaderboard_id) from the rating_abuse table based on (user_id, leaderboard_id) * @param user_id - The user_id of the player * @param leaderboard_id - The leaderboard_id * @param columns - The columns to retrieve (e.g., ['game_count_since_last_check', 'last_alerted_at']) * @returns An object containing the requested columns, or undefined if no match is found. */ function getRatingAbuseData( user_id: number, leaderboard_id: number, columns: K[], ): Pick | undefined { // Guard clauses... Validating the arguments... if (!Array.isArray(columns)) { logEventsAndPrint( `When getting rating_abuse data, columns must be an array of strings! Received: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } if ( !columns.every( (column) => typeof column === 'string' && allRatingAbuseColumns.includes(column), ) ) { logEventsAndPrint( `Invalid columns requested from rating_abuse table: ${jsutil.ensureJSONString(columns)}`, 'errLog.txt', ); return undefined; } // Arguments are valid, move onto the SQL query... // Construct SQL query const query = `SELECT ${columns.join(', ')} FROM rating_abuse WHERE user_id = ? AND leaderboard_id = ?`; try { // Execute the query and fetch result const row = db.get>(query, [user_id, leaderboard_id]); // If no row is found, return undefined if (!row) { logEventsAndPrint( `No matches found in rating_abuse table for user_id = ${user_id} and leaderboard_id = ${leaderboard_id}.`, 'errLog.txt', ); return undefined; } // Return the fetched row (single object) return row; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error and return undefined logEventsAndPrint( `Error executing query when gettings rating_abuse entry of user_id ${user_id} and leaderboard_id = ${leaderboard_id}: ${message}. The query: "${query}"`, 'errLog.txt', ); return undefined; } } /** * Updates multiple column values in the rating_abuse table for a given user. * * @param user_id - The user ID of the player. * @param leaderboard_id - The leaderboard_id * @param columnsAndValues - An object containing column-value pairs to update. * @returns - A result object indicating success or failure. */ function updateRatingAbuseColumns( user_id: number, leaderboard_id: number, columnsAndValues: Partial, ): ModifyQueryResult { // Ensure columnsAndValues is an object and not empty if (typeof columnsAndValues !== 'object' || Object.keys(columnsAndValues).length === 0) { logEventsAndPrint( `Invalid or empty columns and values provided for user ID "${user_id}" and leaderboard ID "${leaderboard_id}" when updating rating_abuse columns! Received: ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); // Detailed logging for debugging return { success: false, reason: 'Invalid arguments.' }; // Generic error message } for (const column in columnsAndValues) { // Validate all provided columns if (!allRatingAbuseColumns.includes(column)) { logEventsAndPrint( `Invalid column "${column}" provided for user ID "${user_id}" and leaderboard ID "${leaderboard_id}" when updating rating_abuse columns! Received: ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); // Detailed logging for debugging return { success: false, reason: 'Invalid column.' }; // Generic error message } } // Dynamically build the SET part of the query const setStatements = Object.keys(columnsAndValues) .map((column) => `${column} = ?`) .join(', '); const values = Object.values(columnsAndValues); // Add the user_id and leaderboard_id as the last parameters for the WHERE clause values.push(user_id, leaderboard_id); // Update query to modify multiple columns const updateQuery = `UPDATE rating_abuse SET ${setStatements} WHERE user_id = ? AND leaderboard_id = ?`; try { // Execute the update query const result = db.run(updateQuery, values); // Check if the update was successful if (result.changes > 0) return { success: true, result }; else { logEventsAndPrint( `No changes made when updating rating_abuse table columns ${JSON.stringify(columnsAndValues)} for entry in rating_abuse table with user ID "${user_id}" and leaderboard ID "${leaderboard_id}"! Received: ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); return { success: false, reason: 'No changes made.' }; // Generic error message } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); // Log the error for debugging purposes logEventsAndPrint( `Error updating rating_abuse table columns ${JSON.stringify(Object.keys(columnsAndValues))} for user ID "${user_id}" and leaderboard ID "${leaderboard_id}": ${message}! Received: ${jsutil.ensureJSONString(columnsAndValues)}`, 'errLog.txt', ); // Return an error message return { success: false, reason: 'Database error.' }; // Generic error message } } // Exports -------------------------------------------------------------------------------------------- export { addEntryToRatingAbuseTable, isEntryInRatingAbuseTable, getRatingAbuseData, updateRatingAbuseColumns, }; ================================================ FILE: src/server/database/refreshTokenManager.ts ================================================ // src/server/database/refreshTokenManager.ts /** * This module manages refresh tokens in the database, providing functions * to add, find, delete, and update them in the `refresh_tokens` table. */ import type { Request } from 'express'; import db from './database.js'; import { getClientIP } from '../utility/IP.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { refreshTokenExpiryMillis } from '../controllers/authenticationTokens/tokenSigner.js'; /** * Represents a record in the `refresh_tokens` database table. */ export type RefreshTokenRecord = { token: string; user_id: number; /** The Unix timestamp, in milliseconds, when the token was created. */ created_at: number; /** The Unix timestamp, in milliseconds, when the token will expire. */ expires_at: number; /** The last known IP address the user used this refresh token from. */ ip_address: string | null; /** * The Unix timestamp, in milliseconds, when the token was consumed for a session renewal. * Allow a small grace period for using old tokens when renewing sessions. */ consumed_at: number | null; }; /** * Finds a refresh token in the database. * @param token - The JWT refresh token string. * @returns The token record if found, otherwise undefined. * @throws {Error} Throws a generic error if a database error occurs. */ export function findRefreshToken(token: string): RefreshTokenRecord | undefined { const query = ` SELECT token, user_id, created_at, expires_at, consumed_at, ip_address FROM refresh_tokens WHERE token = ? `; try { return db.get(query, [token]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Database error while finding refresh token: ${message}`, 'errLog.txt'); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Finds refresh token entries in the database associated with a list of user_ids * @param user_id_list - A list of user IDs * @returns A list of RefreshTokenRecords connected to the users in the user_id_list * @throws {Error} Throws a generic error if a database error occurs. */ export function findRefreshTokensForUsers(user_id_list: number[]): RefreshTokenRecord[] { const placeholders = user_id_list.map(() => '?').join(', '); const query = ` SELECT token, user_id, created_at, expires_at, ip_address FROM refresh_tokens WHERE user_id IN (${placeholders}) `; try { return db.all(query, user_id_list); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Database error while finding refresh tokens for users ${JSON.stringify(user_id_list)}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Adds a new refresh token record to the database. * @param req - The Express request object to get the IP address. * @param userId - The ID of the user the token belongs to. * @param token - The new JWT refresh token string. * @throws {Error} Throws a generic error if a database error occurs. */ export function addRefreshToken(req: Request, userId: number, token: string): void { const now = Date.now(); const query = ` INSERT INTO refresh_tokens (token, user_id, created_at, expires_at, ip_address) VALUES (?, ?, ?, ?, ?) `; const ip_address = getClientIP(req) || null; try { db.run(query, [ token, userId, now, // created_at now + refreshTokenExpiryMillis, // expires_at ip_address, ]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Database error while adding refresh token for userId ${userId}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Deletes a specific refresh token from the database. * @param token - The token to delete. * @throws {Error} Throws a generic error if a database error occurs. */ export function deleteRefreshToken(token: string): void { const query = `DELETE FROM refresh_tokens WHERE token = ?`; try { db.run(query, [token]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Database error while deleting refresh token: ${message}`, 'errLog.txt'); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Deletes all refresh tokens for a given user. Used for "log out of all devices". * Effectively terminates all login sessions for the user. * @param userId - The user's ID. * @throws {Error} Throws a generic error if a database error occurs. */ export function deleteAllRefreshTokensForUser(userId: number): void { const query = `DELETE FROM refresh_tokens WHERE user_id = ?`; try { db.run(query, [userId]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Database error while deleting all refresh tokens for userId ${userId}: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Updates the IP address for a given token. * @param token - The token to update. * @param ip - The new IP address to record. * @throws {Error} Throws a generic error if a database error occurs. */ export function updateRefreshTokenIP(token: string, ip: string | null): void { const query = `UPDATE refresh_tokens SET ip_address = ? WHERE token = ?`; try { db.run(query, [ip, token]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Database error while updating refresh token IP: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while processing the refresh token.'); } } /** * Marks a token as consumed (soft delete). * Used during rotation to allow a short grace period for concurrent requests. * @param token - The token to mark as consumed. * @throws {Error} Throws a generic error if a database error occurs. */ export function markRefreshTokenAsConsumed(token: string): void { const now = Date.now(); const query = `UPDATE refresh_tokens SET consumed_at = ? WHERE token = ?`; try { db.run(query, [now, token]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Database error while marking refresh token as consumed: ${message}`, 'errLog.txt', ); throw new Error('A database error occurred while processing the refresh token.'); } } ================================================ FILE: src/server/game/gamemanager/abortresigngame.ts ================================================ // src/server/game/gamemanager/abortresigngame.ts /** * This script handles the abortings and resignations of online games */ import type { ServerGame } from './gameutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import gameutility from './gameutility.js'; import { setGameConclusion } from './gamemanager.js'; //-------------------------------------------------------------------------------------------------------- /** * Called when a client tries to abort a game. * @param ws - The websocket * @param servergame - The game they are in.. */ function abortGame(_ws: CustomWebSocket, servergame: ServerGame): void { // Is it legal?... if (gameutility.isGameOver(servergame.basegame)) { // Return if game is already over console.log( `Player tried to abort game ${servergame.match.id} when the game is already over!`, ); return; } else if (gameutility.isGameBorderlineResignable(servergame.basegame)) { // A player might try to abort a game after his opponent has just played the second move due to latency issues... // In doubt, be lenient and allow him to abort here. DO NOT RETURN console.log( `Player tried to abort game ${servergame.match.id} when there's been exactly 2 moves played! Aborting game anyways...`, ); } else if (gameutility.isGameResignable(servergame.basegame)) { // Return if player tries to abort when he does not have the right console.error( `Player tried to abort game ${servergame.match.id} when there's been at least 3 moves played!`, ); return; } // Abort setGameConclusion(servergame, { condition: 'aborted' }); } /** * Called when a client tries to resign a game. * @param ws - The websocket * @param servergame - The game they are in. */ function resignGame(ws: CustomWebSocket, servergame: ServerGame): void { // Is it legal?... if (gameutility.isGameOver(servergame.basegame)) { // Return if game is already over console.log( `Player resign to resign game ${servergame.match.id} when the game is already over!`, ); return; } else if (!gameutility.isGameResignable(servergame.basegame)) { // Return if player tries to resign when he does not have the right console.error( `Player tried to resign game ${servergame.match.id} when there's less than 2 moves played! Ignoring..`, ); return; } // Resign const ourColor = ws.metadata.subscriptions.game?.color || gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; const opponentColor = typeutil.invertPlayer(ourColor); setGameConclusion(servergame, { victor: opponentColor, condition: 'resignation' }); } export { abortGame, resignGame }; ================================================ FILE: src/server/game/gamemanager/activeplayers.ts ================================================ // src/server/game/gamemanager/activeplayers.ts /** * This script keeps track of the ID's of games members and browsers are currently in. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { MatchInfo } from './gameutility.js'; import type { AuthMemberInfo } from '../../types.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; //-------------------------------------------------------------------------------------------------------- /** * Contains what members are currently in a game: `{ member: gameID }` * Users that are present in this list are not allowed to join another game until they're * deleted from here. As soon as a game is over, we can {@link removeUserFromActiveGame()}, * even though the game may not be deleted/logged yet. */ const membersInActiveGames: Record = {}; /** * Contains what browsers are currently in a game: `{ browser: gameID }` * Users that are present in this list are not allowed to join another game until they're * deleted from here. As soon as a game is over, we can {@link removeUserFromActiveGame()} * even though the game may not be deleted/logged yet. */ const browsersInActiveGames: Record = {}; //-------------------------------------------------------------------------------------------------------- /** * Adds the user to the list of users currently in an active game. * Players in this are not allowed to join a second game. * @param id - The id of the game they are in. */ function addUserToActiveGames(user: AuthMemberInfo, id: number): void { if (user.signedIn) membersInActiveGames[user.user_id] = id; else browsersInActiveGames[user.browser_id] = id; } /** * Removes the user from the list of users currently in an active game. * This allows them to join a new game. * Doesn't remove them if they are already in a new game of a different ID. * @param user - An object containing either the `member` or `browser` property. * @param gameID - The id of the game they are in. */ function removeUserFromActiveGame(user: AuthMemberInfo, gameID: number): void { // Only removes them from the game if they belong to a game of that ID. // If they DON'T belong to that game, that means they speedily // resigned and started a new game, so don't modify this! if (user.signedIn) { if (membersInActiveGames[user.user_id] === gameID) delete membersInActiveGames[user.user_id]; else if (membersInActiveGames[user.user_id] !== undefined) console.log( 'Not removing member from active games because they speedily joined a new game!', ); } else { if (browsersInActiveGames[user.browser_id] === gameID) delete browsersInActiveGames[user.browser_id]; else if (browsersInActiveGames[user.browser_id] !== undefined) console.log( 'Not removing browser from active games because they speedily joined a new game!', ); } } /** * Returns true if the player behind the socket is already in an * active game, which means they're not allowed to join a new one. * @param ws - The websocket */ function isSocketInAnActiveGame(ws: CustomWebSocket): boolean { const player = ws.metadata.memberInfo; // Allow a member to still join a new game, even if they're browser may be connected to one already. if (player.signedIn) { // Their username trumps their browser id. return player.user_id in membersInActiveGames; } else return player.browser_id in browsersInActiveGames; } /** * Returns true if the player behind the socket is not in an active game * of the provided ID (has seen the game conclusion). * @param match * @param color */ function hasColorInGameSeenConclusion(match: MatchInfo, color: Player): boolean { const player = match.playerData[color]; if (!player) throw new Error( `Invalid color "${color}" when checking if color in game has seen game conclusion!`, ); return getIDOfGamePlayerIsIn(player.identifier) !== match.id; } /** * Gets a game by player. * @param player - The player object containing all the memberinfo * @returns The game they are in, if they belong in one, otherwise undefined. */ function getIDOfGamePlayerIsIn(player: AuthMemberInfo): number | undefined { if (player.signedIn) return membersInActiveGames[player.user_id]; else return browsersInActiveGames[player.browser_id]; } //-------------------------------------------------------------------------------------------------------- export { addUserToActiveGames, removeUserFromActiveGame, isSocketInAnActiveGame, hasColorInGameSeenConclusion, getIDOfGamePlayerIsIn, }; ================================================ FILE: src/server/game/gamemanager/afkdisconnect.ts ================================================ // src/server/game/gamemanager/afkdisconnect.ts /** * The script handles the setting, resetting, and cancellation * of both the auto resign timer when players go AFK in online games, * and the disconnection timer when they leave the page / lose internet. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { MatchInfo, ServerGame } from './gameutility.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import gameutility from './gameutility.js'; //-------------------------------------------------------------------------------------------------------- /** * The time to give players who disconnected not by choice * (network interruption) to reconnect to the game before * we tell their opponent they've disconnected, and start an auto-resign timer. */ const timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis = 5_000; // 5 seconds /** * The duration of the auto-resign timer by disconnect, when the player * has intentionally left the page. */ const timeBeforeAutoResignByDisconnectMillis = 20_000; // 20 seconds /** * The duration of the auto-resign timer by disconnect (more forgiving), * when the player's internet cuts out. */ const timeBeforeAutoResignByDisconnectMillis_NotByChoice = 60_000; // 60 seconds //-------------------------------------------------------------------------------------------------------- /** * Cancels the timer that automatically resigns a player due to being AFK (Away From Keyboard). * This function should be called when the "AFK-Return" websocket action is received, indicating * that the player has returned, OR when a client refreshes the page! */ function cancelAutoAFKResignTimer(servergame: ServerGame, alertOpponent: boolean = false): void { if (servergame.match.autoAFKResignTime !== undefined && alertOpponent) { // Alert their opponent const opponentColor = typeutil.invertPlayer(servergame.basegame.whosTurn); gameutility.sendMessageToSocketOfColor( servergame.match, opponentColor, 'game', 'opponentafkreturn', ); } clearTimeout(servergame.match.autoAFKResignTimeoutID); servergame.match.autoAFKResignTimeoutID = undefined; servergame.match.autoAFKResignTime = undefined; } //-------------------------------------------------------------------------------------------------------- /** * Starts a timer to auto-resign a player from disconnection. * @param servergame - The game * @param color - The color to start the auto-resign timer for * @param closureNotByChoice - True if the player didn't close the connection on purpose. * @param onAutoResignFunc - The function to call when the player should be auto resigned from disconnection. This should have 2 arguments: The game, and the color that won. */ function startDisconnectTimer( servergame: ServerGame, color: Player, closureNotByChoice: boolean, onAutoResignFunc: (_game: ServerGame, _winner: Player) => void, ): void { // console.log(`Starting disconnect timer to auto resign player ${color}.`); const now = Date.now(); const resignable = gameutility.isGameResignable(servergame.basegame); let timeBeforeAutoResign = closureNotByChoice && resignable ? timeBeforeAutoResignByDisconnectMillis_NotByChoice : timeBeforeAutoResignByDisconnectMillis; // console.log(`Time before auto resign: ${timeBeforeAutoResign}`) let timeToAutoLoss = now + timeBeforeAutoResign; // Is there an afk timer already running for them? // If so, delete it, transferring it's time remaining to this disconnect timer. // We can do this because if player is disconnected, they are afk anyway. // And if if they reconnect, then they're not afk anymore either. if ( servergame.basegame.whosTurn === color && servergame.match.autoAFKResignTime !== undefined ) { if (servergame.match.autoAFKResignTime > timeToAutoLoss) console.error( "The time to auto-resign by AFK should not be greater than time to auto-resign by disconnect. We shouldn't be overwriting the AFK timer.", ); timeToAutoLoss = servergame.match.autoAFKResignTime; timeBeforeAutoResign = timeToAutoLoss - now; cancelAutoAFKResignTimer(servergame); } const playerdata = servergame.match.playerData[color]!; const opponentColor = typeutil.invertPlayer(color); // Clear the cushion timer state since we're transitioning to the auto-resign timer. playerdata.disconnect.startTime = undefined; playerdata.disconnect.timeoutID = setTimeout( () => onAutoResignFunc(servergame, opponentColor), timeBeforeAutoResign, ); playerdata.disconnect.timeToAutoLoss = timeToAutoLoss; playerdata.disconnect.wasByChoice = !closureNotByChoice; // Alert their opponent the time their opponent will be auto-resigned by disconnection. const value = { millisUntilAutoDisconnectResign: timeBeforeAutoResign, wasByChoice: !closureNotByChoice, }; gameutility.sendMessageToSocketOfColor( servergame.match, opponentColor, 'game', 'opponentdisconnect', value, ); } /** * Cancels both players timers to auto-resign them from disconnection if they were disconnected. * Typically called when a game ends. * @param match - The match */ function cancelDisconnectTimers(match: MatchInfo): void { for (const color of Object.keys(match.playerData)) { cancelDisconnectTimer(match, Number(color) as Player, true); } } /** * Cancels the player's timer to auto-resign them from disconnection if they were disconnected. * This is called when they reconnect/refresh. * @param match - The game * @param color - The color to cancel the timer for */ function cancelDisconnectTimer( match: MatchInfo, color: Player, dontNotifyOpponent: boolean = false, ): void { // console.log(`Canceling disconnect timer for player ${color}!`) /** Whether the timer (not the cushion to start the timer) for auto-resigning is RUNNING! */ const autoResignTimerWasRunning = gameutility.isAutoResignDisconnectTimerActiveForColor( match, color, ); const playerdata = match.playerData[color]!; clearTimeout(playerdata.disconnect.startID); clearTimeout(playerdata.disconnect.timeoutID); playerdata.disconnect.startID = undefined; playerdata.disconnect.startTime = undefined; playerdata.disconnect.timeoutID = undefined; playerdata.disconnect.timeToAutoLoss = undefined; playerdata.disconnect.wasByChoice = undefined; if (dontNotifyOpponent) return; // Alert their opponent their opponent has returned... if (!autoResignTimerWasRunning) return; // Opponent was never notified their opponent was afk, skip telling them their opponent has returned. const opponentColor = typeutil.invertPlayer(color); gameutility.sendMessageToSocketOfColor( match, opponentColor, 'game', 'opponentdisconnectreturn', ); } //-------------------------------------------------------------------------------------------------------- export { timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis, cancelAutoAFKResignTimer, startDisconnectTimer, cancelDisconnectTimers, cancelDisconnectTimer, }; ================================================ FILE: src/server/game/gamemanager/cheatreport.ts ================================================ // src/server/game/gamemanager/cheatreport.ts /** * This script handles cheat reports, aborting games when they come in. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { ServerGame } from './gameutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import * as z from 'zod'; import typeutil from '../../../shared/chess/util/typeutil.js'; import { isGameInstantlyDeleted } from '../../../shared/chess/variants/servervalidation.js'; import gameutility from './gameutility.js'; import { logEvents } from '../../middleware/logEvents.js'; import { setGameConclusion } from './gamemanager.js'; /** The zod schema for validating the contents of the cheatreport message. */ const reportschem = z.strictObject({ /** The client's reason they reported their opponent. */ reason: z.string(), opponentsMoveNumber: z.int(), }); type ReportMessage = z.infer; /** * * @param ws - The socket * @param servergame - The game they belong in. * @param messageContents - The contents of the socket report message */ function onReport( ws: CustomWebSocket, servergame: ServerGame, messageContents: ReportMessage, ): void { // { reason, opponentsMoveNumber } console.log('Received cheat report! - Check hackLog.txt for more details.'); const ourColor = ws.metadata.subscriptions.game?.color || gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; const opponentColor = typeutil.invertPlayer(ourColor); // Cheat reports are only valid in games that are not instantly deleted on conclusion. // (i.e. games without server-side move validation AND are public) if ( isGameInstantlyDeleted( servergame.match.variant, servergame.basegame.dateTimestamp, servergame.match.publicity === 'private', ) ) { const errString = `Player tried to report cheating in a game that doesn't support cheat reports. Variant: ${servergame.match.variant}. Publicity: ${servergame.match.publicity}. Report message: ${JSON.stringify(messageContents)}. Reporter color: ${ourColor}. Game ID: ${servergame.match.id}`; logEvents(errString, 'hackLog.txt'); gameutility.sendMessageToSocketOfColor( servergame.match, ourColor, 'general', 'printerror', 'Cannot report opponent in this game.', ); return; } const perpetratingMoveIndex = servergame.basegame.moves.length - 1; const colorThatPlayedPerpetratingMove = gameutility.getColorThatPlayedMoveIndex( servergame.basegame, perpetratingMoveIndex, ); if (colorThatPlayedPerpetratingMove === ourColor) { const errString = `Silly goose player tried to report themselves for cheating. Report message: ${JSON.stringify(messageContents)}. Reporter color: ${ourColor}.\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`; logEvents(errString, 'hackLog.txt'); gameutility.sendMessageToSocketOfColor( servergame.match, ourColor, 'general', 'printerror', "Silly goose. You can't report yourself for cheating! You played that move!", ); return; } // Remove the last move played. const perpetratingMove = servergame.basegame.moves.pop(); if (!perpetratingMove) return; const opponentsMoveNumber = messageContents.opponentsMoveNumber; const errText = `Cheating reported! Perpetrating move: ${perpetratingMove.token}. Move number: ${opponentsMoveNumber}. The report description: ${messageContents.reason} Color who reported: ${ourColor}. Probably cheater color: ${opponentColor}.\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`; console.error(errText); logEvents(errText, 'hackLog.txt'); for (const playerStr in servergame.match.playerData) { const player: Player = Number(playerStr) as Player; const isSuspectedCheater = player === opponentColor; if (isSuspectedCheater) { gameutility.sendMessageToSocketOfColor( servergame.match, player, 'general', 'notifyerror', 'server.javascript.ws-you_cheated', ); } else { gameutility.sendMessageToSocketOfColor( servergame.match, player, 'general', 'notify', 'server.javascript.ws-opponent_cheated', ); } } // Cheating report was valid, terminate the game.. setGameConclusion(servergame, { condition: 'aborted' }); } export { onReport, reportschem }; ================================================ FILE: src/server/game/gamemanager/drawoffers.ts ================================================ // src/server/game/gamemanager/drawoffers.ts /** * This script contains utility methods for draw offers, * and has almost zero dependancies. * * It does NOT contain the routes for when a player * extends/accepts a draw offer! * NOR does it send any websocket messages. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { MatchInfo, ServerGame } from './gameutility.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; //-------------------------------------------------------------------------------------------------------- /** * Minimum number of plies (half-moves) that * must span between 2 consecutive draw offers * by the same player! * * THIS MUST ALWAYS MATCH THE CLIENT-SIDE!!!! */ const movesBetweenDrawOffers = 2; //-------------------------------------------------------------------------------------------------------- /** * Returns true if the game currently has an open draw offer. * If so, players are not allowed to extend another. */ function isDrawOfferOpen(match: MatchInfo): boolean { return match.drawOfferState !== undefined; } /** * Returns true if the given color has extended a draw offer that's not confirmed yet. * @param color - The color who extended the draw offer */ function doesColorHaveExtendedDrawOffer(match: MatchInfo, color: Player): boolean { return match.drawOfferState === color; } /** * Returns true if they given color has extended a draw offer * too recently for them to extend another, yet. */ function hasColorOfferedDrawTooFast({ match, basegame }: ServerGame, color: Player): boolean { const lastPlyDrawOffered = getLastDrawOfferPlyOfColor(match, color); // number | undefined if (lastPlyDrawOffered !== undefined) { // They have made at least 1 offer this game // console.log("Last ply offered:", lastPlyDrawOffered); const movesSinceLastOffer = basegame.moves.length - lastPlyDrawOffered; if (movesSinceLastOffer < movesBetweenDrawOffers) return true; } return false; } /** * Opens a draw offer, extended by the provided color. * DOES NOT INFORM the opponent. * @param color - The color of the player extending the offer */ function openDrawOffer({ match, basegame }: ServerGame, color: Player): void { if (isDrawOfferOpen(match)) { logEventsAndPrint( "MUST NOT open a draw offer when there's already one open!!", 'errLog.txt', ); return; } const playerdata = match.playerData[color]!; playerdata.lastOfferPly = basegame.moves.length; match.drawOfferState = color; return; } /** * Closes any open draw offer. * DOES NOT INFORM the opponent. */ function closeDrawOffer(match: MatchInfo): void { match.drawOfferState = undefined; } /** * Returns the last ply move the provided color has offered a draw, * if they have, otherwise undefined. */ function getLastDrawOfferPlyOfColor(match: MatchInfo, color: Player): number | undefined { return match.playerData[color]?.lastOfferPly; } //-------------------------------------------------------------------------------------------------------- export { isDrawOfferOpen, doesColorHaveExtendedDrawOffer, hasColorOfferedDrawTooFast, openDrawOffer, closeDrawOffer, getLastDrawOfferPlyOfColor, }; ================================================ FILE: src/server/game/gamemanager/gamecount.ts ================================================ // src/server/game/gamemanager/gamecount.ts /** * Derives the active game count from the activeGames object in gamemanager.ts. */ import { activeGames } from './gamemanager.js'; import { broadcastToAllInviteSubs } from '../invitesmanager/invitessubscribers.js'; /** Broadcasts the current game count to all sockets subscribed to the invites list. */ function broadcastGameCountToInviteSubs(): void { broadcastToAllInviteSubs('gamecount', getActiveGameCount()); } /** Returns the active game count. */ function getActiveGameCount(): number { return Object.keys(activeGames).length; } export { getActiveGameCount, broadcastGameCountToInviteSubs }; ================================================ FILE: src/server/game/gamemanager/gamelogger.ts ================================================ // src/server/game/gamemanager/gamelogger.ts /** * This script logs all completed games into the "games" database table * It also computes the players' ratings in rated games and logs them into the "ratings" table * It also updates the players' stats in the "players_stats" table */ import type { Game } from '../../../shared/chess/logic/gamefile.js'; import type { RatingData } from './ratingcalculation.js'; import type { MatchInfo, ServerGame } from './gameutility.js'; import timeutil from '../../../shared/util/timeutil.js'; import clockutil from '../../../shared/chess/util/clockutil.js'; import metadatautil from '../../../shared/chess/util/metadatautil.js'; import icnconverter from '../../../shared/chess/logic/icn/icnconverter.js'; import { VariantLeaderboards } from '../../../shared/chess/variants/validleaderboard.js'; import { PlayerGroup, Player, players } from '../../../shared/chess/util/typeutil.js'; import db from '../../database/database.js'; import gameutility from './gameutility.js'; import { logEvents, logEventsAndPrint } from '../../middleware/logEvents.js'; import { computeRatingDataChanges, DEFAULT_LEADERBOARD_ELO, DEFAULT_LEADERBOARD_RD, } from './ratingcalculation.js'; import { addUserToLeaderboard, getPlayerLeaderboardRating_core, isPlayerInLeaderboard, updatePlayerLeaderboardRating, } from '../../database/leaderboardsManager.js'; // Functions ------------------------------------------------------------------------------- /** * Logs a completed game to the database by executing an atomic transaction. * Adds to and updates tables: games, player_games, player_stats, and leaderboards. * Either all database queries succeed, or none do (rollback on error). * This ensures data integrity and consistency. * @param servergame - The game to log * @returns The rating data if the game was rated and not aborted, otherwise undefined. */ function logGame(servergame: ServerGame): RatingData | undefined { if (servergame.basegame.moves.length === 0) return; // Don't log games with zero moves try { // Create the transaction by wrapping our orchestrator function. // We no longer need to pass any parameters here. const transaction = db.transaction<[ServerGame], RatingData | undefined>((g) => { return logGame_orchestrator(g); }); // Execute the transaction. Typically takes 2-8 milliseconds when using NVME storage. const ratingData = transaction(servergame); // If we reach here, the transaction was successful. return ratingData; } catch (error) { // This block will only execute if the orchestrator throws an error, causing a rollback. const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : 'No stack trace available'; void logEventsAndPrint( `FATAL: Game log transaction failed and was rolled back for Game ID ${servergame.match.id}. Check unloggedGames log. Error: ${errorMessage}\n${errorStack}`, 'errLog.txt', ); void logEvents( `Game: ${gameutility.getSimplifiedGameString(servergame)}`, 'unloggedGames.txt', ); return; } } /** * This is the core orchestrator that runs INSIDE the transaction of logging the game. * It performs all reads, calculations, and writes in a single, atomic operation. * It is designed to throw an error on any failure to trigger a rollback of the database. * Either ALL operations succeed, or NONE do. */ function logGame_orchestrator(servergame: ServerGame): RatingData | undefined { const { victor, condition: termination } = servergame.basegame.gameConclusion!; // --- Part 1: Handle Rating Updates --- const ratingData = updateLeaderboardsInTransaction(servergame.match, victor); // Immediately stamp the rating diffs onto the game's metadata so that // they're present for ICN generation and any other downstream use. if (ratingData !== undefined) { servergame.basegame.metadata.WhiteRatingDiff = metadatautil.getWhiteBlackRatingDiff( ratingData[players.WHITE]!.elo_change_from_game!, ); servergame.basegame.metadata.BlackRatingDiff = metadatautil.getWhiteBlackRatingDiff( ratingData[players.BLACK]!.elo_change_from_game!, ); } // --- Part 2: Create Game Records in games and player_games tables --- addGameRecordsInTransaction(servergame, victor, termination, ratingData); // --- Part 3: Update Player Stats --- updateAllPlayerStatsInTransaction(servergame, victor); // If all steps succeed, return the rating data. return ratingData; } /** * Updates leaderboards within the transaction. It calculates rating changes * and calls the unsafe (error-throwing) _core functions to update the database. * @returns The final rating data object, or undefined if the game was not rated, or aborted. * @throws An error if any database write fails. */ function updateLeaderboardsInTransaction( match: MatchInfo, victor: Player | null | undefined, ): RatingData | undefined { if (!match.rated || victor === undefined) return undefined; // If game is unrated or aborted, then no ratings get updated const leaderboard_id = VariantLeaderboards[match.variant]!; // Will always be defined if the game is rated. // 1. Build initial rating data by reading from the DB. let ratingdata: RatingData = {}; for (const playerStr in match.playerData) { const player: Player = Number(playerStr) as Player; const user_id = match.playerData[player]?.identifier.signedIn ? match.playerData[player].identifier.user_id : undefined; if (user_id === undefined) throw new Error( `Attempted to process rating for player ${playerStr} in rated game ${match.id} without a user_id.`, ); // If a player isn't on the leaderboard, add them first. // We use the _core (error-throwing) version because we are inside a transaction. if (!isPlayerInLeaderboard(user_id, leaderboard_id)) { addUserToLeaderboard( user_id, leaderboard_id, DEFAULT_LEADERBOARD_ELO, DEFAULT_LEADERBOARD_RD, ); } // We can now safely assume the player has a rating record. const leaderboard_data = getPlayerLeaderboardRating_core(user_id, leaderboard_id); if (leaderboard_data === undefined) throw Error( `Unable to read leaderboard data for user_id ${user_id} in leaderboard ${leaderboard_id}. This should never happen, they should have been added!`, ); ratingdata[player] = { elo_at_game: leaderboard_data.elo, rating_deviation_at_game: leaderboard_data.rating_deviation, rd_last_update_date: leaderboard_data.rd_last_update_date, }; } // 2. Calculate the new ratings. ratingdata = computeRatingDataChanges(ratingdata, victor); // 3. Write the new ratings to the database. for (const playerStr in ratingdata) { const player: Player = Number(playerStr) as Player; // TS is annoying sometimes, we already know all the players have user_ids const user_id = match.playerData[player]!.identifier.signedIn ? match.playerData[player]!.identifier.user_id : undefined; const data = ratingdata[player]!; updatePlayerLeaderboardRating( user_id!, leaderboard_id, data.elo_after_game!, data.rating_deviation_after_game!, ); } return ratingdata; } /** * [INTERNAL] Adds records to `games` and `player_games` tables. This function contains the "merged logic". Throws on error. * @returns The new game_id. */ function addGameRecordsInTransaction( { match, basegame }: ServerGame, victor: Player | null | undefined, termination: string, ratingData: RatingData | undefined, ): void { const { base_time_seconds, increment_seconds } = clockutil.splitTimeControl(match.clock); // --- Prepare ICN --- const icn = getICNOfGame(basegame); // This will throw on failure. const dateSqliteString = timeutil.timestampToSqlite(match.timeCreated); // 1. Insert the main record into the 'games' table. const gameQuery = ` INSERT INTO games ( game_id, date, base_time_seconds, increment_seconds, variant, rated, leaderboard_id, private, result, termination, move_count, time_duration_millis, icn ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; const gameResult = db.run(gameQuery, [ match.id, dateSqliteString, base_time_seconds, increment_seconds, match.variant, match.rated ? 1 : 0, VariantLeaderboards[match.variant] ?? null, match.publicity === 'private' ? 1 : 0, basegame.metadata.Result!, termination, basegame.moves.length, match.timeEnded ? match.timeEnded - match.timeCreated : null, icn, // Use the pre-generated ICN ]); const game_id = gameResult.lastInsertRowid as number; // 2. Loop through players and insert records into the 'player_games' table. const playerGamesQuery = ` INSERT INTO player_games ( user_id, game_id, player_number, score, clock_at_end_millis, elo_at_game, elo_change_from_game ) VALUES (?, ?, ?, ?, ?, ?, ?)`; const ending_clocks = !basegame.untimed ? gameutility.getGameClockValues(basegame).clocks : undefined; for (const playerStr in match.playerData) { const player = Number(playerStr) as Player; const user_id = match.playerData[player]!.identifier.signedIn ? match.playerData[player]!.identifier.user_id : undefined; if (!user_id) continue; // prettier-ignore db.run(playerGamesQuery, [ user_id, game_id, player, victor === undefined ? null : victor === player ? 1 : victor === null ? 0.5 : 0, !basegame.untimed ? ending_clocks![player]! : null, ratingData?.[player]?.elo_at_game ?? null, ratingData?.[player]?.elo_change_from_game ?? null, ]); } } /** * [INTERNAL] Loops through all players in a game and updates their stats by calling * the single-player update function. */ function updateAllPlayerStatsInTransaction( { basegame, match }: ServerGame, victor: Player | null | undefined, ): void { const playerMoveCounts = getPlayerMoveCountsInGame({ basegame, match }); for (const playerStr in match.playerData) { const player = Number(playerStr) as Player; const user_id = match.playerData[player]!.identifier.signedIn ? match.playerData[player]!.identifier.user_id : undefined; if (!user_id) continue; // Guests dono't have any stats to update. // prettier-ignore updateSinglePlayerStatsInTransaction(user_id, { moves_played_increment: playerMoveCounts[player]!, outcome: victor === undefined ? 'aborted' : victor === player ? "wins" : victor === null ? "draws" : "losses", is_rated: match.rated, publicity: match.publicity, }); } } /** * [INTERNAL] Updates a player's aggregate stats in the `player_stats` table. * This logic is co-located here because it is only ever used by the logGame transaction. * This version uses direct SQL increments for efficiency (`col = col + 1`). * It does not throw an error if the user is not found, as a user might be * deleted mid-game. It logs this event instead. */ function updateSinglePlayerStatsInTransaction( user_id: number, statsToUpdate: { moves_played_increment: number; outcome: 'wins' | 'losses' | 'draws' | 'aborted'; is_rated: boolean; publicity: 'public' | 'private'; }, ): void { // Start building the list of columns to update and the values for them. const setClauses: string[] = ['moves_played = moves_played + ?', 'game_count = game_count + 1']; const values: (number | string)[] = [statsToUpdate.moves_played_increment]; if (statsToUpdate.outcome === 'aborted') { setClauses.push('game_count_aborted = game_count_aborted + 1'); } else { const ratedString: 'rated' | 'casual' = statsToUpdate.is_rated ? 'rated' : 'casual'; // Increment the correct rated/casual counter. setClauses.push(`game_count_${ratedString} = game_count_${ratedString} + 1`); // Increment the correct public/private counter. // This is safe because `statsToUpdate.publicity` can only be 'public' or 'private'. setClauses.push( `game_count_${statsToUpdate.publicity} = game_count_${statsToUpdate.publicity} + 1`, ); // Increment the correct win/loss/draw counter. setClauses.push( `game_count_${statsToUpdate.outcome} = game_count_${statsToUpdate.outcome} + 1`, ); // Increment the correct combined outcome + rated/casual counter. setClauses.push( `game_count_${statsToUpdate.outcome}_${ratedString} = game_count_${statsToUpdate.outcome}_${ratedString} + 1`, ); } const query = `UPDATE player_stats SET ${setClauses.join(', ')} WHERE user_id = ?`; values.push(user_id); const result = db.run(query, values); if (result.changes === 0) { // This should now be impossible. If it happens, it's a critical error. throw new Error( `CRITICAL: User ${user_id} not found in player_stats during game log. This should not be possible. Did we allow them to delete their account mid-game?`, ); } } /** Converts a server-side {@link Game} into an ICN */ function getICNOfGame(game: Game): string { // Get ICN of game let ICN: string; try { ICN = icnconverter.LongToShort_Format( { ...game, fullMove: 1, state_global: { moveRuleState: game.gameRules.moveRule !== undefined ? 0 : undefined, }, }, { skipPosition: true, compact: true, spaces: false, comments: true, make_new_lines: false, move_numbers: false, }, ); } catch (error: unknown) { const errMessage = error instanceof Error ? error.message : String(error); const errStack = error instanceof Error ? error.stack : 'No stack trace available'; // Re-throw error with additional context, the orchestrator will catch it and roll back the transaction. throw Error( `Error converting game to ICN: ${errMessage}\nThe primed gamefile:\n${JSON.stringify(game)}\n${errStack}`, ); } return ICN; } /** * Counts the number of moves each player has made in the game. * * TODO: Move to moveutil script, once its dependancies are healthy!!! */ function getPlayerMoveCountsInGame({ match, basegame }: ServerGame): PlayerGroup { // Optimized to not require iterating through each move in the list. const playerMoveCounts: PlayerGroup = {}; const fullmoves_completed_total = Math.floor( basegame.moves.length / basegame.gameRules.turnOrder.length, ); const last_partial_move_length = basegame.moves.length % basegame.gameRules.turnOrder.length; for (const playerStr in match.playerData) { const player: Player = Number(playerStr) as Player; playerMoveCounts[player] = fullmoves_completed_total * basegame.gameRules.turnOrder.filter((p: Player) => p === player).length; playerMoveCounts[player] += basegame.gameRules.turnOrder .slice(0, last_partial_move_length) .filter((p: Player) => p === player).length; } return playerMoveCounts; } export default { logGame, }; ================================================ FILE: src/server/game/gamemanager/gamemanager.ts ================================================ // src/server/game/gamemanager/gamemanager.ts /** * The script keeps track of all our active online games. */ import type { Invite } from '../invitesmanager/inviteutility.js'; import type { Rating } from '../../../shared/types.js'; import type { ServerGame } from './gameutility.js'; import type { AuthMemberInfo } from '../../types.js'; import type { GameConclusion } from '../../../shared/chess/util/winconutil.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js'; import WebSocket from 'ws'; import clock from '../../../shared/chess/logic/clock.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import gamefile from '../../../shared/chess/logic/gamefile.js'; import winconutil from '../../../shared/chess/util/winconutil.js'; import gamefileutility from '../../../shared/chess/util/gamefileutility.js'; import { Leaderboards } from '../../../shared/chess/variants/validleaderboard.js'; import { doesVariantSupportServerValidation, isGameInstantlyDeleted, } from '../../../shared/chess/variants/servervalidation.js'; import statlogger from '../statlogger.js'; import gamelogger from './gamelogger.js'; import gameutility from './gameutility.js'; import ratingabuse from './ratingabuse.js'; import socketUtility from '../../socket/socketUtility.js'; import liveGameValues from './liveGameValues.js'; import { executeSafely } from '../../utility/errorGuard.js'; import { closeDrawOffer } from './drawoffers.js'; import { genUniqueGameID } from '../../database/gamesManager.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { restoreAllLiveGames } from './liveGameRestore.js'; import { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js'; import { timeBeforeGameDeletionMillis } from './gameutility.js'; import { broadcastGameCountToInviteSubs } from './gamecount.js'; import { addUserToActiveGames, removeUserFromActiveGame, getIDOfGamePlayerIsIn, hasColorInGameSeenConclusion, } from './activeplayers.js'; import { cancelAutoAFKResignTimer, startDisconnectTimer, cancelDisconnectTimers, timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis, } from './afkdisconnect.js'; // Constants ---------------------------------------------------------------------------------- /** Whether to log all new and ending games to the console. */ const PRINT_GAMES = false; // State -------------------------------------------------------------------------------------- /** * The object containing all currently active games. Each game's id is the key: `{ id: Game }` * This may temporarily include games that are over, but not yet deleted/logged. * * The game's ids are the same id they will receive in the database! For this reason they must * be unique across the games table, and all other live games. */ const activeGames: Record = {}; // Functions ----------------------------------------------------------------------------------- /** * Creates the `ServerGame` object and subscibes each player to the game * Auto-subscribes the players to receive game updates. * @param invite - The invite with the properties `id`, `owner`, `variant`, `clock`, `color`, `rated`, `publicity`. * @param assignments - The color each player has * @param actingPlayer - The color of the player that started the game and sent the socket message * @param replyto - The ID of the incoming socket message of the player that started the game. This is used for the `replyto` property on our response. */ function createGame( invite: Invite, assignments: PlayerGroup<{ identifier: AuthMemberInfo; socket?: CustomWebSocket }>, actingPlayer: Player, replyto?: number, ): void { const ratinginfo: typeof assignments & PlayerGroup<{ rating?: Rating }> = {}; for (const [color, data] of Object.entries(assignments)) { const player: Player = Number(color) as Player; ratinginfo[player] = data; if (data.identifier.signedIn) { ratinginfo[player].rating = getEloOfPlayerInLeaderboard( data.identifier.user_id, Leaderboards.INFINITY, ); } } const gameID = issueUniqueGameId(); const now = Date.now(); const metadata = gameutility.constructMetadataOfGame( invite.rated === 'rated', invite.variant, invite.clock, now, ratinginfo, ); const basegame = gamefile.initGame(metadata, now, invite.variant); const match = gameutility.initMatch(invite, gameID, assignments); // If the variant is small, construct the board for server-side move legality validation. const boardsim = doesVariantSupportServerValidation(match.variant, basegame.dateTimestamp) ? gamefile.initBoard(basegame.gameRules, match.variant, basegame.dateTimestamp) : undefined; const servergame: ServerGame = { basegame, match, boardsim }; for (const [strcolor, { socket }] of Object.entries(assignments)) { const player = Number(strcolor) as Player; if (socket) gameutility.subscribeClientToGame( servergame, socket, player, actingPlayer === player ? { replyto } : {}, ); else startDisconnectTimer(servergame, player, false, onPlayerLostByDisconnect); } for (const data of Object.values(match.playerData)) { addUserToActiveGames(data.identifier, servergame.match.id); } activeGames[servergame.match.id] = servergame; // Persist the new game to the database for restoration after server restart. liveGameValues.onGameCreated(servergame); if (PRINT_GAMES) { console.log('Starting new game:'); gameutility.printGame(servergame); } } /** * Returns an id that is unique across BOTH the games table * AND the live games inside {@link activeGames}. * * The game will receive this same id in the database when it is logged. */ function issueUniqueGameId(): number { let id: number; do { id = genUniqueGameID(); // This is already unique against all game_ids in the table. } while (activeGames[id] !== undefined); // Repeat until we have an id unique against all active games. // console.log(`Issued game_id (${id})!`); return id; } /** * Checks if member with a given username is currently listed as being in some active game * @param username - username of some member * @returns true if member is currently in active game, otherwise false */ function isMemberInSomeActiveGame(username: string): boolean { for (const servergame of Object.values(activeGames)) { for (const player of Object.values(servergame.match.playerData)) { if (!player.identifier.signedIn) continue; if (player.identifier.username === username) return true; } } return false; } /** * Starts the 5-second cushion timer for a player who disconnected not by their own choice * (network interruption). After the cushion elapses, if they have not yet reconnected, * the full disconnect auto-resign timer is started. * Also persists the cushion state to the database. * @param servergame - The game * @param color - The player who disconnected */ function startDisconnectCushionTimerAndPersist(servergame: ServerGame, color: Player): void { servergame.match.playerData[color]!.disconnect.startID = setTimeout( () => startDisconnectTimerAndPersist(servergame, color, true), timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis, ); servergame.match.playerData[color]!.disconnect.startTime = Date.now() + timeToGiveDisconnectedBeforeStartingAutoResignTimerMillis; liveGameValues.onPlayerDisconnected(servergame, color); } /** Starts the auto-resign disconnect timer and immediately persists the new disconnect state to the database. */ function startDisconnectTimerAndPersist( servergame: ServerGame, color: Player, closureNotByChoice: boolean, ): void { startDisconnectTimer(servergame, color, closureNotByChoice, onPlayerLostByDisconnect); liveGameValues.onPlayerDisconnected(servergame, color); } /** * Unsubscribes a websocket from the game their connected to after a socket closure. * Detaches their socket from the game, updates their metadata.subscriptions. * @param ws - Their websocket. * @param options - Additional options. * @param [unsubNotByChoice] When true, we will give them a 5-second cushion to re-sub before we start an auto-resignation timer. Set to false if we call this due to them closing the tab. */ function unsubClientFromGameBySocket(ws: CustomWebSocket, { unsubNotByChoice = true } = {}): void { const gameID = ws.metadata.subscriptions.game?.id; if (gameID === undefined) return console.error("Cannot unsub client from game when it's not subscribed to one."); const servergame = getGameByID(gameID); if (!servergame) return console.log( `Cannot unsub client from game when game doesn't exist! Metadata: ${socketUtility.stringifySocketMetadata(ws)}`, ); gameutility.unsubClientFromGame(servergame.match, ws); // Don't tell the client to unsub because their socket is CLOSING // Let their OPPONENT know they've disconnected though... if (gameutility.isGameOver(servergame.basegame)) return; // It's fine if players unsub/disconnect after the game has ended. const color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)! as Player; if (unsubNotByChoice) { // Internet interruption. Give them 5 seconds before starting auto-resign timer. // console.log('Waiting 5 seconds before starting disconnection timer.'); startDisconnectCushionTimerAndPersist(servergame, color); } else { // Closed tab manually. Immediately start auto-resign timer. startDisconnectTimerAndPersist(servergame, color, unsubNotByChoice); } } /** * Returns the game with the specified id. * @param id - The id of the game to pull. * @returns The game */ function getGameByID(id: number): ServerGame | undefined { return activeGames[id]; } /** * Gets a game by player. * @param player - The player object with one of 2 properties: `member` or `browser`, depending on if they are signed in. * @returns The game they are in, if they belong in one, otherwise undefined.. */ function getGameByPlayer(player: AuthMemberInfo): ServerGame | undefined { const gameID = getIDOfGamePlayerIsIn(player); if (gameID === undefined) return; // Not in a game; return getGameByID(gameID); } /** * Gets a game by socket, first checking if they are subscribed to a game, * if not then it checks if they are in the players in active games list. * @param ws - Their websocket * @returns The game they are in, if they belong in one, otherwise undefined. */ function getGameBySocket(ws: CustomWebSocket): ServerGame | undefined { const gameID = ws.metadata.subscriptions.game?.id; if (gameID) return getGameByID(gameID); // The socket is not subscribed to any game. Perhaps this is a resync/refresh? // Is the client in a game? What's their username/browser-id? const player = ws.metadata.memberInfo; return getGameByPlayer(player); } /** * Called when the client sees the game conclusion. Tries to remove them from the players * in active games list, which then allows them to join a new game. * * THIS SHOULD ALSO be the point when the server knows this player * agrees with the resulting game conclusion (no cheating detected), * and the server may change the players elos once both players send this. * @param ws - Their websocket * @param servergame - The game they are in. */ function onRequestRemovalFromPlayersInActiveGames( ws: CustomWebSocket, servergame: ServerGame, ): void { if (!gameutility.isGameOver(servergame.basegame)) return; // Game is still going, can't let them join a new game. const user = ws.metadata.memberInfo; removeUserFromActiveGame(user, servergame.match.id); // If both players have requested this (i.e. have seen the game conclusion), // and the game is scheduled to be deleted, just delete it now! // Is the opponent still in the players in active games list? (has not seen the game results) const color = ws.metadata.subscriptions.game?.color || gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; const opponentColor = typeutil.invertPlayer(color); if (!hasColorInGameSeenConclusion(servergame.match, opponentColor)) return; // They are still in the active games list because they have not seen the game conclusion yet. // console.log("Deleting game immediately, instead of waiting 15 seconds, because both players have seen the game conclusion and requested to be removed from the players in active games list.") // Both players have seen the game conclusion and requested to be removed // from the players in active games list, just delete the game now! gameutility.cancelDeleteGameTimer(servergame.match); deleteGame(servergame); } /** * Pushes the game clock, adding increment. Resets the timer * to auto terminate the game when a player loses on time. * @param servergame - The game * @returns The new time (in ms) of the player that just moved after increment is added. */ function pushGameClock({ basegame, match }: ServerGame): number | undefined { basegame.whosTurn = basegame.gameRules.turnOrder[basegame.moves.length % basegame.gameRules.turnOrder.length]!; if (basegame.untimed) return; // Don't adjust the times if the game isn't timed. const data = clock.push(basegame, basegame.clocks); // Reset the timer that will auto terminate the game when one player loses on time. if (!gameutility.isGameOver(basegame) && gameutility.isGameResignable(basegame)) { // Cancel previous auto loss timer if it exists clearTimeout(match.autoTimeLossTimeoutID); // Set the next one const timeUntilLoseOnTime = Math.max(basegame.clocks.timeRemainAtTurnStart!, 0); match.autoTimeLossTimeoutID = setTimeout( () => onPlayerLostOnTime({ basegame, match }), timeUntilLoseOnTime, ); } return data; } /** * Finalizes the game conclusion and immediately deletes and logs the game. * Use this for all conclusions not triggered by a move (time, disconnect, abort, resign, draw). * For move-triggered conclusions use {@link finalizeConclusion} and {@link teardownGame} * directly so messages can be sent between finalization and teardown. * @param servergame - The game * @param conclusion - The new game conclusion */ function setGameConclusion(servergame: ServerGame, conclusion: GameConclusion | undefined): void { finalizeConclusion(servergame, conclusion); if (conclusion !== undefined) teardownGame(servergame); } /** * Finalizes the game conclusion: sets basegame state and metadata, stops the clock, * cancels all timers, closes the draw offer, stamps the end time, and persists to the DB. * After this returns, the game state is final and consistent with what will be logged. * Does NOT broadcast to clients or touch socket/game-object teardown. * @param servergame - The game * @param conclusion - The new game conclusion */ function finalizeConclusion(servergame: ServerGame, conclusion: GameConclusion | undefined): void { gamefileutility.setConclusion(servergame.basegame, conclusion); if (conclusion === undefined) return; const players: Record = {}; for (const [c, data] of Object.entries(servergame.match.playerData)) { players[c] = { id: data.identifier.signedIn ? data.identifier.username : data.identifier.browser_id, s: data.identifier.signedIn, }; } if (PRINT_GAMES) console.log( `Game ${servergame.match.id} over. Players: ${JSON.stringify(players)}. Conclusion: ${JSON.stringify(servergame.basegame.gameConclusion)}. Moves: ${servergame.basegame.moves.length}.`, ); clock.stop(servergame.basegame); // Cancel the timer that will auto terminate // the game when the next player runs out of time clearTimeout(servergame.match.autoTimeLossTimeoutID); // Also cancel the one that auto loses by AFK cancelAutoAFKResignTimer(servergame); cancelDisconnectTimers(servergame.match); closeDrawOffer(servergame.match); // The ending time of the game is set, if it is undefined if (servergame.match.timeEnded === undefined) servergame.match.timeEnded = Date.now(); // Persist the final game state to the database. liveGameValues.onGameConcluded(servergame); } /** * Executes game teardown: broadcasts the final game state to * clients if the conclusion was not move-triggered, then either * deletes the game immediately or schedules deletion after a short cushion. * Must be called after {@link finalizeConclusion}. * @param servergame - The game (basegame.gameConclusion must already be set) */ function teardownGame(servergame: ServerGame): void { const conclusion = servergame.basegame.gameConclusion!; // Move-triggered conclusions already send the gameConclusion in the move response. if (!winconutil.isConclusionMoveTriggered(conclusion.condition)) gameutility.broadcastGameUpdate(servergame); gameutility.cancelDeleteGameTimer(servergame.match); // Cancel first, in case a hacking report just occurred. if ( isGameInstantlyDeleted( servergame.match.variant, servergame.basegame.dateTimestamp, servergame.match.publicity === 'private', ) ) { // Server validated every move — cheating is impossible, // OR we disallow cheat reports (private game). // We can log and unsubscribe clients immediately. deleteGame(servergame); } else { // No server-side validation (e.g. large variant, or pasted position). // Give the opponent time to oppose the conclusion. servergame.match.deleteTimeoutID = setTimeout( () => deleteGame(servergame), timeBeforeGameDeletionMillis, ); } } /** * Called when a player in the game loses on time. * Sets the gameConclusion, notifies both players. * Sets a 5 second timer to delete the game in case * one of them was disconnected when this happened. * @param servergame - The game */ function onPlayerLostOnTime(servergame: ServerGame): void { // console.log('Someone has lost on time!'); // Who lost on time? const loser = servergame.basegame.whosTurn!; const winner = typeutil.invertPlayer(loser); clock.stop(servergame.basegame); // Sometimes their clock can have 1ms left. Just make that zero. if (servergame.basegame.clocks) servergame.basegame.clocks.currentTime[loser] = 0; setGameConclusion(servergame, { victor: winner, condition: 'time' }); } /** * Called when a player in the game loses by disconnection. * Sets the gameConclusion, notifies the opponent. * @param servergame - The game * @param colorWon - The color that won by opponent disconnection */ function onPlayerLostByDisconnect(servergame: ServerGame, colorWon: Player): void { if (gameutility.isGameOver(servergame.basegame)) return console.error( 'We should have cancelled the auto-loss-by-disconnection timer when the game ended!', ); if (gameutility.isGameResignable(servergame.basegame)) { // console.log('Someone has lost by disconnection!'); setGameConclusion(servergame, { victor: colorWon, condition: 'disconnect' }); } else { // console.log('Game aborted from disconnection.'); setGameConclusion(servergame, { condition: 'aborted' }); } } /** * Called when a player in the game loses by abandonment (AFK). * Sets the gameConclusion, notifies both players. * Sets a 5 second timer to delete the game in case * one of them was disconnected when this happened. * @param servergame - The game * @param colorWon - The color that won by opponent abandonment (AFK) */ function onPlayerLostByAbandonment(servergame: ServerGame, colorWon: Player): void { if (gameutility.isGameResignable(servergame.basegame)) { // console.log('Someone has lost by abandonment!'); setGameConclusion(servergame, { victor: colorWon, condition: 'disconnect' }); } else { // console.log('Game aborted from abandonment.'); setGameConclusion(servergame, { condition: 'aborted' }); } } /** * Deletes the game. Prints the active game count. * This should not be called until after both clients have had a chance * to see the game result, or after 15 seconds after the game ends * to give players time to cheat report. * @param servergame */ function deleteGame(servergame: ServerGame): void { // Delete is BEFORE logging, since the user may still send us game actions like "removefromplayersinactivegames" // and because of async stuff below, the game isn't actually deleted yet, which may trigger a second deleteGame() call. delete activeGames[servergame.match.id]; // Delete the game from the activeGames list broadcastGameCountToInviteSubs(); // Remove the live game from the persistence database. liveGameValues.onGameDeleted(servergame.match.id); // If the pastedGame flag is present, skip logging to the database. // We don't know the starting position. if (servergame.match.positionPasted) { // console.log('Skipping logging custom game.'); } else { // The gamelogger logs the completed game information into the database tables "games", "player_stats" and "ratings" // The ratings are calculated during the logging of the game into the database const ratingdata = gamelogger.logGame(servergame); // Mostly deprecated: // The statlogger logs games with at least 2 moves played (resignable) into /database/stats.json for stat collection executeSafely( () => statlogger.logGame(servergame), `statlogger unable to log game! ${gameutility.getSimplifiedGameString(servergame)}`, ); // Send rating changes to all players of game, if relevant if (ratingdata !== undefined) gameutility.sendRatingChangeToAllPlayers(servergame.match, ratingdata); } // Unsubscribe both players' sockets from the game if they still are connected. // If the socket is undefined, they will have already been auto-unsubscribed. // And remove them from the list of users in active games to allow them to join a new game. for (const data of Object.values(servergame.match.playerData)) { removeUserFromActiveGame(data.identifier, servergame.match.id); if (!data.socket) continue; // They don't have a socket connected. // We inform their opponent they have disconnected inside js when we call this method. // Tell the client to unsub on their end, IF the socket isn't closing. if (data.socket.readyState === WebSocket.OPEN) sendSocketMessage(data.socket, 'game', 'unsub'); gameutility.unsubClientFromGame(servergame.match, data.socket); } // Monitor suspicion levels for all players who participated in the game // Doesn't have to be in the same transaction as the game logging, // as the rating abuse table's data does not reference other tables. ratingabuse.measureRatingAbuseAfterGame(servergame); if (PRINT_GAMES) console.log(`Deleted game ${servergame.match.id}.`); } // Shutdown Preparation & Startup Restoration ------------------------------------------------ /** * Call when server's about to restart. * Stop all runtime timers and close sockets gracefully. * The games will be restored from the database on the next startup. * Their state is already stored inside live_games and live_game_players tables. */ function prepGamesForShutdown(): void { for (const gameID in activeGames) { const servergame = activeGames[gameID]!; // Cancel all runtime timers clearTimeout(servergame.match.autoTimeLossTimeoutID); cancelAutoAFKResignTimer(servergame); cancelDisconnectTimers(servergame.match); gameutility.cancelDeleteGameTimer(servergame.match); // Unsubscribe all sockets (we will resub them when they reconnect) for (const data of Object.values(servergame.match.playerData)) { if (!data.socket) continue; gameutility.unsubClientFromGame(servergame.match, data.socket); } delete activeGames[gameID]; } } /** * Restores all live games from the database on server startup. * Should be called after initDatabase() and before accepting client connections. */ function restoreLiveGames(): void { const restoredGames = restoreAllLiveGames(); for (const { servergame, pendingTimers } of restoredGames) { // Add the game to the active games list activeGames[servergame.match.id] = servergame; // Register players in the active players list for (const data of Object.values(servergame.match.playerData)) { addUserToActiveGames(data.identifier, servergame.match.id); } // Start timers // 1. Delete timer (for concluded games) if (pendingTimers.deleteTimerMs !== undefined) { if (pendingTimers.deleteTimerMs <= 0) { // Timer already expired, delete immediately deleteGame(servergame); continue; // Skip to next game since this one is being deleted } servergame.match.deleteTimeoutID = setTimeout( () => deleteGame(servergame), pendingTimers.deleteTimerMs, ); } // Skip remaining timers for concluded games if (gameutility.isGameOver(servergame.basegame)) continue; // 2. Auto time loss timer (for timed games) if (pendingTimers.autoTimeLossMs !== undefined) { if (pendingTimers.autoTimeLossMs <= 0) { // Clock already expired during downtime onPlayerLostOnTime(servergame); continue; } servergame.match.autoTimeLossTimeoutID = setTimeout( () => onPlayerLostOnTime(servergame), pendingTimers.autoTimeLossMs, ); } // 3. AFK resign timer if (pendingTimers.afkResignTimerMs !== undefined) { const opponentColor = typeutil.invertPlayer(servergame.basegame.whosTurn!); if (pendingTimers.afkResignTimerMs <= 0) { // AFK timer already expired during downtime onPlayerLostByAbandonment(servergame, opponentColor); continue; } servergame.match.autoAFKResignTimeoutID = setTimeout( () => onPlayerLostByAbandonment(servergame, opponentColor), pendingTimers.afkResignTimerMs, ); } // 4. Per-player disconnect timers for (const [playerStr, timerState] of Object.entries(pendingTimers.disconnectTimers)) { const player = Number(playerStr) as Player; const opponentColor = typeutil.invertPlayer(player); if (timerState.type === 'timer') { // Disconnect auto-resign timer was active if (timerState.remainingMs <= 0) { // Timer already expired, immediately resign onPlayerLostByDisconnect(servergame, opponentColor); break; // Game is over } // Revive the timer for the remaining duration exactly. // No sockets are connected yet at startup, so skip the opponent notification. const playerdata = servergame.match.playerData[player]!; playerdata.disconnect.startTime = undefined; playerdata.disconnect.timeoutID = setTimeout( () => onPlayerLostByDisconnect(servergame, opponentColor), timerState.remainingMs, ); playerdata.disconnect.timeToAutoLoss = Date.now() + timerState.remainingMs; playerdata.disconnect.wasByChoice = timerState.byChoice; } else if (timerState.type === 'cushion') { // Still in the 5-second cushion period if (timerState.remainingMs <= 0) { // Cushion has elapsed, start the disconnect timer immediately and persist that state. startDisconnectTimerAndPersist(servergame, player, !timerState.byChoice); } else { // Revive the cushion timer for the remaining duration servergame.match.playerData[player]!.disconnect.startID = setTimeout( () => startDisconnectTimerAndPersist( servergame, player, !timerState.byChoice, ), timerState.remainingMs, ); servergame.match.playerData[player]!.disconnect.startTime = Date.now() + timerState.remainingMs; } } else { // Fresh: was connected before restart, now disconnected due to server restart. // Give them the same 5-second cushion as a normal internet interruption. startDisconnectCushionTimerAndPersist(servergame, player); } } } } //-------------------------------------------------------------------------------------------------------- export { activeGames, createGame, isMemberInSomeActiveGame, unsubClientFromGameBySocket, onPlayerLostByAbandonment, getGameBySocket, onRequestRemovalFromPlayersInActiveGames, setGameConclusion, finalizeConclusion, teardownGame, pushGameClock, getGameByID, // Shutdown Preparation & Startup Restoration prepGamesForShutdown, restoreLiveGames, }; ================================================ FILE: src/server/game/gamemanager/gamerouter.ts ================================================ // src/server/game/gamemanager/gamerouter.ts /* * This script routes all incoming websocket messages * with the "game" route to where they need to go. */ import type { CustomWebSocket } from '../../socket/socketUtility.js'; import * as z from 'zod'; import { onPaste } from './pastereport.js'; import { onJoinGame } from './joingame.js'; import { resyncToGame } from './resync.js'; import { onAFK, onAFK_Return } from './onAFK.js'; import { abortGame, resignGame } from './abortresigngame.js'; import { onReport, reportschem } from './cheatreport.js'; import { submitMove, submitmoveschem } from './movesubmission.js'; import { offerDraw, acceptDraw, declineDraw } from './onOfferDraw.js'; import { getGameBySocket, onRequestRemovalFromPlayersInActiveGames } from './gamemanager.js'; const GameSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('abort') }), z.strictObject({ action: z.literal('resync'), value: z.int() }), z.strictObject({ action: z.literal('AFK') }), z.strictObject({ action: z.literal('AFK-Return') }), z.strictObject({ action: z.literal('offerdraw') }), z.strictObject({ action: z.literal('acceptdraw') }), z.strictObject({ action: z.literal('declinedraw') }), z.strictObject({ action: z.literal('joingame') }), z.strictObject({ action: z.literal('resign') }), z.strictObject({ action: z.literal('removefromplayersinactivegames') }), z.strictObject({ action: z.literal('paste') }), z.strictObject({ action: z.literal('report'), value: reportschem }), z.strictObject({ action: z.literal('submitmove'), value: submitmoveschem }), ]); type GameMessage = z.infer; /** * Handles all incoming websocket messages related to active games. * Possible actions: submitmove/offerdraw/abort/resign/joingame/resync/paste... * @param ws - The socket * @param contents - The incoming websocket message, with the properties `route`, `action`, `value`, `id`. * @param id - The id of the incoming message. This should be included in our response as the `replyto` property. */ function routeGameMessage(ws: CustomWebSocket, contents: GameMessage, id: number): void { // All actions that don't require a game switch (contents.action) { case 'resync': resyncToGame(ws, contents.value, id); return; case 'joingame': onJoinGame(ws); return; } const servergame = getGameBySocket(ws); // The game they belong in, if they belong in one. if (!servergame) { // This is rare but can happen if the game is deleted on the server while their message is in transit. console.log( `Received game message of action "${contents.action}" when player is not in a game. Maybe it was just deleted?`, ); return; } // All remaining actions requiring the game they're in switch (contents.action) { case 'submitmove': submitMove(ws, servergame, contents.value); break; case 'removefromplayersinactivegames': onRequestRemovalFromPlayersInActiveGames(ws, servergame); break; case 'abort': abortGame(ws, servergame); break; case 'resign': resignGame(ws, servergame); break; case 'offerdraw': offerDraw(ws, servergame); break; case 'acceptdraw': acceptDraw(ws, servergame); break; case 'declinedraw': declineDraw(ws, servergame); break; case 'AFK': onAFK(ws, servergame); break; case 'AFK-Return': onAFK_Return(ws, servergame); break; case 'report': onReport(ws, servergame, contents.value); break; case 'paste': onPaste(ws, servergame); break; default: // @ts-ignore console.error(`UNKNOWN web socket action received in game route! "${contents.action}"`); } } export { routeGameMessage, GameSchema }; ================================================ FILE: src/server/game/gamemanager/gameutility.ts ================================================ // src/server/game/gamemanager/gameutility.ts /** * This script contains our Game constructor for the server-side, * and contains many utility methods for working with them! * * At most this ever handles a single game, not multiple. */ import type { MoveRecord } from '../../../shared/chess/logic/movepiece.js'; import type { RatingData } from './ratingcalculation.js'; import type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js'; import type { Board, Game } from '../../../shared/chess/logic/gamefile.js'; import type { AuthMemberInfo } from '../../types.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js'; import type { ClockValues, GameUpdateMessage, MetaData, OpponentsMoveMessage, ParticipantState, PlayerRatingChangeInfo, Rating, TimeControl, } from '../../../shared/types.js'; import clock from '../../../shared/chess/logic/clock.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import metadatautil from '../../../shared/chess/util/metadatautil.js'; import { players as p } from '../../../shared/chess/util/typeutil.js'; import { Leaderboards, VariantLeaderboards, } from '../../../shared/chess/variants/validleaderboard.js'; import servermetadatautil from '../servermetadatautil.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; import { memberInfoEq, Invite } from '../invitesmanager/inviteutility.js'; import { UNCERTAIN_LEADERBOARD_RD } from './ratingcalculation.js'; import { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js'; import { sendNotify, sendNotifyError, sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { doesColorHaveExtendedDrawOffer, getLastDrawOfferPlyOfColor } from './drawoffers.js'; // Constants ------------------------------------------------------------------------------------ /** * The cushion time, before the game is deleted, if one player * has disconnected and has not yet seen the game conclusion. * This gives them a little bit of time to reconnect and submit a cheat report, * which is only useful in variants where we're not doing server-side move validation. */ export const timeBeforeGameDeletionMillis = 1000 * 8; // Types ---------------------------------------------------------------------------------------- /** Contains information about this player's disconnection and auto resign timer. */ type PlayerDisconnect = { /** * The timeout id of the timer that will START the auto disconnection timer * This is triggered if their socket unexpectedly closes, * and lasts for 5 seconds to give them a chance to reconnect. */ startID?: NodeJS.Timeout; /** * The epoch-ms timestamp when the 5-second reconnection cushion expires. * Set alongside startID when the cushion timer is started. * Used for persistence: on server restart, this allows reviving the cushion timer. */ startTime?: number; } & ( | { /** * The timeout id of the timer that will auto-resign the * player if they are disconnected for too long. */ timeoutID: NodeJS.Timeout; /** * The estimated timestamp that the player will * be auto-resigned from being disconnected too long. */ timeToAutoLoss: number; /** * Whether the player was disconnected by choice or not. * If not, they are given extra time to reconnect. */ wasByChoice: boolean; } | { timeoutID: undefined; timeToAutoLoss: undefined; wasByChoice: undefined; } ); /** Information about a single player in an online game. */ interface PlayerData { /** * The identifier of each color. * * If they are signed in, their identifier is `{ member: string }`, where member is their username. * If they are signed out, their identifier is `{ browser: string }`, where browser is their browser-id cookie. * */ identifier: AuthMemberInfo; /** Player's socket, if they are connected. */ socket?: CustomWebSocket; /** The last move ply this player extended a draw offer, if they have. 0-based, where 0 is the start of the game. */ lastOfferPly?: number; /** Contains information about this players disconnection and auto resign timer. */ disconnect: PlayerDisconnect; } /** The info for the server hosting the game */ interface MatchInfo { /** The match's unique ID. This is also the same ID the game will have when logged to the database. */ id: number; /** The variant code of the game being played. */ variant: VariantCode; /** The time this match was created. The number of milliseconds that have elapsed since the Unix epoch. */ timeCreated: number; /** The time this game ended, the game conclusion was set and the clocks were stopped serverside. The number of milliseconds that have elapsed since the Unix epoch. @type {number | undefined} */ timeEnded?: number; /** Whether this match is "public" or "private". */ publicity: 'public' | 'private'; /** Whether the match is rated. */ rated: boolean; /** * The time control of the game (e.g. `"600+5"` or `"-"` for untimed). * Guaranteed defined here because we can't read it from MetaData since it is optional there. */ clock: TimeControl; /** The data held for each player */ playerData: PlayerGroup; /** The ID of the timeout which will auto-lose the player * whos turn it currently is when they run out of time. */ autoTimeLossTimeoutID?: ReturnType; /** The ID of the timeout which will auto-lose the player * whos turn it currently is if they go AFK too long. */ autoAFKResignTimeoutID?: ReturnType; /** The time the current player will be auto-resigned by * AFK if they are currently AFK. */ autoAFKResignTime?: number; /** Whether a current draw offer is extended. If so, this is the color who extended it, otherwise null. */ drawOfferState?: Player; /** The ID of the timer to delete the match after it has ended. * This can be used to cancel it in case a hacking was reported. */ deleteTimeoutID?: ReturnType; /** * Whether a custom position was pasted in by either player. * The game will NOT be logged, because it will crash if we try * to paste it since we don't know the starting position. */ positionPasted: boolean; } /** The game stored in the server */ type ServerGame = { basegame: Game; match: MatchInfo; /** * Used for server-side move legality validation. * Present only for small variants. */ boardsim?: Board; }; // Functions -------------------------------------------------------------------------------------- /** * Construct the match bject based on the invite options and how players have been assigned */ function initMatch( invite: Invite, id: number, assignedPlayers: PlayerGroup<{ identifier: AuthMemberInfo }>, ): MatchInfo { const playerData: MatchInfo['playerData'] = {}; for (const [c, { identifier }] of Object.entries(assignedPlayers)) { playerData[Number(c) as Player] = { identifier, disconnect: { timeoutID: undefined, timeToAutoLoss: undefined, wasByChoice: undefined, }, }; } return { id, variant: invite.variant, playerData, timeCreated: Date.now(), publicity: invite.publicity, rated: invite.rated === 'rated', clock: invite.clock, positionPasted: false, }; } /** * Assigns which player is what color, depending on the `color` property of the invite. * * WE MUST EXPLICITLY have arguments for each player, as otherwise a bug is introduced * if this is called with only 1 player!! And type safety doesn't catch it. * @param inviteColor - The color property of the invite. "Random" / "White" / "Black" * @param player1 - The first player (the invite owner). * @param player2 - The second player (the invite accepter). * @returns An object with 2 properties: * - `colorData`: An object mapping player color to player info * - `playerColors`: the colors of each player, in order of ascending player number. */ function assignWhiteBlackPlayersFromInvite( inviteColor: Player | null, player1: AuthMemberInfo, player2: AuthMemberInfo, ): PlayerGroup { // { id, owner, variant, clock, color, rated, publicity } const colorData: PlayerGroup = {}; if (inviteColor === p.WHITE) { colorData[p.WHITE] = player1; colorData[p.BLACK] = player2; } else if (inviteColor === p.BLACK) { colorData[p.WHITE] = player2; colorData[p.BLACK] = player1; } else if (inviteColor === null) { // Random if (Math.random() > 0.5) { colorData[p.WHITE] = player1; colorData[p.BLACK] = player2; } else { colorData[p.WHITE] = player2; colorData[p.BLACK] = player1; } } else throw Error(`Unsupported color ${inviteColor} when assigning players to game.`); return colorData; } /** * Links their socket to this game, modifies their metadata.subscriptions, and sends them the game info. * @param servergame - The game they are a part of. * @param playerSocket - Their websocket. * @param playerColor - What color they are playing in this game. p.NEU * @param options - An object that may contain the option `sendGameInfo`, that when *true* won't send the game information over. Default: *true* * @param options.sendGameInfo * @param options.replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response. */ function subscribeClientToGame( servergame: ServerGame, playerSocket: CustomWebSocket, playerColor: Player, { sendGameInfo = true, replyto }: { sendGameInfo?: boolean; replyto?: number } = {}, ): void { const { match } = servergame; // 1. Attach their socket to the game for receiving updates const playerData = match.playerData[playerColor]; if (playerData === undefined) return console.error( `Cannot subscribe client to game when game does not expect color ${playerColor} to be present`, ); if (playerData.socket) { sendSocketMessage(playerData.socket, 'game', 'leavegame'); unsubClientFromGame(match, playerData.socket); } playerData.socket = playerSocket; // 2. Modify their socket metadata to add the 'game', subscription, // and indicate what game the belong in and what color they are! playerSocket.metadata.subscriptions.game = { id: match.id, color: playerColor, }; // 3. Send the game information, unless this is a reconnection, // at which point we verify if they are in sync if (sendGameInfo) sendGameInfoToPlayer(servergame, playerSocket, playerColor, replyto); } /** * Detaches the websocket from the game. * Updates the socket's subscriptions. * @param match * @param ws - Their websocket. */ function unsubClientFromGame(match: MatchInfo, ws: CustomWebSocket): void { if (ws.metadata.subscriptions.game === undefined) return; // Already unsubbed (they aborted) // 1. Detach their socket from the game so we no longer send updates delete match.playerData[ws.metadata.subscriptions.game.color]?.socket; // 2. Remove the game key-value pair from the sockets metadata subscription list. delete ws.metadata.subscriptions.game; } /** * Sends the game info to the player, the info they need to load the online game. * * Makes sure not to send sensitive info, such as player's browser-id cookies. * @param servergame - The game they're in. * @param playerSocket - Their websocket * @param playerColor - The color they are. * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response. */ function sendGameInfoToPlayer( servergame: ServerGame, playerSocket: CustomWebSocket, playerColor: Player, replyto?: number, ): void { const ratings = getRatingDataForGamePlayers( servergame.match.playerData, servergame.match.variant, ); const gameUpdateContents = getGameUpdateMessageContents(servergame, playerColor, false); const messageContents = { gameInfo: { id: servergame.match.id, rated: servergame.match.rated, publicity: servergame.match.publicity, playerRatings: ratings, }, metadata: servergame.basegame.metadata, youAreColor: playerColor, ...gameUpdateContents, }; sendSocketMessage(playerSocket, 'game', 'joingame', messageContents, replyto); } /** * Returns the current elo of all players in the game on the leaderboard * of the variant being played, or the INFINITY leaderboard if the variant does not have a leaderboard. * @returns An object containing the rating for non-guests in the game, and whether we are confident in that rating, IF the variant has a leaderboard. */ function getRatingDataForGamePlayers( players: PlayerGroup<{ identifier: AuthMemberInfo }>, variant: VariantCode, ): PlayerGroup { // Fallback to INFINITY leaderboard if the variant does not have a leaderboard. const leaderboardId = VariantLeaderboards[variant] ?? Leaderboards.INFINITY; const ratingData: PlayerGroup = {}; for (const [color, { identifier }] of Object.entries(players)) { if (!identifier.signedIn) continue; // Not a member, no rating to send const user_id = identifier.user_id; ratingData[Number(color) as Player] = getEloOfPlayerInLeaderboard(user_id, leaderboardId); } return ratingData; } /** * Generates metadata for a game including event details, player information, and timestamps. */ function constructMetadataOfGame( rated: boolean, variantKey: VariantCode, clock: TimeControl, dateTimestamp: number, playerdata: PlayerGroup<{ rating?: Rating; identifier: AuthMemberInfo }>, ): MetaData { const white = playerdata[p.WHITE]!.identifier; const black = playerdata[p.BLACK]!.identifier; const whiteIdentity = { name: white.signedIn ? white.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie id: white.signedIn ? white.user_id : undefined, elo: playerdata[p.WHITE]?.rating ? metadatautil.getFormattedElo(playerdata[p.WHITE]!.rating!) : undefined, }; const blackIdentity = { name: black.signedIn ? black.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie id: black.signedIn ? black.user_id : undefined, elo: playerdata[p.BLACK]?.rating ? metadatautil.getFormattedElo(playerdata[p.BLACK]!.rating!) : undefined, }; return servermetadatautil.buildGameMetadata( rated, variantKey, clock, dateTimestamp, whiteIdentity, blackIdentity, ); } /** * Resyncs a client's websocket to a game. The client already * knows the game id and much other information. We only need to send * them the current move list, player timers, and game conclusion. * @param ws - Their websocket * @param servergame - The game * @param colorPlayingAs - Their color * @param [replyToMessageID] - If specified, the id of the incoming socket message this update will be the reply to */ function resyncToGame( ws: CustomWebSocket, servergame: ServerGame, colorPlayingAs: Player, replyToMessageID?: number, ): void { // If their socket isn't subscribed, connect them to the game! if (!ws.metadata.subscriptions.game) subscribeClientToGame(servergame, ws, colorPlayingAs, { sendGameInfo: false }); // This function ALREADY sends all the information the client needs to resync! sendGameUpdateToColor(servergame, colorPlayingAs, false, { replyTo: replyToMessageID }); } /** * Alerts both players in the game of the game conclusion if it has ended, * and the current moves list and timers. * @param servergame - The game */ function broadcastGameUpdate(servergame: ServerGame): void { for (const player in servergame.match.playerData) { sendGameUpdateToColor(servergame, Number(player) as Player, false); } } /** * Alerts the player of the specified color of the game conclusion if it has ended, * and the current moves list and timers. * @param servergame - The game * @param color - The color of the player * @param forceSync - If true, the client will force its move list to exactly match the server's (not re-submitting any extra move) * @param [options.replyTo] - If specified, the id of the incoming socket message this update will be the reply to */ function sendGameUpdateToColor( servergame: ServerGame, color: Player, forceSync: boolean, { replyTo }: { replyTo?: number } = {}, ): void { const playerdata = servergame.match.playerData[color]; if (playerdata?.socket === undefined) return; // Not connected, can't send message const messageContents = getGameUpdateMessageContents(servergame, color, forceSync); sendSocketMessage(playerdata.socket, 'game', 'gameupdate', messageContents, replyTo); } /** * Constructs a gameupdate message UNIQUE to the player! * Unique because only one person receives the millisUntilAutoAFKResign * property - the opposite player of the one who has gone AFK. */ function getGameUpdateMessageContents( servergame: ServerGame, color: Player, forceSync: boolean, ): GameUpdateMessage { const messageContents: GameUpdateMessage = { gameConclusion: servergame.basegame.gameConclusion, moves: servergame.basegame.moves.map((m) => simplifyMove(m)), participantState: getParticipantState( servergame.match, color, servergame.basegame.whosTurn, ), forceSync, }; // Include timer info if it's timed if (!servergame.basegame.untimed) messageContents.clockValues = getGameClockValues(servergame.basegame); return messageContents; } /** * Alerts all players in the game of the rating changes of the game * @param match - The game * @param ratingdata - The rating data */ function sendRatingChangeToAllPlayers(match: MatchInfo, ratingdata: RatingData): void { const messageContents = getRatingChangeMessageContents(ratingdata); for (const playerdata of Object.values(match.playerData)) { if (playerdata.socket === undefined) continue; // Not connected, can't send message sendSocketMessage(playerdata.socket, 'game', 'gameratingchange', messageContents); } } /** * Calculates the json object we send to the client's containing the * rating changes from the results of the rated game. */ function getRatingChangeMessageContents( ratingdata: RatingData, ): PlayerGroup { const messageContents: PlayerGroup = {}; for (const [playerStr, playerRating] of Object.entries(ratingdata)) { messageContents[Number(playerStr) as Player] = { newRating: { value: playerRating.elo_after_game!, confident: playerRating.rating_deviation_after_game! <= UNCERTAIN_LEADERBOARD_RD, }, change: playerRating.elo_change_from_game!, }; } return messageContents; } function getParticipantState(match: MatchInfo, color: Player, whosTurn: Player): ParticipantState { const opponentColor = typeutil.invertPlayer(color); const now = Date.now(); const opponentData = match.playerData[opponentColor]!; const participantState: ParticipantState = { drawOffer: { unconfirmed: doesColorHaveExtendedDrawOffer(match, opponentColor), // True if our opponent has extended a draw offer we haven't yet confirmed/denied lastOfferPly: getLastDrawOfferPlyOfColor(match, color), // The move ply WE HAVE last offered a draw, if we have, otherwise undefined. }, }; // Include other relevant stuff if defined... // Only send AFK countdown to the opponent, not to the AFK player themselves. if (match.autoAFKResignTime !== undefined && color !== whosTurn) { const millisLeftUntilAutoAFKResign = match.autoAFKResignTime - now; participantState.millisUntilAutoAFKResign = millisLeftUntilAutoAFKResign; } // If their opponent has disconnected, send them that info too. if (opponentData.disconnect.timeToAutoLoss !== undefined) { participantState.disconnect = { millisUntilAutoDisconnectResign: opponentData.disconnect.timeToAutoLoss - now, wasByChoice: opponentData.disconnect.wasByChoice, }; } return participantState; } /** * Tests if the given socket belongs in the game. If so, it returns the color they are. * @param match - The game * @param ws - The websocket * @returns The color they are, if they belong, otherwise *undefined*. */ function doesSocketBelongToGame_ReturnColor( match: MatchInfo, ws: CustomWebSocket, ): Player | undefined { if (match.id === ws.metadata.subscriptions.game?.id) return ws.metadata.subscriptions.game?.color; // Color isn't provided in their subscriptions, perhaps this is a resync/refresh? return doesPlayerBelongToGame_ReturnColor(match, ws.metadata.memberInfo); } /** * Tests if the given player belongs in the game. If so, it returns the color they are. * @param match - The game * @param player - The player object with one of 2 properties: `member` or `browser`, depending on if they are signed in. * @returns The color they are, if they belong, otherwise *false*. */ function doesPlayerBelongToGame_ReturnColor( match: MatchInfo, player: AuthMemberInfo, ): Player | undefined { for (const [splayer, data] of Object.entries(match.playerData)) { const playercolor = Number(splayer) as Player; if (memberInfoEq(player, data.identifier)) return playercolor; } return undefined; } /** * Sends a websocket message to the specified color in the game. * @param match - The game * @param color - The color of the player in this game to send the message to * @param sub - Where this message should be routed to, client side. * @param action - The action the client should perform. If sub is "general" and action is "notify" or "notifyerror", then this needs to be the key of the message in the TOML, and we will auto-translate it! * @param value - The value to send to the client. */ function sendMessageToSocketOfColor( match: MatchInfo, color: Player, sub: string, action: string, value?: any, ): void { const data = match.playerData[color]; if (data === undefined) { logEventsAndPrint( `Tried to send a message to player ${color} when there isn't one in game!`, 'errLog.txt', ); return; } const ws = data.socket; if (!ws) return; // They are not connected, can't send message if (sub === 'general') { if (action === 'notify') return sendNotify(ws, value); // The value needs translating if (action === 'notifyerror') return sendNotifyError(ws, value); // The value needs translating } sendSocketMessage(ws, sub, action, value); // Value doesn't need translating, send normally. } /** * Safely prints a game to the console. Temporarily stringifies the * player sockets to remove self-referencing, and removes Node timers. * @param servergame - The game */ function printGame(servergame: ServerGame): void { const stringifiedGame = getSimplifiedGameString(servergame); console.log(JSON.parse(stringifiedGame)); // Turning it back into an object gives it a special formatting in the console, instead of just printing a string. } /** * Stringifies a game, by removing any recursion or Node timers from within, so it's JSON.stringify()'able. * @param servergame - The game * @returns The simplified game string */ function getSimplifiedGameString(servergame: ServerGame): string { // Only transfer interesting information. const players: PlayerGroup = {}; for (const [c, data] of Object.entries(servergame.match.playerData)) { players[Number(c) as Player] = data.identifier; } let moves: undefined | string[]; if (servergame.basegame.moves.length > 0) moves = servergame.basegame.moves.map((m) => m.token); const simplifiedGame = { id: servergame.match.id, timeCreated: `${servergame.basegame.metadata.UTCDate} ${servergame.basegame.metadata.UTCTime}`, timeEnded: servergame.match.timeEnded, variant: servergame.match.variant, clock: servergame.basegame.metadata.TimeControl, rated: servergame.match.rated, players, moves, }; return JSON.stringify(simplifiedGame); } /** * Returns *true* if the provided game has ended (gameConclusion truthy). * Games that are over are retained for a short period of time * to allow disconnected players to reconnect to see the results. * @param basegame - The game * @returns true if the game is over (gameConclusion truthy) */ function isGameOver(basegame: Game): boolean { return basegame.gameConclusion !== undefined; } /** * Returns true if the provided color has an actively running auto-resign timer. * NOT whether the 5-second reconnection cushion window timer has started. * @param match - The game they're in * @param color - The color they are in this game */ function isAutoResignDisconnectTimerActiveForColor(match: MatchInfo, color: Player): boolean { // If these are defined, then the timer is defined. return match.playerData[color]!.disconnect.timeToAutoLoss !== undefined; } /** * Sends the current clock values to the player who just moved. * @param servergame - The game */ function sendUpdatedClockToColor(servergame: ServerGame, color: Player): void { if (color !== p.BLACK && color !== p.WHITE) { logEventsAndPrint( `Color must be white or black when sending clock to color! Got: ${color}`, 'errLog.txt', ); return; } if (servergame.basegame.untimed) return; // Don't send clock values in an untimed game const message = getGameClockValues(servergame.basegame); const playerSocket = servergame.match.playerData[color]!.socket; if (!playerSocket) return; // They are not connected, can't send message sendSocketMessage(playerSocket, 'game', 'clock', message); } /** * Return the clock values of the servergame that can be sent to a client or logged. * It also includes who's clock is currently counting down, if one is. * This also updates the clocks, as the players current time should not be the same as when their turn first started. * @param basegame - The game */ function getGameClockValues(basegame: Game): ClockValues { if (basegame.untimed) throw new Error('Tried to get values of clocks from a game that had none!'); updateClockValues(basegame); return clock.createEdit(basegame.clocks); } /** * Update the games clock values. This is NOT called after the clocks are pushed, * This is called right before we send clock information to the client, * so that it's as accurate as possible. * @param basegame - The game */ function updateClockValues(basegame: Game): undefined { const now = Date.now(); if (basegame.untimed || !isGameResignable(basegame) || isGameOver(basegame)) return; if (basegame.clocks.timeAtTurnStart === undefined) throw new Error('cannot update clock values when timeAtTurnStart is not defined!'); const timeElapsedSinceTurnStart = now - basegame.clocks.timeAtTurnStart; const newTime = basegame.clocks.timeRemainAtTurnStart! - timeElapsedSinceTurnStart; const playerdata = basegame.clocks.currentTime; if (playerdata[basegame.whosTurn] === undefined) { logEventsAndPrint( `Cannot update games clock values when whose turn doesn't have a clock! "${basegame.whosTurn}"`, 'errLog.txt', ); return; } playerdata[basegame.whosTurn] = newTime; return; } /** * Sends a move to the player provided * @param servergame - The game * @param color - The color of the player to send the latest move to */ function sendMoveToColor({ basegame, match }: ServerGame, color: Player, move: MoveRecord): void { if (!(color in match.playerData)) { logEventsAndPrint( `Color to send move to must be one that is in the game (white or black)! ${color}`, 'errLog.txt', ); return; } const message: OpponentsMoveMessage = { move: simplifyMove(move), gameConclusion: basegame.gameConclusion, moveNumber: basegame.moves.length, }; if (!basegame.untimed) message.clockValues = getGameClockValues(basegame); const sendToSocket = match.playerData[color]!.socket; if (!sendToSocket) return; // They are not connected, can't send message sendSocketMessage(sendToSocket, 'game', 'move', message); } /** * Simplifies a game's move into the minimal info needed for the client to reconstruct the move. */ function simplifyMove(move: MoveRecord): { token: string } { return { token: move.token }; } /** * Cancel the timer to delete a game after it has ended if it is currently running. */ function cancelDeleteGameTimer(match: MatchInfo): void { clearTimeout(match.deleteTimeoutID); } /** * Tests if the game is resignable (at least 2 moves have been played). * If not, then the game is abortable. * @param basegame - The game * @returns *true* if the game is resignable. */ function isGameResignable(basegame: Game): boolean { return basegame.moves.length > 1; } /** * Tests if the game has just become resignable with the latest move (exactly 2 moves have been played). * @param basegame - The game * @returns *true* if the game has just become resignable after the last move. */ function isGameBorderlineResignable(basegame: Game): boolean { return basegame.moves.length === 2; } /** * Returns the color of the player that played that moveIndex within the moves list. * Returns error if index -1 * @param basegame * @param i - The moveIndex * @returns - The color that played the moveIndex */ function getColorThatPlayedMoveIndex(basegame: Game, i: number): Player { const turnOrder = basegame.gameRules.turnOrder; if (i === -1) return turnOrder[turnOrder.length - 1]!; return turnOrder[i % turnOrder.length]!; } export type { ServerGame, MatchInfo, PlayerData, PlayerDisconnect }; export default { initMatch, subscribeClientToGame, unsubClientFromGame, resyncToGame, assignWhiteBlackPlayersFromInvite, constructMetadataOfGame, broadcastGameUpdate, sendGameUpdateToColor, sendRatingChangeToAllPlayers, doesSocketBelongToGame_ReturnColor, sendMessageToSocketOfColor, printGame, getSimplifiedGameString, isGameOver, isAutoResignDisconnectTimerActiveForColor, getGameClockValues, sendUpdatedClockToColor, sendMoveToColor, cancelDeleteGameTimer, isGameResignable, isGameBorderlineResignable, getColorThatPlayedMoveIndex, getRatingDataForGamePlayers, }; ================================================ FILE: src/server/game/gamemanager/joingame.ts ================================================ // src/server/game/gamemanager/joingame.ts /** * This script checks if a user belongs to a game, when they send the 'joingame' * message, and if so, sends them the game info */ import type { CustomWebSocket } from '../../socket/socketUtility.js'; import gameutility from './gameutility.js'; import liveGameValues from './liveGameValues.js'; import { getGameBySocket } from './gamemanager.js'; import { cancelAutoAFKResignTimer, cancelDisconnectTimer } from './afkdisconnect.js'; /** * The method that fires when a client sends the 'joingame' command after refreshing the page. * This should fetch any game their in and reconnect them to it. * @param ws - Their new websocket */ function onJoinGame(ws: CustomWebSocket): void { const servergame = getGameBySocket(ws); if (!servergame) return; // They don't belong in a game, don't join them in one. const colorPlayingAs = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; gameutility.subscribeClientToGame(servergame, ws, colorPlayingAs); // Cancel the timer that auto loses them by AFK, IF IT is their turn! if (servergame.basegame.whosTurn === colorPlayingAs) { const hadAFKTimer = servergame.match.autoAFKResignTime !== undefined; cancelAutoAFKResignTimer(servergame, true); if (hadAFKTimer) liveGameValues.onPlayerAFKReturn(servergame); } cancelDisconnectTimer(servergame.match, colorPlayingAs); liveGameValues.onPlayerReconnected(servergame, colorPlayingAs); } export { onJoinGame }; ================================================ FILE: src/server/game/gamemanager/liveGameRestore.ts ================================================ // src/server/game/gamemanager/liveGameRestore.ts /** * This script restores live games from the database on server startup. * * It reads all rows from live_games and live_player_games, reconstructs * the full ServerGame objects (metadata, clocks, boardsim, player identities), * and determines which pending timers (AFK resign, auto time loss, disconnect, * delete) need to be reinstated. * * See dev-utils/live-game-persistence.md for the schema and restoration details. */ import type { MoveRecord } from '../../../shared/chess/logic/movepiece.js'; import type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js'; import type { AuthMemberInfo } from '../../types.js'; import type { GameConclusion } from '../../../shared/chess/util/winconutil.js'; import type { LiveGamesRecord } from '../../database/liveGamesManager.js'; import type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js'; import type { LivePlayerGamesRecord } from '../../database/livePlayerGamesManager.js'; import type { MatchInfo, PlayerData, ServerGame } from './gameutility.js'; import type { ClockValues, MetaData, TimeControl } from '../../../shared/types.js'; import type { Condition, DrawCondition, WinCondition, } from '../../../shared/chess/util/winconutil.js'; import jsutil from '../../../shared/util/jsutil.js'; import gamefile from '../../../shared/chess/logic/gamefile.js'; import movepiece from '../../../shared/chess/logic/movepiece.js'; import icnconverter from '../../../shared/chess/logic/icn/icnconverter.js'; import metadatautil from '../../../shared/chess/util/metadatautil.js'; import { players as p } from '../../../shared/chess/util/typeutil.js'; import servermetadatautil from '../servermetadatautil.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; import { getMemberDataByCriteria } from '../../database/memberManager.js'; import { getLivePlayerGamesForGame } from '../../database/livePlayerGamesManager.js'; import { getAllLiveGames, deleteLiveGame } from '../../database/liveGamesManager.js'; // Types ----------------------------------------------------------------------------------------- /** * Result of restoring games. The caller is responsible for adding them * to activeGames and setting up their event connections. */ interface RestoredGame { servergame: ServerGame; /** Timers that need to be started after adding to activeGames. */ pendingTimers: PendingTimers; } /** Timers that may need to be started for a restored game, based on its state at the time of server shutdown. */ interface PendingTimers { /** If defined, the delete game timer should fire after this many ms. 0 means immediately. */ deleteTimerMs?: number; /** If defined, the AFK resign timer should fire after this many ms. 0 means immediately. */ afkResignTimerMs?: number; /** Per-player disconnect state to restore. */ disconnectTimers: PlayerGroup; /** * If defined, the auto time loss timer for the current player's * turn should fire after this many ms. 0 means immediately. */ autoTimeLossMs?: number; } /** Represents the state of a player's disconnect timer that needs to be restored. */ interface DisconnectTimerState { /** 'cushion' = still in 5s cushion, 'timer' = auto-resign timer active, 'fresh' = was connected before restart */ type: 'cushion' | 'timer' | 'fresh'; /** Milliseconds remaining until the timer fires. 0 or negative means immediately. */ remainingMs: number; /** Whether the disconnect was by choice. */ byChoice: boolean; } // Restoration ------------------------------------------------------------------------------------ /** * Restores all live games from the database. * Called once during server startup, after initDatabase() and before accepting connections. * * @returns An array of restored ServerGame objects with their pending timers. * The caller is responsible for integrating these into the active game system. */ function restoreAllLiveGames(): RestoredGame[] { const liveGameRows = getAllLiveGames(); if (liveGameRows.length === 0) return []; console.log(`Restoring ${liveGameRows.length} live game(s) from database.`); const restored: RestoredGame[] = []; for (const gameRow of liveGameRows) { try { const playerRows = getLivePlayerGamesForGame(gameRow.game_id); if (playerRows.length !== 2) { logEventsAndPrint( `Live game ${gameRow.game_id} has ${playerRows.length} player rows, expected 2. Skipping restoration of this game.`, 'errLog.txt', ); deleteLiveGame(gameRow.game_id); continue; } const result = restoreSingleGame(gameRow, playerRows); restored.push(result); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logEventsAndPrint( `Failed to restore live game ${gameRow.game_id}: ${message}`, 'errLog.txt', ); // Delete the corrupt game from the database so it doesn't block future restarts. deleteLiveGame(gameRow.game_id); } } return restored; } /** * Restores a single live game from its database rows. */ function restoreSingleGame( gameRow: LiveGamesRecord, playerRows: LivePlayerGamesRecord[], ): RestoredGame { // 1. Reconstruct AuthMemberInfo for each player const playerIdentities = reconstructPlayerIdentities(playerRows); // 2. Reconstruct MetaData const gameMetadata = reconstructMetadata(gameRow, playerRows, playerIdentities); // 3. Reconstruct clock values for timed games const clockValues = reconstructClockValues(gameRow, playerRows); // 4. Reconstruct game conclusion const gameConclusion = reconstructConclusion(gameRow); // 8. Reconstruct MatchInfo const matchInfo = reconstructMatchInfo(gameRow, playerRows, playerIdentities); // 5. Create the basegame const basegame = gamefile.initGame( gameMetadata, gameRow.time_created, matchInfo.variant, gameConclusion, clockValues, ); // Note: clock state (ticking color, timeAtTurnStart) is already set correctly // by clock.edit() inside initGame() via the clockValues we pass in. const servergame: ServerGame = { match: matchInfo, basegame }; // 6. Parse & replay moves, conditionally constructing boardsim const moves: MoveRecord[] = parseMoves(gameRow.moves); if (gameRow.validate_moves) { const boardsim = gamefile.initBoard( basegame.gameRules, matchInfo.variant, basegame.dateTimestamp, ); servergame.boardsim = boardsim; // Pushes moves to BOTH the basegame and boardsim movepiece.makeAllMovesInGame({ basegame, boardsim }, moves); } else { // Push all the moves to JUST the basegame for (const move of moves) { basegame.moves.push(jsutil.deepCopyObject(move)); } // Update whosTurn based on move count basegame.whosTurn = basegame.gameRules.turnOrder[ basegame.moves.length % basegame.gameRules.turnOrder.length ]!; } // 9. Compute pending timers const pendingTimers = computePendingTimers(gameRow, playerRows, servergame); return { servergame, pendingTimers }; } // Helper functions --------------------------------------------------------------------------------- /** * Reconstructs AuthMemberInfo for each player from the database rows. */ function reconstructPlayerIdentities( playerRows: LivePlayerGamesRecord[], ): PlayerGroup { const identities: PlayerGroup = {}; for (const row of playerRows) { const player = row.player_number as Player; if (row.user_id !== null) { // Signed-in player: look up username and roles from members table const memberData = getMemberDataByCriteria( ['username', 'roles'], 'user_id', row.user_id, ); if (memberData) { let roles = null; try { roles = memberData.roles ? JSON.parse(memberData.roles) : null; } catch { logEventsAndPrint( `Failed to parse roles for user_id ${row.user_id} during game restoration.`, 'errLog.txt', ); } identities[player] = { signedIn: true, user_id: row.user_id, username: memberData.username, roles, browser_id: row.browser_id, }; } else { // User was deleted since the game started. Treat as guest. identities[player] = { signedIn: false, browser_id: row.browser_id, }; } } else { // Guest player identities[player] = { signedIn: false, browser_id: row.browser_id, }; } } return identities; } /** * Reconstructs MetaData from the stored atomic values. */ function reconstructMetadata( gameRow: LiveGamesRecord, playerRows: LivePlayerGamesRecord[], playerIdentities: PlayerGroup, ): MetaData { const white = playerIdentities[p.WHITE]!; const black = playerIdentities[p.BLACK]!; // Find per-player rows for signed-in identity lookup const whiteRow = playerRows.find((r) => r.player_number === p.WHITE)!; const blackRow = playerRows.find((r) => r.player_number === p.BLACK)!; return servermetadatautil.buildGameMetadata( Boolean(gameRow.rated), gameRow.variant as VariantCode, gameRow.clock as TimeControl, gameRow.time_created, { name: white.signedIn ? white.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie id: white.signedIn ? white.user_id : undefined, elo: whiteRow.elo ?? undefined, }, { name: black.signedIn ? black.username : metadatautil.GUEST_NAME_ICN_METADATA, // Protect browser's browser-id cookie id: black.signedIn ? black.user_id : undefined, elo: blackRow.elo ?? undefined, }, ); } /** * Reconstructs ClockValues from stored per-player times. */ function reconstructClockValues( gameRow: LiveGamesRecord, playerRows: LivePlayerGamesRecord[], ): ClockValues | undefined { // Untimed games don't have clock values if (gameRow.clock === '-') return undefined; const clocks: PlayerGroup = {}; for (const row of playerRows) { if (row.time_remaining_ms !== null) { clocks[row.player_number as Player] = row.time_remaining_ms; } } const colorTicking = gameRow.color_ticking === null ? undefined : (gameRow.color_ticking as Player); const timeColorTickingLosesAt = colorTicking !== undefined ? gameRow.clock_snapshot_time! + clocks[colorTicking]! : undefined; return { clocks, colorTicking, timeColorTickingLosesAt, }; } /** * Reconstructs GameConclusion from stored values. */ function reconstructConclusion(gameRow: LiveGamesRecord): GameConclusion | undefined { if (gameRow.conclusion_condition === null) return undefined; // Game is ongoing still const condition = gameRow.conclusion_condition as Condition; if (gameRow.conclusion_victor !== null) { // Decisive result — someone won return { condition: condition as WinCondition, victor: gameRow.conclusion_victor as Player, }; } else if (condition === 'aborted') { // Aborted — victor is undefined return { condition: 'aborted' }; } else { // Draw — victor is null return { condition: condition as DrawCondition, victor: null, }; } } /** * Reconstructs the MatchInfo from stored values. */ function reconstructMatchInfo( gameRow: LiveGamesRecord, playerRows: LivePlayerGamesRecord[], playerIdentities: PlayerGroup, ): MatchInfo { const playerData: PlayerGroup = {}; for (const row of playerRows) { const identity = playerIdentities[row.player_number as Player]!; playerData[row.player_number as Player] = { identifier: identity, lastOfferPly: row.last_draw_offer_ply ?? undefined, disconnect: { startID: undefined, startTime: row.disconnect_cushion_end_time ?? undefined, timeoutID: undefined, timeToAutoLoss: undefined, wasByChoice: undefined, }, }; } return { id: gameRow.game_id, variant: gameRow.variant as VariantCode, timeCreated: gameRow.time_created, timeEnded: gameRow.time_ended ?? undefined, publicity: gameRow.private === 1 ? 'private' : 'public', rated: gameRow.rated === 1, clock: gameRow.clock as TimeControl, playerData, drawOfferState: gameRow.draw_offer_state === null ? undefined : (gameRow.draw_offer_state as Player), autoAFKResignTime: gameRow.afk_resign_time ?? undefined, positionPasted: gameRow.position_pasted === 1, }; } /** * Parses the moves string back into move objects. */ function parseMoves(movesString: string): MoveRecord[] { if (movesString === '') return []; return icnconverter.parseShortFormMoves(movesString); } /** * Computes which timers need to be started after restoration. */ function computePendingTimers( gameRow: LiveGamesRecord, playerRows: LivePlayerGamesRecord[], servergame: ServerGame, ): PendingTimers { const now = Date.now(); const timers: PendingTimers = { disconnectTimers: {}, }; // Delete timer for concluded games if (gameRow.delete_time !== null) { const remaining = gameRow.delete_time - now; timers.deleteTimerMs = Math.max(remaining, 0); } // AFK resign timer if (gameRow.afk_resign_time !== null) { const remaining = gameRow.afk_resign_time - now; timers.afkResignTimerMs = Math.max(remaining, 0); } // Auto time loss timer for timed, ongoing games if (!servergame.basegame.untimed && gameRow.color_ticking !== null) { const tickingTime = servergame.basegame.clocks.currentTime[gameRow.color_ticking as Player]!; timers.autoTimeLossMs = Math.max(tickingTime, 0); } // Per-player disconnect timers for (const row of playerRows) { const player = row.player_number as Player; if (row.disconnect_resign_time !== null) { // Case 1: Auto-resign timer was already active const remaining = row.disconnect_resign_time - now; timers.disconnectTimers[player] = { type: 'timer', remainingMs: Math.max(remaining, 0), byChoice: row.disconnect_by_choice === 1, }; } else if (row.disconnect_cushion_end_time !== null) { // Case 2: Still in the 5-second cushion period const remaining = row.disconnect_cushion_end_time - now; timers.disconnectTimers[player] = { type: 'cushion', remainingMs: Math.max(remaining, 0), byChoice: row.disconnect_by_choice === 1, }; } else { // Case 3: Was connected before restart. Give them a fresh disconnect timer // (not by choice, since the server restart caused the disconnection). timers.disconnectTimers[player] = { type: 'fresh', remainingMs: -1, // Signal that a fresh timer should be started byChoice: false, }; } } return timers; } // Exports -------------------------------------------------------------------------------------------- export { restoreAllLiveGames }; ================================================ FILE: src/server/game/gamemanager/liveGameValues.ts ================================================ // src/server/game/gamemanager/liveGameValues.ts /** * This script keeps the live-state of the active games in the database up to date. * It computes the column values to be persisted for each state-change event, * then updates the live_games and live_player_games tables accordingly. * * See dev-utils/live-game-persistence.md for the schema and event matrix. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { LiveGameData, LiveGamesRecord } from '../../database/liveGamesManager.js'; import type { ServerGame, PlayerData, PlayerDisconnect } from './gameutility.js'; import type { LivePlayerDisconnectData, LivePlayerGamesRecord, } from '../../database/livePlayerGamesManager.js'; import { Game } from '../../../shared/chess/logic/gamefile.js'; import icnconverter from '../../../shared/chess/logic/icn/icnconverter.js'; import { players as p } from '../../../shared/chess/util/typeutil.js'; import { timeBeforeGameDeletionMillis } from './gameutility.js'; import { insertLiveGame, updateLiveGame, deleteLiveGame } from '../../database/liveGamesManager.js'; import { insertLivePlayerGame, updateLivePlayerGame, } from '../../database/livePlayerGamesManager.js'; // Value Computation ---------------------------------------------------------------------------------- /** * Computes the moves string from a ServerGame's move list, including embedded clock stamps. * Uses the ICN compact format: `1,2>3,4{[%clk 0:09:56.7]}|5,6>7,8=Q{[%clk 0:09:45.2]}` */ function getMovesString(servergame: ServerGame): string { const { basegame } = servergame; if (basegame.moves.length === 0) return ''; return icnconverter.getShortFormMovesFromMoves(basegame.moves, { compact: true, spaces: false, comments: !basegame.untimed, move_numbers: false, }); } /** * Extracts the elo display string for a player from game metadata. */ function getPlayerEloString(basegame: Game, player: Player): string | null { // The elo is stored in metadata as WhiteElo/BlackElo strings like "1500" or "1200?" // prettier-ignore const eloKey = player === p.WHITE ? 'WhiteElo' : player === p.BLACK ? 'BlackElo' : (() => { throw new Error(`Invalid player ${player} when getting elo string`); })(); return basegame.metadata[eloKey] ?? null; } /** * Returns the disconnect-related live_player_games columns for a player's current disconnect state. */ function getDisconnectColumnData(disconnect: PlayerDisconnect): LivePlayerDisconnectData { return { disconnect_cushion_end_time: disconnect.startTime ?? null, disconnect_resign_time: disconnect.timeToAutoLoss ?? null, disconnect_by_choice: disconnect.wasByChoice !== undefined ? (disconnect.wasByChoice ? 1 : 0) : null, }; } /** * Updates time_remaining_ms for all players from the current clock state. * No-op for untimed games. */ function persistCurrentClockTimes(servergame: ServerGame): void { const { basegame, match } = servergame; if (basegame.untimed) return; for (const playerStr of Object.keys(match.playerData)) { const player = Number(playerStr) as Player; updateLivePlayerGame(match.id, player, { time_remaining_ms: basegame.clocks.currentTime[player] ?? null, }); } } /** * Builds a LivePlayerGamesRecord from player data. */ function buildPlayerRecord( game_id: number, player: Player, playerData: PlayerData, basegame: Game, ): LivePlayerGamesRecord { const { identifier, disconnect } = playerData; return { game_id, player_number: player, user_id: identifier.signedIn ? identifier.user_id : null, browser_id: identifier.browser_id, elo: getPlayerEloString(basegame, player), last_draw_offer_ply: playerData.lastOfferPly ?? null, time_remaining_ms: basegame.untimed ? null : (basegame.clocks.currentTime[player] ?? null), ...getDisconnectColumnData(disconnect), }; } // Persistence Events --------------------------------------------------------------------------------- /** * Called when a new game is created. Inserts the full initial state into both tables. */ function onGameCreated(servergame: ServerGame): void { const { basegame, match } = servergame; const record: LiveGamesRecord = { game_id: match.id, time_created: match.timeCreated, variant: match.variant, clock: match.clock, rated: match.rated ? 1 : 0, private: match.publicity === 'private' ? 1 : 0, moves: '', color_ticking: null, clock_snapshot_time: null, draw_offer_state: null, conclusion_condition: null, conclusion_victor: null, time_ended: null, afk_resign_time: null, delete_time: null, position_pasted: 0, validate_moves: servergame.boardsim !== undefined ? 1 : 0, }; insertLiveGame(record); // Insert one row per player for (const [playerStr, playerData] of Object.entries(match.playerData)) { const player = Number(playerStr) as Player; const playerRecord = buildPlayerRecord(match.id, player, playerData, basegame); insertLivePlayerGame(playerRecord); } } /** * Called after a move is submitted and the game state is updated. * Updates the moves string, clock state, and per-player time. */ function onMoveSubmitted(servergame: ServerGame): void { const { basegame, match } = servergame; const gameUpdates: Partial = { moves: getMovesString(servergame), }; if (!basegame.untimed) { gameUpdates.color_ticking = basegame.clocks.colorTicking ?? null; gameUpdates.clock_snapshot_time = basegame.clocks.timeAtTurnStart ?? null; } updateLiveGame(match.id, gameUpdates); persistCurrentClockTimes(servergame); } /** * Called when a game conclusion is set (checkmate, resignation, time loss, etc.). * Updates conclusion columns and sets the delete timer target. */ function onGameConcluded(servergame: ServerGame): void { const { basegame, match } = servergame; const conclusion = basegame.gameConclusion!; const gameUpdates: Partial = { conclusion_condition: conclusion.condition, conclusion_victor: conclusion.victor ?? null, time_ended: match.timeEnded!, delete_time: match.timeEnded! + timeBeforeGameDeletionMillis, draw_offer_state: null, // Draw offers are closed on conclusion afk_resign_time: null, // AFK timers are cancelled on conclusion }; // Stop clock state if (!basegame.untimed) { // Both color ticking and timeAtTurnStart are set to null on game end gameUpdates.color_ticking = null; gameUpdates.clock_snapshot_time = null; } updateLiveGame(match.id, gameUpdates); // Update time_remaining_ms for timed games (e.g., time loss sets loser to 0) persistCurrentClockTimes(servergame); } /** * Called when a draw offer is extended. */ function onDrawOfferExtended(servergame: ServerGame, offeringColor: Player): void { updateLiveGame(servergame.match.id, { draw_offer_state: offeringColor, }); updateLivePlayerGame(servergame.match.id, offeringColor, { last_draw_offer_ply: servergame.match.playerData[offeringColor]!.lastOfferPly ?? null, }); } /** * Called when a draw offer is declined (or auto-declined on move). */ function onDrawOfferDeclined(servergame: ServerGame): void { updateLiveGame(servergame.match.id, { draw_offer_state: null, }); } /** * Called when a player disconnects (either by choice or network interruption). * Persists the disconnect state for that player. */ function onPlayerDisconnected(servergame: ServerGame, color: Player): void { const playerDisconnectData = servergame.match.playerData[color]!.disconnect; updateLivePlayerGame(servergame.match.id, color, getDisconnectColumnData(playerDisconnectData)); } /** * Called when a player reconnects. Clears their disconnect state. */ function onPlayerReconnected(servergame: ServerGame, color: Player): void { updateLivePlayerGame(servergame.match.id, color, { disconnect_cushion_end_time: null, disconnect_resign_time: null, disconnect_by_choice: null, }); } /** * Called when a player goes AFK. Persists the AFK resign timestamp. */ function onPlayerAFK(servergame: ServerGame): void { updateLiveGame(servergame.match.id, { afk_resign_time: servergame.match.autoAFKResignTime ?? null, }); } /** * Called when a player returns from AFK. Clears the AFK resign timestamp. */ function onPlayerAFKReturn(servergame: ServerGame): void { updateLiveGame(servergame.match.id, { afk_resign_time: null, }); } /** * Called when a position is pasted. Sets position_pasted and clears validate_moves. */ function onPositionPasted(servergame: ServerGame): void { updateLiveGame(servergame.match.id, { position_pasted: 1, validate_moves: 0, }); } /** * Called when a game is fully deleted/logged. Removes the live game from the database. */ function onGameDeleted(game_id: number): void { deleteLiveGame(game_id); } // Exports -------------------------------------------------------------------------------------------- export default { // Persistence Events onGameCreated, onMoveSubmitted, onGameConcluded, onDrawOfferExtended, onDrawOfferDeclined, onPlayerDisconnected, onPlayerReconnected, onPlayerAFK, onPlayerAFKReturn, onPositionPasted, onGameDeleted, }; ================================================ FILE: src/server/game/gamemanager/movesubmission.ts ================================================ // src/server/game/gamemanager/movesubmission.ts /** * The script handles when a user submits a move in * the game they are in, and does basic checks to make sure it's valid. */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { FullGame } from '../../../shared/chess/logic/gamefile.js'; import type { MoveRecord } from '../../../shared/chess/logic/movepiece.js'; import type { MoveParsed } from '../../../shared/chess/logic/icn/icnconverter.js'; import type { GameConclusion } from '../../../shared/chess/util/winconutil.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import * as z from 'zod'; import bimath from '../../../shared/util/math/bimath.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import movepiece from '../../../shared/chess/logic/movepiece.js'; import winconutil from '../../../shared/chess/util/winconutil.js'; import icnconverter from '../../../shared/chess/logic/icn/icnconverter.js'; import movevalidation from '../../../shared/chess/logic/movevalidation.js'; import gamefileutility from '../../../shared/chess/util/gamefileutility.js'; import liveGameValues from './liveGameValues.js'; import { declineDraw } from './onOfferDraw.js'; import { resyncToGame } from './resync.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; import gameutility, { ServerGame } from './gameutility.js'; import { pushGameClock, finalizeConclusion, teardownGame } from './gamemanager.js'; /** The zod schema for validating the contents of the submitmove message. */ const submitmoveschem = z.strictObject({ move: z.string(), moveNumber: z.int(), gameConclusion: winconutil.gameConclusionSchema.optional(), }); type SubmitMoveMessage = z.infer; /** The number of additional coordinate digits allowed per second of game duration. */ const DIGITS_PER_SECOND = 4.5; /** * * Call when a websocket submits a move. Performs some checks, * adds the move to the game's move list, adjusts the game's * properties, and alerts their opponent of the move. * @param ws - The websocket submitting the move. * @param servergame - The game they are in. * @param messageContents - An object containing the properties `move`, `moveNumber`, and `gameConclusion`. */ function submitMove( ws: CustomWebSocket, servergame: ServerGame, messageContents: SubmitMoveMessage, ): void { // They can't submit a move if they aren't subscribed to a game if (!ws.metadata.subscriptions.game) { console.error( 'Player tried to submit a move when not subscribed. They should only send move when they are in sync, not right after the socket opens.', ); sendSocketMessage( ws, 'general', 'printerror', 'Failed to submit move. You are not subscribed to a game.', ); return; } // Their subscription info should tell us what game they're in, including the color they are. const color = ws.metadata.subscriptions.game.color; const opponentColor = typeutil.invertPlayer(color); // If the game is already over, don't accept it. if (gameutility.isGameOver(servergame.basegame)) return; // Make sure it's their turn if (servergame.basegame.whosTurn !== color) { // Can occasionally happen if they in rapid succession send a resync request and // a move submission, then when their client resyncs they submit their move again. // Just discard this submission and resync just in case they are actually out of sync. resyncToGame(ws, servergame.match.id); return; } // Make sure the move number matches up. If not, they're out of sync, resync them! const expectedMoveNumber = servergame.basegame.moves.length + 1; if (messageContents.moveNumber !== expectedMoveNumber) { const errString = `Client submitted a move with incorrect move number! Expected: ${expectedMoveNumber} Message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`; logEventsAndPrint(errString, 'hackLog.txt'); resyncToGame(ws, servergame.match.id); return; } // Verify the move is in the correct format const moveParsed = doesMoveCheckOut(messageContents.move); if (moveParsed === false) { const errString = `Player sent a move in an invalid format. The message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`; logEventsAndPrint(errString, 'hackLog.txt'); sendSocketMessage(ws, 'general', 'printerror', 'Invalid move format.'); return; } // Check if the move exceeds the soft distance cap based on game duration if (!isMoveWithinDistanceCap(moveParsed, servergame.match.timeCreated)) { const errString = `Player sent a move that exceeds the distance cap for game duration. The message: ${JSON.stringify(messageContents)}. User: ${JSON.stringify(ws.metadata.memberInfo)}`; logEventsAndPrint(errString, 'hackLog.txt'); sendSocketMessage( ws, 'general', 'notifyerror', 'Move not accepted. Distance exceeds allowed limit for game duration.', ); return; } // Use server-side validation if the boardsim exists, otherwise trust the client's reported conclusion. const moveRecord = servergame.boardsim !== undefined ? applyServerValidatedMove(ws, servergame, messageContents, moveParsed, color) : applyClientReportedMove(ws, servergame, messageContents, moveParsed, color); if (moveRecord === undefined) return; // The move was illegal, or the conclusion was invalid, and we've already sent the appropriate error message to the client, so just exit. // console.log(`Accepted a move! Their websocket message data:`) // console.log(messageContents) // console.log("New move list:") // console.log(game.moves); declineDraw(ws, servergame); // Auto-decline any open draw offer on move submissions // Persist the move and updated game state to the database. liveGameValues.onMoveSubmitted(servergame); const gameIsOver = gameutility.isGameOver(servergame.basegame); if (gameIsOver) { // If the game ended, finalize state before sending: stops the clock and persists to DB. // This ensures both clients receive the same frozen clock values that are in the DB. finalizeConclusion(servergame, servergame.basegame.gameConclusion); // Send a whole gameupdate to the move-submitter gameutility.sendGameUpdateToColor(servergame, color, false); } else { // Just send updated clocks to the move-submitter gameutility.sendUpdatedClockToColor(servergame, color); } // Send their move to their opponent. gameutility.sendMoveToColor(servergame, opponentColor, moveRecord); // Tear down the game after sends. teardownGame skips broadcastGameUpdate for // move-triggered conclusions since clients were already notified individually above. if (gameIsOver) teardownGame(servergame); } /** * Validates the move against the server-side board simulation, makes it, and updates the game state. * Returns the resulting MoveRecord, or undefined if the move was illegal (error messages are sent to the client). */ function applyServerValidatedMove( ws: CustomWebSocket, servergame: ServerGame, messageContents: SubmitMoveMessage, moveParsed: MoveParsed, color: Player, ): MoveRecord | undefined { // Makes ts happy knowing boardsim is already defined const gamefile: FullGame = { basegame: servergame.basegame, boardsim: servergame.boardsim! }; const validationResult = movevalidation.validateMove(gamefile, moveParsed); if (!validationResult.valid) { const errString = `Player sent an illegal move: "${messageContents.move}" Reason: ${validationResult.reason} User: ${JSON.stringify(ws.metadata.memberInfo)}`; logEventsAndPrint(errString, 'hackLog.txt'); // Send the sender a gameupdate to correct their board if a bug somehow caused this gameutility.sendGameUpdateToColor(servergame, color, true); // forceSync true to force their move list to match ours // Send notifyerror last to override any previous toasts sendSocketMessage( ws, 'general', 'notifyerror', 'Oops! That was an illegal move. If this is a bug, please report it!', ); return; } // Generate and make the move in the logical game const fullMove = movepiece.generateAndMakeMove(gamefile, validationResult.tagged); // Set the clock stamp on both the boardsim's MoveFull and the basegame's MoveRecord. // (makeMove creates a separate MoveRecord object for basegame, so we must set both.) const moveRecord = servergame.basegame.moves[servergame.basegame.moves.length - 1]!; const clockStamp = pushGameClock(servergame); if (clockStamp !== undefined) { fullMove.clockStamp = clockStamp; moveRecord.clockStamp = clockStamp; } // The server determines the game conclusion; discard any client-claimed conclusion. // Auto-sets basegame.gameConclusion if the move triggers a conclusion. gamefileutility.doGameOverChecks(gamefile); return moveRecord; } /** * Accepts a move for large variants without server-side validation, and updates the game state. * Returns the resulting MoveRecord, or undefined if the claimed game conclusion was invalid. */ function applyClientReportedMove( ws: CustomWebSocket, servergame: ServerGame, messageContents: SubmitMoveMessage, moveParsed: MoveParsed, color: Player, ): MoveRecord | undefined { if (!doesGameConclusionCheckOut(messageContents.gameConclusion, color)) { const errString = `Player sent a conclusion that doesn't check out! Invalid. The message: "${JSON.stringify(messageContents)}" User: ${JSON.stringify(ws.metadata.memberInfo)}`; logEventsAndPrint(errString, 'hackLog.txt'); sendSocketMessage(ws, 'general', 'printerror', 'Invalid game conclusion.'); return; } const moveRecord: MoveRecord = { startCoords: moveParsed.startCoords, endCoords: moveParsed.endCoords, token: moveParsed.token, // clockStamp added below }; if (moveParsed.promotion !== undefined) moveRecord.promotion = moveParsed.promotion; // Must be BEFORE pushing the clock, because pushGameClock() depends on the length of the moves. servergame.basegame.moves.push(moveRecord); // Add the move to the list! // Must be AFTER pushing the move, because pushGameClock() depends on the length of the moves. const clockStamp = pushGameClock(servergame); // Flip whos turn and adjust the game properties if (clockStamp !== undefined) moveRecord.clockStamp = clockStamp; // If the clock stamp was set, add it to the move. // Manually set basegame.gameConclusion to client-reported conclusion gamefileutility.setConclusion(servergame.basegame, messageContents.gameConclusion); return moveRecord; } /** * Calculates the maximum distance a move should be allowed based on game duration. * @param gameStartTime - When the game was created (in milliseconds) * @returns Maximum allowed coordinate digits */ function getMaxAllowedCoordinateDigits(gameStartTime: number): number { const currentTime = Date.now(); const gameElapsedSeconds = (currentTime - gameStartTime) / 1000; // Start with a baseline of 1 digit (allows coordinates like -9 to 9) const baselineDigits = 1; const extraDigits = gameElapsedSeconds * DIGITS_PER_SECOND; return Math.floor(baselineDigits + extraDigits); } /** * Checks if a move's coordinates exceed the soft distance cap based on game duration. * Only checks end coordinates since start coordinates are known to be safe. * @param moveParsed - The parsed move to check * @param gameStartTime - When the game was created (in milliseconds) * @returns true if the move is within allowed distance, false otherwise */ function isMoveWithinDistanceCap(moveParsed: MoveParsed, gameStartTime: number): boolean { const maxAllowedDigits = getMaxAllowedCoordinateDigits(gameStartTime); // Only check end coordinates since start coordinates are safe const endXDigits = bimath.countDigits(moveParsed.endCoords[0]); const endYDigits = bimath.countDigits(moveParsed.endCoords[1]); const maxDigitsInMove = Math.max(endXDigits, endYDigits); return maxDigitsInMove <= maxAllowedDigits; } /** * Returns true if their submitted move is in the format `x,y>x,y=3N`. * @param move - Their move submission. * @returns The move, if correctly formatted, otherwise false. */ function doesMoveCheckOut(move: string): MoveParsed | false { // Is the move in the correct format? "x,y>x,y=N" let moveParsed: MoveParsed; try { moveParsed = icnconverter.parseTokenMove(move); } catch { // It either didn't pass the regex, or the promoted piece abbreviation was invalid. return false; } return moveParsed; } /** * Returns true if the provided game conclusion seems reasonable for their move submission. * An example of a not reasonable one would be if they claimed they won by their opponent resigning. * This does not run the checkmate algorithm, so it's not foolproof. * @param gameConclusion - Their claimed game conclusion. * @param color - The color they are in the game. * @returns *true* if their claimed conclusion seems reasonable. */ function doesGameConclusionCheckOut( gameConclusion: GameConclusion | undefined, color: Player, ): boolean { if (gameConclusion === undefined) return true; const { victor, condition } = gameConclusion; if (!winconutil.isConclusionMoveTriggered(condition)) return false; // We can't submit a move where our opponent wins const oppositeColor = typeutil.invertPlayer(color); return victor !== oppositeColor; } export { submitMove, submitmoveschem }; ================================================ FILE: src/server/game/gamemanager/onAFK.ts ================================================ // src/server/game/gamemanager/onAFK.ts /** * The script handles the route when users inform us they have gone AFK or returned from being AFK. */ import type { ServerGame } from './gameutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import gameutility from './gameutility.js'; import liveGameValues from './liveGameValues.js'; import { cancelAutoAFKResignTimer } from './afkdisconnect.js'; import { onPlayerLostByAbandonment } from './gamemanager.js'; //-------------------------------------------------------------------------------------------------------- /** * The length of the timer to auto resign somebody by being AFK/disconnected for too long. * This cannot change because the client is hard coded to play a low-time sound on timer start, * and a unique 10 second countdown at 10 seconds remaining. * Plus, they are the ones who tell us when they are AFK. This does not include the by default * 40-second pretimer they are allowed to be AFK before this 20s timer starts. */ const durationOfAutoResignTimerMillis = 1000 * 20; // 20 seconds. /** * Called when a client alerts us they have gone AFK. * Alerts their opponent, and starts a timer to auto-resign. * @param ws - The socket * @param servergame - The game they are in. */ function onAFK(ws: CustomWebSocket, servergame: ServerGame): void { const { match, basegame } = servergame; // console.log("Client alerted us they are AFK.") const color = gameutility.doesSocketBelongToGame_ReturnColor(match, ws)!; if (gameutility.isGameOver(basegame)) return console.error( 'Client submitted they are afk when the game is already over. Ignoring.', ); // Verify it's their turn (can't lose by afk if not) if (basegame.whosTurn !== color) return console.error("Client submitted they are afk when it's not their turn. Ignoring."); if (!basegame.untimed && gameutility.isGameResignable(basegame)) return console.error( 'Client submitted they are afk in a timed, resignable game. There is no afk auto-resign timers in timed games anymore.', ); if ( match.playerData[color]!.disconnect.startID !== undefined || match.playerData[color]!.disconnect.timeToAutoLoss !== undefined ) { return console.error( "Player's disconnect timer should have been cancelled before starting their afk timer!", ); } const opponentColor = typeutil.invertPlayer(color); // Start a 20s timer to auto terminate the game by abandonment. match.autoAFKResignTimeoutID = setTimeout( () => onPlayerLostByAbandonment(servergame, opponentColor), durationOfAutoResignTimerMillis, ); // The auto resign function should have 2 arguments: The game, and the color that won. match.autoAFKResignTime = Date.now() + durationOfAutoResignTimerMillis; // Persist the AFK state to the database. liveGameValues.onPlayerAFK(servergame); // Alert their opponent const value = { millisUntilAutoAFKResign: durationOfAutoResignTimerMillis }; gameutility.sendMessageToSocketOfColor(match, opponentColor, 'game', 'opponentafk', value); } /** * Called when a client alerts us they have returned from being AFK. * Alerts their opponent, and cancels the timer to auto-resign. * @param ws - The socket * @param servergame - The game they are in. */ function onAFK_Return(ws: CustomWebSocket, servergame: ServerGame): void { // console.log("Client alerted us they no longer AFK.") const color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws); if (gameutility.isGameOver(servergame.basegame)) return console.error( 'Client submitted they are back from being afk when the game is already over. Ignoring.', ); // Verify it's their turn (can't lose by afk if not) if (servergame.basegame.whosTurn !== color) return console.error( "Client submitted they are back from being afk when it's not their turn. Ignoring.", ); if (!servergame.basegame.untimed && gameutility.isGameResignable(servergame.basegame)) return console.error( 'Client submitted they are back from being afk in a timed, resignable game. There is no afk auto-resign timers in timed games anymore.', ); cancelAutoAFKResignTimer(servergame, true); liveGameValues.onPlayerAFKReturn(servergame); } export { onAFK, onAFK_Return }; ================================================ FILE: src/server/game/gamemanager/onOfferDraw.ts ================================================ // src/server/game/gamemanager/onOfferDraw.ts /** * This script contains the routes for extending, accepting, and rejecting * draw offers in online games. */ import type { ServerGame } from './gameutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import typeutil from '../../../shared/chess/util/typeutil.js'; import gameutility from './gameutility.js'; import liveGameValues from './liveGameValues.js'; import { setGameConclusion } from './gamemanager.js'; import { isDrawOfferOpen, hasColorOfferedDrawTooFast, openDrawOffer, doesColorHaveExtendedDrawOffer, closeDrawOffer, } from './drawoffers.js'; //-------------------------------------------------------------------------------------------------------- /** * Called when client wants to offer a draw. Sends confirmation to opponent. * @param ws - The socket * @param servergame - The game they are in. */ function offerDraw(ws: CustomWebSocket, servergame: ServerGame): void { // console.log('Client offers a draw.'); const { match, basegame } = servergame; const color = gameutility.doesSocketBelongToGame_ReturnColor(match, ws)!; if (gameutility.isGameOver(basegame)) return console.error('Client offered a draw when the game is already over. Ignoring.'); if (isDrawOfferOpen(match)) return console.error( `${color} tried to offer a draw when the game already has a draw offer!`, ); if (hasColorOfferedDrawTooFast(servergame, color)) return console.error('Client tried to offer a draw too fast.'); if (!gameutility.isGameResignable(basegame)) return console.error('Client tried to offer a draw on the first 2 moves'); // Extend the draw offer! openDrawOffer(servergame, color); liveGameValues.onDrawOfferExtended(servergame, color); // Alert their opponent const opponentColor = typeutil.invertPlayer(color); gameutility.sendMessageToSocketOfColor(match, opponentColor, 'game', 'drawoffer'); } /** * Called when client accepts a draw. Ends the game. * @param ws - The socket * @param servergame - The game they are in. */ function acceptDraw(ws: CustomWebSocket, servergame: ServerGame): void { // console.log('Client accepts a draw.'); const color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; if (gameutility.isGameOver(servergame.basegame)) return console.error('Client accepted a draw when the game is already over. Ignoring.'); if (!isDrawOfferOpen(servergame.match)) return console.error("Client tried to accept a draw offer when there isn't one."); if (doesColorHaveExtendedDrawOffer(servergame.match, color)) return console.error('Client tried to accept their own draw offer, silly!'); // Accept draw offer! closeDrawOffer(servergame.match); setGameConclusion(servergame, { victor: null, condition: 'agreement' }); } /** * Called when client declines a draw. Alerts opponent. * @param ws - The socket * @param servergame - The game they are in. */ function declineDraw(ws: CustomWebSocket, servergame: ServerGame): void { const color = gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws)!; const opponentColor = typeutil.invertPlayer(color); // Since this method is run every time a move is submitted, we have to early exit // if their opponent doesn't have an open draw offer. if (!doesColorHaveExtendedDrawOffer(servergame.match, opponentColor)) return; // console.log('Client declines a draw.'); if (gameutility.isGameOver(servergame.basegame)) return console.error('Client declined a draw when the game is already over. Ignoring.'); // Decline the draw! closeDrawOffer(servergame.match); // Alert their opponent gameutility.sendMessageToSocketOfColor(servergame.match, opponentColor, 'game', 'declinedraw'); liveGameValues.onDrawOfferDeclined(servergame); } //-------------------------------------------------------------------------------------------------------- export { offerDraw, acceptDraw, declineDraw }; ================================================ FILE: src/server/game/gamemanager/pastereport.ts ================================================ // src/server/game/gamemanager/pastereport.ts /** * This script flags private games that have a custom position pasted. */ import type { ServerGame } from './gameutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import gameutility from './gameutility.js'; import liveGameValues from './liveGameValues.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; /** * Called when a player submits a websocket message informing us they * pasted a game in a private match. * * We don't want to log custom games when they're finished, * because we don't know their starting position, the game * would crash if we attempted to paste it. * @param ws - The socket * @param servergame - The game they belong in, if they belong in one. */ function onPaste(ws: CustomWebSocket, servergame: ServerGame): void { // { reason, opponentsMoveNumber } console.log('Client pasted a game.'); const ourColor = ws.metadata.subscriptions.game?.color || gameutility.doesSocketBelongToGame_ReturnColor(servergame.match, ws); if (servergame.match.publicity !== 'private') { const errString = `Player reported pasting in a non-private game. Reporter color: ${ourColor}. Number of moves played: ${servergame.basegame.moves.length}.\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`; logEventsAndPrint(errString, 'errLog.txt'); return; } if (servergame.match.rated) { const errString = `Player reported pasting in a rated game. Reporter color: ${ourColor}. Number of moves played: ${servergame.basegame.moves.length}.\nThe game: ${gameutility.getSimplifiedGameString(servergame)}`; logEventsAndPrint(errString, 'errLog.txt'); return; } // Flag the game to not be logged servergame.match.positionPasted = true; // Also delete boardsim, disabling server-side move validation. delete servergame.boardsim; // Persist the paste state to the database. liveGameValues.onPositionPasted(servergame); } export { onPaste }; ================================================ FILE: src/server/game/gamemanager/ratingabuse.ts ================================================ // src/server/game/gamemanager/ratingabuse.ts /** * This script can weight a user's level of suspiciousness for rating abuse, * in attempt to boost their own elo. * * This can include repeatedly losing on purpose on an alt account, * or playing illegal moves to abort games to avoid losing elo. * * Naviary is notified by email of any flagged users. */ import type { ServerGame } from './gameutility.js'; import type { GamesRecord } from '../../database/gamesManager.js'; import type { PlayerGamesRecord } from '../../database/playerGamesManager.js'; import type { RefreshTokenRecord } from '../../database/refreshTokenManager.js'; import timeutil from '../../../shared/util/timeutil.js'; import { VariantLeaderboards } from '../../../shared/chess/variants/validleaderboard.js'; import gameutility from './gameutility.js'; import { getMultipleGameData } from '../../database/gamesManager.js'; import { sendRatingAbuseEmail } from '../../controllers/emailController.js'; import { findRefreshTokensForUsers } from '../../database/refreshTokenManager.js'; import { logEvents, logEventsAndPrint } from '../../middleware/logEvents.js'; import { getMultipleMemberDataByCriteria } from '../../database/memberManager.js'; import { getRecentNRatedGamesForUser, getOpponentsOfUserFromGames, } from '../../database/playerGamesManager.js'; import { addEntryToRatingAbuseTable, isEntryInRatingAbuseTable, getRatingAbuseData, updateRatingAbuseColumns, } from '../../database/ratingAbuseManager.js'; /** * Potential red flags (already implemented checks are marked with an X at the start of the line): * * (X) Low move counts (games ended quickly) * (X) Low game time durations with a high number of close together games, or high clock values at end (indicates no thinking) * (X) Opponents use the same IP address. OR The player has no active refresh tokens (logged out mid-game) * (X) Many games against always the same opponents * (X) Opponent accounts brand new * * Win streaks, especially against the same opponents * Rapid improvement over days/weeks that should take months, especially if account new * Low total rated loss count * Opponents have low total casual matches, and low total rated wins * Excessive resignation terminations * Cheat reports against them */ // Constants ----------------------------------------------------------------------------- /** How many games played to measure a player's rating abuse probability at once. */ const GAME_INTERVAL_TO_MEASURE = 5; /** Total suspicion score which is enough to mark a user as suspicious. */ const SUSPICION_TOTAL_WEIGHT_THRESHHOLD = 1.0; /** Buffer time for sending the next email. If a user is found suspicious several times in that interval, no email is sent. */ const SUSPICIOUS_USER_NOTIFICATION_BUFFER_MILLIS = 1000 * 60 * 60 * 24; // 24 hours /** * Two rated games started this close after each other have a nonzero suspicion score. * * Slightly higher than {@link SUSPICIOUS_TIME_DURATION_MILLIS} to account for time to accept a new invite. */ const TOO_CLOSE_GAMES_MILLIS = 1000 * 60 * 3.5; // 3.5 minutes /** * Games with fewer moves than this have a nonzero suspicion score. * * Average move count per game is 38 moves. */ const SUSPICIOUS_MOVE_COUNT = 25; /** Games lasting less than this time on the server have a nonzero suspicion score. */ const SUSPICIOUS_TIME_DURATION_MILLIS = 1000 * 60 * 3; // 3 minutes /** Opponents with a younger account age than this count as suspicious. */ const SUSPICIOUS_ACCOUNT_AGE_MILLIS = 1000 * 60 * 60 * 24 * 5; // 5 days // Types Definitions --------------------------------------------------------------------- /** * Relevant entries of a PlayerGamesRecord object, * which are used for the rating abuse calculation. */ type RatingAbuseRelevantPlayerGamesRecord = Pick< PlayerGamesRecord, 'game_id' | 'score' | 'clock_at_end_millis' | 'elo_change_from_game' >; /** * Relevant entries of a GamesRecord object, * which are used for the rating abuse calculation. */ type RatingAbuseRelevantGamesRecord = Pick< GamesRecord, | 'game_id' | 'date' | 'base_time_seconds' | 'increment_seconds' | 'private' | 'termination' | 'move_count' | 'time_duration_millis' >; /** Object containing all relevant information about a specific game, which is used for the rating abuse calculation */ type RatingAbuseRelevantGameInfo = RatingAbuseRelevantPlayerGamesRecord & RatingAbuseRelevantGamesRecord; /** Relevant entries of a MemberRecord object, which are used for the rating abuse calculation */ type RatingAbuseRelevantMemberRecord = { username: string; user_id: number; joined: string; }; /** Object containing information about analysis of suspicion level of some characteristic */ type SuspicionLevelRecord = { category: | 'close_game_pairs' | 'move_count' | 'duration' | 'clock_at_end' | 'same_opponents' | 'ip_addresses' | 'opponent_account_age'; weight: number; comment?: string; }; // Functions ----------------------------------------------------------------------------- /** * Monitor suspicion levels for all players who played a particular game in a particular leaderboard */ function measureRatingAbuseAfterGame(servergame: ServerGame): void { // Do not monitor suspicion levels, if game was unrated if (!servergame.match.rated) return; // Skip if the game was aborted (this also covers 0 moves), // the game will NOT have added an entry in the leaderboards table for the players! if (servergame.basegame.gameConclusion!.victor === undefined) return; // Do not monitor suspicion levels, if game belongs to no valid leaderboard_id const leaderboard_id = VariantLeaderboards[servergame.match.variant]; if (leaderboard_id === undefined) return; for (const [playerStr, player] of Object.entries(servergame.match.playerData)) { if (!player.identifier.signedIn) { void logEventsAndPrint( `Unexpected: Player "${playerStr}" is not signed in. Game: ${gameutility.getSimplifiedGameString(servergame)}`, 'errLog.txt', ); continue; } const user_id = player.identifier.user_id; const username = player.identifier.username; try { measurePlayerRatingAbuse(user_id, username, leaderboard_id); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); void logEventsAndPrint( `Error running rating_abuse checks for user ID "${user_id}" on leaderboard ${leaderboard_id}: ${message}`, 'errLog.txt', ); } } } /** * Weights a specific user's probability of rating abuse on a specified leaderboard. * If it flags a user, it sends Naviary an email with data on them. */ function measurePlayerRatingAbuse(user_id: number, username: string, leaderboard_id: number): void { // If player is not in rating_abuse table, add him to it if (!isEntryInRatingAbuseTable(user_id, leaderboard_id)) { const result = addEntryToRatingAbuseTable(user_id, leaderboard_id); if (!result.success) { void logEventsAndPrint( `Failed to add user ${user_id} to rating_abuse table for leaderboard ${leaderboard_id} for reason: ${result.reason}`, 'errLog.txt', ); return; } } // Access the player rating_abuse data const rating_abuse_data = getRatingAbuseData(user_id, leaderboard_id, [ 'game_count_since_last_check', 'last_alerted_at', ]); if (rating_abuse_data === undefined) { void logEventsAndPrint( `Unable to read rating_abuse_data of user ${user_id} on leaderboard ${leaderboard_id} while making RatingAbuse check!`, 'errLog.txt', ); return; } // Increment game_count_since_last_check by 1 let game_count_since_last_check = 1 + (rating_abuse_data.game_count_since_last_check || 0); // Early exit condition if the newly incremented game_count_since_last_check is still below the GAME_INTERVAL_TO_MEASURE threshhold if (game_count_since_last_check < GAME_INTERVAL_TO_MEASURE) { updateRatingAbuseColumns(user_id, leaderboard_id, { game_count_since_last_check }); // update rating_abuse table with new value for game_count_since_last_check return; } // Now we run the actual suspicion level check, thereby setting game_count_since_last_check to 0 from now on game_count_since_last_check = 0; updateRatingAbuseColumns(user_id, leaderboard_id, { game_count_since_last_check }); // Retrieve the most recent ranked non-aborted games from the player_games table const recentPlayerGamesEntries = getRecentNRatedGamesForUser( user_id, leaderboard_id, GAME_INTERVAL_TO_MEASURE, ['game_id', 'score', 'clock_at_end_millis', 'elo_change_from_game'], ); const netRatingChange = recentPlayerGamesEntries.reduce( (acc, g) => acc + (g.elo_change_from_game ?? 0), 0, ); const game_id_list = recentPlayerGamesEntries.map((recent_game) => recent_game.game_id); // The player has lost elo the past GAME_INTERVAL_TO_MEASURE games. No cause for concern, early exit if (netRatingChange <= 0) { const messageText = `Innocent: Ran suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id}, but user net rating change ${netRatingChange} is not positive in the last ${GAME_INTERVAL_TO_MEASURE} games. Game IDs: ${JSON.stringify(game_id_list)}.`; void logEvents(messageText, 'ratingAbuseLog.txt'); return; } // Retrieve these same games also from the games table const recentGamesEntries = getMultipleGameData(game_id_list, [ 'game_id', 'date', 'base_time_seconds', 'increment_seconds', 'private', 'termination', 'move_count', 'time_duration_millis', ])!; const games_table_game_id_list = recentGamesEntries.map((recent_game) => recent_game.game_id); // Combine the information about the games into a single gameInfoList object const gameInfoList: RatingAbuseRelevantGameInfo[] = []; for (let i = 0; i < game_id_list.length; i++) { const j = games_table_game_id_list.indexOf(game_id_list[i]!); // If the same game_id exists in both lists of retrieved database entries, add this game as a single object to gameInfoList if (j > -1) { gameInfoList.push({ ...recentPlayerGamesEntries[i]!, ...recentGamesEntries[j]! }); } else { void logEventsAndPrint( `Found game_id ${game_id_list[i]!} in player_games table but not it games table, during rating abuse calculation`, 'errLog.txt', ); } } // console.log(gameInfoList); // Get a list of the user_ids of the previous opponents of the player const opponentPlayerGamesEntries = getOpponentsOfUserFromGames(user_id, game_id_list, [ 'user_id', ]); const user_id_list = opponentPlayerGamesEntries.map((entry) => entry.user_id!); const unique_user_id_list = [...new Set(user_id_list)]; // Dictionary of frequencies of user_ids in user_id_list const user_id_frequency: { [key: number]: number } = {}; for (const user_id of user_id_list) { user_id_frequency[user_id] = (user_id_frequency[user_id] || 0) + 1; } // Get the refresh tokens of the user and all his opponents let refreshTokenEntries: RefreshTokenRecord[]; try { refreshTokenEntries = findRefreshTokensForUsers([user_id, ...unique_user_id_list]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); void logEventsAndPrint( `Error fetching refresh token entries for users "${JSON.stringify([user_id, ...unique_user_id_list])}": ${message}`, 'errLog.txt', ); refreshTokenEntries = []; } // Extract the IP addresses of the user and his opponents from the refresh tokens const user_ip_address_list: string[] = []; // ip_addresses of the user const opponent_ip_address_lists: { [key: number]: string[] } = {}; // ip_addresses of his unique opponents for (const refreshToken of refreshTokenEntries) { if (refreshToken.ip_address === null) continue; // If the refresh token belongs to the user, add his IP address to user_ip_address_list if (refreshToken.user_id === user_id) user_ip_address_list.push(refreshToken.ip_address); // Else, add the IP address to the opponent_ip_address_list else if (refreshToken.user_id in user_id_frequency) { opponent_ip_address_lists[refreshToken.user_id] = opponent_ip_address_lists[refreshToken.user_id] || []; // Initialize if undefined opponent_ip_address_lists[refreshToken.user_id]!.push(refreshToken.ip_address); } } // Get relevant MemberRecords of the opponents from the members table let opponentInfoList: RatingAbuseRelevantMemberRecord[] = []; try { opponentInfoList = getMultipleMemberDataByCriteria( ['username', 'user_id', 'joined'], 'user_id', unique_user_id_list, ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); void logEventsAndPrint( `Error fetching records for opponents during rating abuse calculation for user ${username} with user_id ${user_id}: ${message}`, 'errLog.txt', ); } // Handcrafted game suspicion checking ------------------------------------------ /** An Object containg a suspicion level score for various monitored things */ const suspicion_level_record_list: SuspicionLevelRecord[] = []; // Run various checks and add entries to suspicion_level_record_list, if necessary checkCloseGamePairs(gameInfoList, suspicion_level_record_list); checkMoveCounts(gameInfoList, suspicion_level_record_list); checkDurations(gameInfoList, suspicion_level_record_list); checkClockAtEnd(gameInfoList, suspicion_level_record_list); checkOpponentSameness(user_id_list, user_id_frequency, suspicion_level_record_list); checkIPAddresses( user_id_list, user_id_frequency, user_ip_address_list, opponent_ip_address_lists, suspicion_level_record_list, ); checkOpponentAccountAge( user_id_list, user_id_frequency, opponentInfoList, suspicion_level_record_list, ); /** Sum of all suspicion weights in suspicion_level_record_list */ const suspicion_total_weight = suspicion_level_record_list .map((entry) => entry.weight) .reduce((acc, cur) => acc + cur, 0); // Player is suspicious and admin is notified if necessary if (suspicion_total_weight >= SUSPICION_TOTAL_WEIGHT_THRESHHOLD) { const messageText = ` >>>>>> GUILTY??? Suspicion total weight: ${suspicion_total_weight}. Ran suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id} with net rating change ${netRatingChange} in the last ${GAME_INTERVAL_TO_MEASURE} games, and user might be cheating! Suspicion level record: ${JSON.stringify(suspicion_level_record_list, undefined, 2)}. Opponent user_id_list: ${JSON.stringify(user_id_list)}. OpponentInfoList: ${JSON.stringify(opponentInfoList, undefined, 2)}. Game_id_list: ${JSON.stringify(game_id_list)}. \nGameInfo list: ${JSON.stringify(gameInfoList, undefined, 2)}. `; console.log( `User ${username} is under suspicion of rating abuse (weight: ${suspicion_total_weight})! - Check ratingAbuseLog.txt for more details.`, ); void logEvents('\n' + messageText, 'ratingAbuseLog.txt'); // If enough time has passed from the last alarm for that user, send an email about his rating abuse if ( rating_abuse_data.last_alerted_at === null || rating_abuse_data.last_alerted_at === undefined || Date.now() - timeutil.sqliteToTimestamp(rating_abuse_data.last_alerted_at) >= SUSPICIOUS_USER_NOTIFICATION_BUFFER_MILLIS ) { const messageSubject = `Rating Abuse Warning: user ${username}, user_id ${user_id}`; void sendRatingAbuseEmail(messageSubject, messageText); // Update RatingAbuse table with last_alerted_at value const last_alerted_at = timeutil.timestampToSqlite(Date.now()); updateRatingAbuseColumns(user_id, leaderboard_id, { last_alerted_at }); } } // Player is not suspicious else { const messageText = `Innocent? Suspicion total weight: ${suspicion_total_weight}. ` + `Ran suspicion check for user ${username} with user_id ${user_id} on leaderboard ${leaderboard_id} with net rating change ${netRatingChange} in the last ${GAME_INTERVAL_TO_MEASURE} games, and user seems innocent.` + `Suspicion level record: ${JSON.stringify(suspicion_level_record_list)}. ` + `Opponent user_id_list: ${JSON.stringify(user_id_list)}. ` + `OpponentInfoList: ${JSON.stringify(opponentInfoList)}. ` + `Game_id_list: ${JSON.stringify(game_id_list)}. ` + `GameInfo list: ${JSON.stringify(gameInfoList)}.`; void logEvents(messageText, 'ratingAbuseLog.txt'); } } /** * Check if the game dates are too close in proximity to each other * If yes, append entry to suspicion_level_record_list. */ function checkCloseGamePairs( gameInfoList: RatingAbuseRelevantGameInfo[], suspicion_level_record_list: SuspicionLevelRecord[], ): void { const sorted_timestamp_list = gameInfoList .map((game_info) => timeutil.sqliteToTimestamp(game_info.date)) .sort(); const timestamp_differences: number[] = []; for (let i = 1; i < sorted_timestamp_list.length; i++) { timestamp_differences.push(sorted_timestamp_list[i]! - sorted_timestamp_list[i - 1]!); } const close_game_pairs_amount = timestamp_differences.filter( (diff) => diff < TOO_CLOSE_GAMES_MILLIS, ).length; if (close_game_pairs_amount > 0) { suspicion_level_record_list.push({ category: 'close_game_pairs', weight: (close_game_pairs_amount / timestamp_differences.length) * 0.5, // rescale to [0, 0.5] comment: `Amount: ${close_game_pairs_amount}`, }); } } /** * Check if the move counts of the games in gameInfoList are too low. * If yes, append entry to suspicion_level_record_list. */ function checkMoveCounts( gameInfoList: RatingAbuseRelevantGameInfo[], suspicion_level_record_list: SuspicionLevelRecord[], ): void { let weight = 0; let comment = ''; for (const gameInfo of gameInfoList) { if (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it // Game is suspicious if it contains too few moves if (gameInfo.move_count <= SUSPICIOUS_MOVE_COUNT) { const fraction = Math.max(0, (gameInfo.move_count - 2) / (SUSPICIOUS_MOVE_COUNT - 2)); // fraction is in the interval [0, 1] weight += 1 - fraction; comment += `Game ${gameInfo.game_id} lasted ${gameInfo.move_count} moves. `; } } if (weight > 0) suspicion_level_record_list.push({ category: 'move_count', weight: (weight / gameInfoList.length) * 0.5, // rescale to [0,0.5] comment, }); } /** * Check if the durations on the server of the games in gameInfoList are too low. * If yes, append entry to suspicion_level_record_list. */ function checkDurations( gameInfoList: RatingAbuseRelevantGameInfo[], suspicion_level_record_list: SuspicionLevelRecord[], ): void { let weight = 0; let comment = ''; for (const gameInfo of gameInfoList) { if (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it // Game is suspicious if it lasted too briefly on the server if ( gameInfo.time_duration_millis !== null && gameInfo.time_duration_millis <= SUSPICIOUS_TIME_DURATION_MILLIS ) { const fraction = gameInfo.time_duration_millis / SUSPICIOUS_TIME_DURATION_MILLIS; // fraction is in the interval [0, 1] weight += 1 - fraction; comment += `Game ${gameInfo.game_id} lasted ${Math.round(gameInfo.time_duration_millis / 1000)}s. `; } } if (weight > 0) suspicion_level_record_list.push({ category: 'duration', weight: (weight / gameInfoList.length) * 0.8, // rescale to [0,0.8] comment, }); } /** * Check if the clock at the end of the games in gameInfoList are too low. * If yes, append entry to suspicion_level_record_list. */ function checkClockAtEnd( gameInfoList: RatingAbuseRelevantGameInfo[], suspicion_level_record_list: SuspicionLevelRecord[], ): void { let weight = 0; let comment = ''; for (const gameInfo of gameInfoList) { if (!gameInfo.elo_change_from_game || gameInfo.elo_change_from_game < 0) continue; // Game is not suspicious if player lost elo from it // Game is suspicious if the clock at the end is still similar to the start time if ( gameInfo.clock_at_end_millis !== null && gameInfo.base_time_seconds !== null && gameInfo.increment_seconds !== null ) { const approximate_total_time_millis = 1000 * (gameInfo.base_time_seconds + 0.5 * gameInfo.increment_seconds * (gameInfo.move_count - 1)); if ( approximate_total_time_millis > 0 && gameInfo.clock_at_end_millis >= 0.8 * approximate_total_time_millis ) { const fraction = Math.min( 1, gameInfo.clock_at_end_millis / approximate_total_time_millis, ); // fraction is in the interval [0.8, 1] weight += 5 * fraction - 4; // rescale to [0,1] comment += `At end of game ${gameInfo.game_id} with time control ${gameInfo.base_time_seconds / 60}m+${gameInfo.increment_seconds}s, player has ${(gameInfo.clock_at_end_millis / 60_000).toFixed(2)}m left. `; } } } if (weight > 0) suspicion_level_record_list.push({ category: 'clock_at_end', weight: (weight / gameInfoList.length) * 0.4, // rescale to [0, 0.4] comment, }); } /** * Check if the user is playing against the same opponents many times. * If yes, append entry to suspicion_level_record_list. */ function checkOpponentSameness( user_id_list: number[], user_id_frequency: { [key: number]: number }, suspicion_level_record_list: SuspicionLevelRecord[], ): void { if (user_id_list.length === 0) return; let weight = 0; for (const frequency of Object.values(user_id_frequency)) { // Player is suspicious if he played against the same opponent several times if (frequency > 1) weight += frequency ** 2; } if (weight > 0) suspicion_level_record_list.push({ category: 'same_opponents', weight: (weight / user_id_list.length ** 2) * 0.5, // rescale to [0, 0.5] }); } /** * Check if the user is using the same IP address as his opponents. * If yes, append entry to suspicion_level_record_list. */ function checkIPAddresses( user_id_list: number[], user_id_frequency: { [key: number]: number }, user_ip_address_list: string[], opponent_ip_address_lists: { [key: number]: string[] }, suspicion_level_record_list: SuspicionLevelRecord[], ): void { // Player logged out mid game if (user_ip_address_list.length === 0) { suspicion_level_record_list.push({ category: 'ip_addresses', weight: 0.5, comment: 'Player logged out mid-game.', }); return; } else if (user_id_list.length === 0 || Object.keys(opponent_ip_address_lists).length === 0) return; let weight = 0; let comment = 'Opponents using same IP address: '; for (const user_id in opponent_ip_address_lists) { // Player is suspicious if he uses a same IP adress as an opponent const common_ip_addresses = user_ip_address_list.filter((ip_address) => opponent_ip_address_lists[user_id]!.includes(ip_address), ); if (common_ip_addresses.length > 0) { weight += user_id_frequency[user_id] ?? 0; comment += `${user_id},`; } } if (weight > 0) suspicion_level_record_list.push({ category: 'ip_addresses', weight: (weight / user_id_list.length) * 0.5, // rescale to [0, 0.5] comment, }); } /** * Check if the user's opponents have newly created accounts * If yes, append entry to suspicion_level_record_list. */ function checkOpponentAccountAge( user_id_list: number[], user_id_frequency: { [key: number]: number }, opponentInfoList: RatingAbuseRelevantMemberRecord[], suspicion_level_record_list: SuspicionLevelRecord[], ): void { if (user_id_list.length === 0) return; const current_time_millis = Date.now(); let weight = 0; let comment = 'Newly joined opponents: '; for (const opponentInfo of opponentInfoList) { // Player is suspicious if his opponent's account is less than a week old const account_age_millis = Math.max( 0, current_time_millis - timeutil.sqliteToTimestamp(opponentInfo.joined), ); if (account_age_millis < SUSPICIOUS_ACCOUNT_AGE_MILLIS) { const fraction = account_age_millis / SUSPICIOUS_ACCOUNT_AGE_MILLIS; // fraction is in the interval [0, 1] weight += (1 - fraction) * (user_id_frequency[opponentInfo.user_id] ?? 0); comment += `${opponentInfo.user_id},`; } } if (weight > 0) suspicion_level_record_list.push({ category: 'opponent_account_age', weight: (weight / user_id_list.length) * 0.3, // rescale to [0, 0.3] comment, }); } export default { measureRatingAbuseAfterGame, }; ================================================ FILE: src/server/game/gamemanager/ratingcalculation.ts ================================================ // src/server/game/gamemanager/ratingcalculation.ts /** * Implementation of Glicko-1 algorithm for calculating rating changes arising from ranked games */ import timeutil from '../../../shared/util/timeutil.js'; import { PlayerGroup, type Player, players as p } from '../../../shared/chess/util/typeutil.js'; // Default variables, shared across all leaderboards ------------------------------------------------------------------ /** Default elo for a player not contained in a leaderboard. We use the same default across the leaderboards, to avoid confusion. */ const DEFAULT_LEADERBOARD_ELO = 1500.0; /** Minimum elo for a player on a leaderboard. */ const MINIMUM_LEADERBOARD_ELO = 400.0; /** Default rating deviation, used for Glicko-1 */ const DEFAULT_LEADERBOARD_RD = 350.0; /** * Minimum rating deviation, used for Glicko-1 * * 50 => ~+-8 elo change per game played. * 50 DV can be reach by playing 7-8 games per day. * * See: https://discord.com/channels/1114425729569017918/1260310049889189908/1373014556254670970 */ const MINIMUM_LEADERBOARD_RD = 50.0; /** Rating deviations above this are considered to be too uncertain and the user is excluded from leaderboards */ const UNCERTAIN_LEADERBOARD_RD = 220.0; // Requires 3 games to be placed on the leaderboard. /** Constant c, used for Glicko-1 */ const c = 70; /** Constant q, used for Glicko-1 */ const q = 0.00575646273; /** Duration of a glicko-1 rating period, in milliseconds */ const RATING_PERIOD_DURATION = 1000 * 60 * 60 * 24 * 15; // 15 days /** Frequency of automatic RD update in database, in milliseconds */ const RD_UPDATE_FREQUENCY = 1000 * 60 * 60 * 24; // 24 hours // const RD_UPDATE_FREQUENCY = 1000 * 30; // 30s for dev testing // Types ------------------------------------------------------------------------------- /** Type containing all relevant rating calculation quantities for a specific player */ type PlayerRatingData = { elo_at_game: number; rating_deviation_at_game: number; rd_last_update_date: string | null; // A date in string format, as used in the database. Can be null if no games played yet elo_after_game?: number; rating_deviation_after_game?: number; elo_change_from_game?: number; }; /** A dictionary type with Players as keys, containing PlayerRatingData for each player */ type RatingData = PlayerGroup; // Functions ------------------------------------------------------------------------------- /** * Computes the effective rating deviation for the current rating period, as for Glicko-1 algorithm */ function getTrueRD(rating_deviation: number, rd_last_update_date: string | null): number { if (rd_last_update_date === null) return rating_deviation; else { const last_rated_game_timestamp = timeutil.sqliteToTimestamp(rd_last_update_date); const current_timestamp = Date.now(); // fraction of elapsed time over length of a standard rating period -> noninteger in general const rating_periods_elapsed = Math.max( 0, (current_timestamp - last_rated_game_timestamp) / RATING_PERIOD_DURATION, ); return Math.max( MINIMUM_LEADERBOARD_RD, Math.min( DEFAULT_LEADERBOARD_RD, Math.sqrt(rating_deviation ** 2 + rating_periods_elapsed * c ** 2), ), ); } } /** Function g of Glicko-1 algorithm */ function g(RD: number): number { return 1 / Math.sqrt(1 + (3 * q ** 2 * RD ** 2) / Math.PI ** 2); } /** Function E of Glicko-1 algorithm: expected outcome of game */ function E(r: number, r_opp: number, RD_opp: number): number { return 1 / (1 + 10 ** ((-g(RD_opp) * (r - r_opp)) / 400)); } /** Function d^2 of Glicko-1 algorithm */ function d_squared(r: number, r_opp: number, RD_opp: number): number { const Es = E(r, r_opp, RD_opp); return 1 / (q ** 2 * g(RD_opp) ** 2 * Es * (1 - Es)); } /** Given a game outcome for a player, his rating r, his RD, and the opponent'S rating r_opp and RD_opp, compute his new rating with glicko-1 */ function new_rating( outcome: 0 | 0.5 | 1, r: number, RD: number, r_opp: number, RD_opp: number, ): number { return Math.max( MINIMUM_LEADERBOARD_ELO, // prettier-ignore r + ( q / ( 1 / RD ** 2 + 1 / d_squared(r, r_opp, RD_opp) ) ) * g(RD_opp) * (outcome - E(r, r_opp, RD_opp)), ); } /** Given a player's rating r, his RD, and the opponent'S rating r_opp and RD_opp, compute his new rating with glicko-1 */ function new_RD(r: number, RD: number, r_opp: number, RD_opp: number): number { return Math.max( MINIMUM_LEADERBOARD_RD, // p // prettier-ignore Math.sqrt(1 / (1 / RD ** 2 + 1 / d_squared(r, r_opp, RD_opp))), ); } /** * Takes ratingdata object as an input, with entries: elo_at_game, rating_deviation_at_game and rd_last_update_date. * Computes rating data changes and returns ratingdata object by overwriting entries: elo_after_game, rating_deviation_after_game and elo_change_from_game. * MUTATING. Modifies original ratingdata object. */ function computeRatingDataChanges(ratingdata: RatingData, victor: Player | null): RatingData { // Currently, only rating calculations for 2-player games with White vs Black are supported const playerCount = Object.keys(ratingdata).length; if (playerCount !== 2) throw Error('Rating changes are only supported in two player games!'); if (ratingdata[p.WHITE] === undefined || ratingdata[p.BLACK] === undefined) throw Error("Missing White or Black's rating data!"); const r1 = ratingdata[p.WHITE]!.elo_at_game; const r2 = ratingdata[p.BLACK]!.elo_at_game; const RD1 = getTrueRD( ratingdata[p.WHITE]!.rating_deviation_at_game, ratingdata[p.WHITE]!.rd_last_update_date, ); const RD2 = getTrueRD( ratingdata[p.BLACK]!.rating_deviation_at_game, ratingdata[p.BLACK]!.rd_last_update_date, ); const outcome_white = victor === p.WHITE ? 1 : victor === p.BLACK ? 0 : 0.5; const outcome_black = victor === p.WHITE ? 0 : victor === p.BLACK ? 1 : 0.5; ratingdata[p.WHITE]!.elo_after_game = new_rating(outcome_white, r1, RD1, r2, RD2); ratingdata[p.WHITE]!.rating_deviation_after_game = new_RD(r1, RD1, r2, RD2); ratingdata[p.WHITE]!.elo_change_from_game = ratingdata[p.WHITE]!.elo_after_game! - r1; ratingdata[p.BLACK]!.elo_after_game = new_rating(outcome_black, r2, RD2, r1, RD1); ratingdata[p.BLACK]!.rating_deviation_after_game = new_RD(r2, RD2, r1, RD1); ratingdata[p.BLACK]!.elo_change_from_game = ratingdata[p.BLACK]!.elo_after_game! - r2; return ratingdata; } // FOR TESTING =================================================================== /** * DISCUSSION of testing: * https://discord.com/channels/1114425729569017918/1260310049889189908/1373014556254670970 */ // type PlayerStats = { // elo: number; // rd: number; // lastUpdateDate: string | null; // Date string in SQLite format // } // // --- Simulation State --- // const player1CurrentStats: PlayerStats = { // elo: DEFAULT_LEADERBOARD_ELO, // rd: DEFAULT_LEADERBOARD_RD, // lastUpdateDate: null, // Initially null, set to date string after first game // }; // const player2CurrentStats: PlayerStats = { // elo: DEFAULT_LEADERBOARD_ELO, // rd: DEFAULT_LEADERBOARD_RD, // lastUpdateDate: null, // }; // let gameCounter = 0; // const SIMULATION_GAME_INTERVAL_MS = 250; // Simulate a game every 3 seconds // // --- Simulation Function --- // function runSingleGameSimulation() { // gameCounter++; // console.log(`\n--- Simulating Game #${gameCounter} ---`); // // Prepare RatingData for the game about to be played // const ratingDataForThisGame = { // [players.WHITE]: { // elo_at_game: player1CurrentStats.elo, // rating_deviation_at_game: player1CurrentStats.rd, // rd_last_update_date: player1CurrentStats.lastUpdateDate, // }, // [players.BLACK]: { // elo_at_game: player2CurrentStats.elo, // rating_deviation_at_game: player2CurrentStats.rd, // rd_last_update_date: player2CurrentStats.lastUpdateDate, // }, // }; // console.log(`P1 (White) Current: ELO ${player1CurrentStats.elo.toFixed(2)}, RD ${player1CurrentStats.rd.toFixed(2)}, Last Update: ${player1CurrentStats.lastUpdateDate || 'Never'}`); // console.log(`P2 (Black) Current: ELO ${player2CurrentStats.elo.toFixed(2)}, RD ${player2CurrentStats.rd.toFixed(2)}, Last Update: ${player2CurrentStats.lastUpdateDate || 'Never'}`); // // RD values that will actually be used in calculation (after getTrueRD applies time decay) // // Note: getTrueRD is called internally by computeRatingDataChanges. We can also call it here for display. // const rd1ForCalc = getTrueRD(ratingDataForThisGame[players.WHITE].rating_deviation_at_game, ratingDataForThisGame[players.WHITE].rd_last_update_date); // const rd2ForCalc = getTrueRD(ratingDataForThisGame[players.BLACK].rating_deviation_at_game, ratingDataForThisGame[players.BLACK].rd_last_update_date); // console.log(`P1 RD for this game (after time decay): ${rd1ForCalc.toFixed(2)}`); // console.log(`P2 RD for this game (after time decay): ${rd2ForCalc.toFixed(2)}`); // // Simulate a game outcome (randomly) // const randomOutcomeSeed = Math.random(); // let victorId; // let outcomeDescription; // if (randomOutcomeSeed < 0.45) { // Player 1 (White) wins // victorId = players.WHITE; // outcomeDescription = "Player 1 (White) wins"; // } else if (randomOutcomeSeed < 0.9) { // Player 2 (Black) wins // victorId = players.BLACK; // outcomeDescription = "Player 2 (Black) wins"; // } else { // Draw // victorId = null; // `computeRatingDataChanges` handles this as a draw // outcomeDescription = "Draw"; // } // console.log(`Game Outcome: ${outcomeDescription}`); // // Calculate new ratings using Glicko-1 // const GlickoResults = computeRatingDataChanges(ratingDataForThisGame, victorId); // // Update player stats for the next simulated game // // 2 Days // const timeSinceLastGame = 1000 * 60 * 60 * 24 * 30 * 1.5; // 6 weeks // const gameTimestampString = timeutil.timestampToSqlite(Date.now() - timeSinceLastGame); // player1CurrentStats.elo = GlickoResults[players.WHITE]!.elo_after_game!; // player1CurrentStats.rd = GlickoResults[players.WHITE]!.rating_deviation_after_game!; // player1CurrentStats.lastUpdateDate = gameTimestampString; // player2CurrentStats.elo = GlickoResults[players.BLACK]!.elo_after_game!; // player2CurrentStats.rd = GlickoResults[players.BLACK]!.rating_deviation_after_game!; // player2CurrentStats.lastUpdateDate = gameTimestampString; // // Calculate RD changes // const rd1Change = GlickoResults[players.WHITE]!.rating_deviation_after_game! - rd1ForCalc; // const rd2Change = GlickoResults[players.BLACK]!.rating_deviation_after_game! - rd2ForCalc; // // Modified console.log lines // console.log(`P1 (White) New Stats: ELO ${player1CurrentStats.elo.toFixed(2)} (ELO Change: ${GlickoResults[players.WHITE]!.elo_change_from_game!.toFixed(2)}), RD ${player1CurrentStats.rd.toFixed(2)} (RD Change: ${rd1Change.toFixed(2)})`); // console.log(`P2 (Black) New Stats: ELO ${player2CurrentStats.elo.toFixed(2)} (ELO Change: ${GlickoResults[players.BLACK]!.elo_change_from_game!.toFixed(2)}), RD ${player2CurrentStats.rd.toFixed(2)} (RD Change: ${rd2Change.toFixed(2)})`); // // Demonstrate RD increase due to inactivity (illustrative) // // This happens because getTrueRD increases RD if time has passed. // // In our rapid simulation, this effect is small between games. // // Here we show what RD would be after a longer period. // if (gameCounter % 5 === 0 && typeof timeutil !== 'undefined') { // Show every 5 games // const timeDeltaForDemo = 1000 * 60 * 60 * 24 * 30 * 2; // 2 months // // We need to simulate 'Date.now()' being in the future for getTrueRD. // // We can do this by preparing inputs for getTrueRD manually. // const p1LastUpdateTimestamp = timeutil.sqliteToTimestamp(player1CurrentStats.lastUpdateDate); // const futureTimestamp = p1LastUpdateTimestamp + timeDeltaForDemo; // Simulate time passed since last game // // Calculate what getTrueRD would be if 'current_timestamp' was 'futureTimestamp' // const rating_periods_elapsed_demo = Math.max(0, (futureTimestamp - p1LastUpdateTimestamp) / RATING_PERIOD_DURATION); // const p1_RD_if_inactive_demo = Math.max(MIMIMUM_LEADERBOARD_RD, Math.min(DEFAULT_LEADERBOARD_RD, Math.sqrt(player1CurrentStats.rd ** 2 + rating_periods_elapsed_demo * c ** 2))); // const p1_RD_change = p1_RD_if_inactive_demo - player1CurrentStats.rd; // console.log(`\nDEMO: If P1 (RD ${player1CurrentStats.rd.toFixed(2)}) is inactive for ${timeDeltaForDemo / (1000 * 60 * 60 * 24)} days, their RD would become ~${p1_RD_if_inactive_demo.toFixed(2)}. (Change: ${p1_RD_change.toFixed(2)})`); // } // } // // --- Start Simulation --- // console.log("--- Glicko-1 Rating Simulation Test ---"); // console.log("This test will simulate games between two players and update their ratings."); // console.log(`A game is simulated every ${SIMULATION_GAME_INTERVAL_MS / 1000} seconds.`); // console.log("Initial P1 (White): ELO", DEFAULT_LEADERBOARD_ELO, "RD:", DEFAULT_LEADERBOARD_RD); // console.log("Initial P2 (Black): ELO", DEFAULT_LEADERBOARD_ELO, "RD:", DEFAULT_LEADERBOARD_RD); // // Run the first game immediately, then set interval // runSingleGameSimulation(); // const simulationIntervalId = setInterval(runSingleGameSimulation, SIMULATION_GAME_INTERVAL_MS); // // --- Optional: Stop simulation after some time --- // const SIMULATION_DURATION_GAMES = 100; // Number of games to simulate before stopping // const SIMULATION_DURATION_MS = SIMULATION_GAME_INTERVAL_MS * SIMULATION_DURATION_GAMES + 100; // e.g., run for 10 games + buffer // setTimeout(() => { // if (simulationIntervalId) clearInterval(simulationIntervalId); // console.log(`\n--- Simulation automatically stopped after ${gameCounter} games. ---`); // if (typeof timeutil !== 'undefined') { // // Final check of TrueRDs based on their last game time until "now" // const p1FinalTrueRD = getTrueRD(player1CurrentStats.rd, player1CurrentStats.lastUpdateDate); // const p2FinalTrueRD = getTrueRD(player2CurrentStats.rd, player2CurrentStats.lastUpdateDate); // console.log(`P1 Final State: ELO ${player1CurrentStats.elo.toFixed(2)}, Base RD ${player1CurrentStats.rd.toFixed(2)}, Current TrueRD ${p1FinalTrueRD.toFixed(2)}`); // console.log(`P2 Final State: ELO ${player2CurrentStats.elo.toFixed(2)}, Base RD ${player2CurrentStats.rd.toFixed(2)}, Current TrueRD ${p2FinalTrueRD.toFixed(2)}`); // } // }, SIMULATION_DURATION_MS); // ================================================================================ export { DEFAULT_LEADERBOARD_ELO, DEFAULT_LEADERBOARD_RD, UNCERTAIN_LEADERBOARD_RD, RD_UPDATE_FREQUENCY, getTrueRD, computeRatingDataChanges, }; export type { RatingData }; ================================================ FILE: src/server/game/gamemanager/resync.ts ================================================ // src/server/game/gamemanager/resync.ts /** * This script handles resyncing a client to a game when their * websocket closes unexpectedly, but they haven't left the page. * * This is SEPARATE from the re-joining game that happens when you * refresh the page. THAT needs more info sent to the client than this resync does, * which is only a websocket reopening. * * This needs to be its own script instead of in gamemanager because * both gamemanager and movesubmission depend on this, so we avoid circular dependancy. */ import type { ServerGame } from './gameutility.js'; import jsutil from '../../../shared/util/jsutil.js'; import gameutility from './gameutility.js'; import liveGameValues from './liveGameValues.js'; import { getGameByID } from './gamemanager.js'; import { getGameData } from '../../database/gamesManager.js'; import { logEventsAndPrint } from '../../middleware/logEvents.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { cancelDisconnectTimer } from './afkdisconnect.js'; import socketUtility, { CustomWebSocket } from '../../socket/socketUtility.js'; /** * Resyncs a client's websocket to a game. The client already * knows the game id and much other information. We only need to send * them the current move list, player timers, and game conclusion. * @param ws - Their websocket * @param gameID - The game id they requested to sync to. They SHOULD have provided this as a number, but they may tamper it. * @param replyToMessageID - If specified, the id of the incoming socket message this resync will be the reply to */ function resyncToGame(ws: CustomWebSocket, gameID: any, replyToMessageID?: number): void { if (typeof gameID !== 'number') { // Tampered message const log = `Socket sent 'resync', but gameID is in the wrong form! Received: (${jsutil.ensureJSONString(gameID)}) of type ${typeof gameID}. The socket: ${socketUtility.stringifySocketMetadata(ws)}`; logEventsAndPrint(log, 'errLog.txt'); return; } // Make sure their pre-subbed game and game they requested to resync to match. const preSubbedGameId = ws.metadata.subscriptions.game?.id; if (preSubbedGameId !== undefined && preSubbedGameId !== gameID) { logEventsAndPrint( `Client tried to resync to game of id (${gameID}) when they are actually subbed to game of id (${preSubbedGameId})!!`, 'errLog.txt', ); return; } // 1. Check if the game is still live => Resync them const game: ServerGame | undefined = getGameByID(gameID); // 2. Not live => Send game results from database if (!game) { sendClientLoggedGame(ws, gameID); return; } // Verify const colorPlayingAs = ws.metadata.subscriptions.game?.color ?? gameutility.doesSocketBelongToGame_ReturnColor(game.match, ws); if (!colorPlayingAs) { sendSocketMessage(ws, 'game', 'login'); // Unable to verify their socket belongs to this game (probably logged out) return; } gameutility.resyncToGame(ws, game, colorPlayingAs, replyToMessageID); cancelDisconnectTimer(game.match, colorPlayingAs); liveGameValues.onPlayerReconnected(game, colorPlayingAs); } /** Sends a client a game from the database. */ function sendClientLoggedGame(ws: CustomWebSocket, gameID: number): void { const logged_game_info = getGameData(gameID, [ 'game_id', 'rated', 'private', 'termination', 'icn', ]); if (!logged_game_info) { // This happens if the user requests a game that was aborted before // any moves were made, as those games are not stored in the database. sendSocketMessage(ws, 'game', 'nogame'); // IN THE FUTURE: The client could show a "Game not found" page return; } // They should automatically know to unsub on their end, because of this message. // Send them the actual game info. sendSocketMessage(ws, 'game', 'logged-game-info', logged_game_info); console.log(`Sent client game from the database of id (${gameID})!`); } export { resyncToGame }; ================================================ FILE: src/server/game/invitesmanager/acceptinvite.ts ================================================ // src/server/game/invitesmanager/acceptinvite.ts /** * This script handles invite acceptance, * creating a new game if successful. */ import type { AuthMemberInfo } from '../../types.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import type { Player, PlayerGroup } from '../../../shared/chess/util/typeutil.js'; import * as z from 'zod'; import gameutility from '../gamemanager/gameutility.js'; import socketUtility from '../../socket/socketUtility.js'; import { createGame } from '../gamemanager/gamemanager.js'; import { memberInfoEq } from './inviteutility.js'; import { getTranslation } from '../../utility/translate.js'; import { isSocketInAnActiveGame } from '../gamemanager/activeplayers.js'; import { removeSocketFromInvitesSubs } from './invitessubscribers.js'; import { sendNotify, sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { broadcastGameCountToInviteSubs } from '../gamemanager/gamecount.js'; import { getInviteAndIndexByID, deleteInviteByIndex, deleteUsersExistingInvite, findSocketFromOwner, onPublicInvitesChange, IDLengthOfInvites, } from './invitesmanager.js'; /** The zod schema for validating the contents of the acceptinvite message. */ const acceptinviteschem = z.strictObject({ id: z.string().length(IDLengthOfInvites), isPrivate: z.boolean(), }); type AcceptInviteMessage = z.infer; /** * Attempts to accept an invite of given id. * @param ws - The socket performing this action * @param messageContents - The incoming socket message that SHOULD look like: `{ id, isPrivate }` * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response. */ function acceptInvite( ws: CustomWebSocket, messageContents: AcceptInviteMessage, replyto?: number, ): void { // { id, isPrivate } if (isSocketInAnActiveGame(ws)) return sendNotify(ws, 'server.javascript.ws-already_in_game', { replyto }); // Does the invite still exist? const inviteAndIndex = getInviteAndIndexByID(messageContents.id); // { invite, index } if (!inviteAndIndex) return informThemGameAborted(ws, messageContents.isPrivate, messageContents.id, replyto); const { invite, index } = inviteAndIndex; const user = ws.metadata.memberInfo; // Make sure they are not accepting their own. if (memberInfoEq(user, invite.owner)) { sendSocketMessage(ws, 'general', 'printerror', 'Cannot accept your own invite!', replyto); console.error( `Player tried to accept their own invite! Socket: ${socketUtility.stringifySocketMetadata(ws)}`, ); return; } // Make sure it's legal for them to accept. (Not legal if they are a guest or unverified, and the invite is RATED) if (invite.rated === 'rated' && !(user.signedIn && ws.metadata.verified)) { return sendSocketMessage( ws, 'general', 'notify', getTranslation( 'server.javascript.ws-rated_invite_verification_needed', ws.metadata.cookies?.i18next, ), replyto, ); } // Accept the invite! let hadPublicInvite = false; // Delete the invite accepted. if (deleteInviteByIndex(ws, invite, index, { dontBroadcast: true })) hadPublicInvite = true; // Delete their existing invites if (deleteUsersExistingInvite(user, { broadCastNewInvites: false })) hadPublicInvite = true; // Start the game! Notify both players and tell them they've been subscribed to a game! const player1Socket = findSocketFromOwner(invite.owner); // Could be undefined occasionally const player2Socket = ws; // Assign each player a color based on their invite info. Add their socket just encase const assignments: PlayerGroup<{ identifier: AuthMemberInfo; socket?: CustomWebSocket }> = {}; let invite_accepter: Player | undefined; for (const [strcolor, identifier] of Object.entries( gameutility.assignWhiteBlackPlayersFromInvite( invite.color, invite.owner, ws.metadata.memberInfo, ), )) { const player = Number(strcolor) as Player; const is_invite_accepter = memberInfoEq(identifier, player2Socket.metadata.memberInfo); if (is_invite_accepter) invite_accepter = player; assignments[player] = { identifier, socket: is_invite_accepter ? player2Socket : player1Socket, }; } if (invite_accepter === undefined) throw Error("Invite accepter doesn't exist on accepted 2 player invite"); createGame(invite, assignments, invite_accepter, replyto); // Unsubscribe them both from the invites subscription list. if (player1Socket) removeSocketFromInvitesSubs(player1Socket); // Could be undefined occasionally removeSocketFromInvitesSubs(player2Socket); // Broadcast the invites list change after creating the game, // because the new game ups the game count. if (hadPublicInvite) onPublicInvitesChange(); // Broadcast to all invites list subscribers! else broadcastGameCountToInviteSubs(); } /** * Called when a player clicks to accept an invite that gets deleted right before. * This tells them the game was aborted, or that the code * was invalid, if they entered a private invite code. * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response. */ function informThemGameAborted( ws: CustomWebSocket, isPrivate: boolean, inviteID: string, replyto?: number, ): void { const errString = isPrivate ? 'server.javascript.ws-invalid_code' : 'server.javascript.ws-game_aborted'; return sendNotify(ws, errString, { replyto }); } export { acceptInvite, acceptinviteschem }; ================================================ FILE: src/server/game/invitesmanager/cancelinvite.ts ================================================ // src/server/game/invitesmanager/cancelinvite.ts /** * This script handles invite cancelation. */ import type { CustomWebSocket } from '../../socket/socketUtility.js'; import * as z from 'zod'; import socketUtility from '../../socket/socketUtility.js'; import { memberInfoEq } from './inviteutility.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { getInviteAndIndexByID, deleteInviteByIndex, IDLengthOfInvites } from './invitesmanager.js'; /** The zod schema for validating the contents of the cancelinvite message. */ const cancelinviteschem = z.string().length(IDLengthOfInvites); /** This is also the id of the invite to delete */ type CancelInviteMessage = z.infer; /** * Cancels/deletes the specified invite. * @param ws - Their socket * @param messageContents - The incoming socket message that is the ID of the invite to be cancelled! * @param replyto - The ID of the incoming socket message. This is used for the `replyto` property on our response. */ function cancelInvite( ws: CustomWebSocket, messageContents: CancelInviteMessage, replyto?: number, ): void { // Value should be the ID of the invite to cancel! const id = messageContents; // id of invite to delete const inviteAndIndex = getInviteAndIndexByID(id); // { invite, index } | undefined // Already cancelled, they must have joined a game, OR CANCELLED on a different tab! // The client is expecting a response from us, even if empty, so it knows to unlock the create invite button again! if (!inviteAndIndex) return sendSocketMessage(ws, undefined, undefined, undefined, replyto); const { invite, index } = inviteAndIndex; // Make sure they are the owner. if (!memberInfoEq(ws.metadata.memberInfo, invite.owner)) { console.error( `Player tried to delete an invite that wasn't theirs! Invite ID: ${id} Socket: ${socketUtility.stringifySocketMetadata(ws)}`, ); return sendSocketMessage( ws, 'general', 'printerror', 'You are forbidden to delete this invite.', replyto, ); } deleteInviteByIndex(ws, invite, index, { replyto }); } export { cancelInvite, cancelinviteschem }; ================================================ FILE: src/server/game/invitesmanager/createinvite.ts ================================================ // src/server/game/invitesmanager/createinvite.ts /** * This script handles invite creation, making sure that the invites have valid properties. */ import type { Invite } from './inviteutility.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import type { Rating, ServerUsernameContainer } from '../../../shared/types.js'; import * as z from 'zod'; import uuid from '../../../shared/util/uuid.js'; import metadatautil from '../../../shared/chess/util/metadatautil.js'; import { variantCodes } from '../../../shared/chess/variants/variant.js'; import { players as p } from '../../../shared/chess/util/typeutil.js'; import { Leaderboards, VariantLeaderboards, } from '../../../shared/chess/variants/validleaderboard.js'; import timecontrol from '../timecontrol.js'; import { getTranslation } from '../../utility/translate.js'; import { isSocketInAnActiveGame } from '../gamemanager/activeplayers.js'; import { getEloOfPlayerInLeaderboard } from '../../database/leaderboardsManager.js'; import { sendNotify, sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { existingInviteHasID, userHasInvite, addInvite, IDLengthOfInvites, } from './invitesmanager.js'; /** The zod schema for validating the contents of the createinvite message. */ const createinviteschem = z .strictObject({ variant: z.enum(variantCodes), // `${number}+${number}` | '-' clock: z .union([z.templateLiteral([z.number(), '+', z.number()]), z.literal('-')]) .refine((c) => timecontrol.isValid(c), { error: 'Invalid clock value.' }), color: z.literal([p.WHITE, p.BLACK, null]), publicity: z.enum(['public', 'private']), rated: z.enum(['casual', 'rated']), tag: z.string().length(8), }) .refine( (val) => { // Additional refinements for cross-property validation if (val.rated === 'rated') { // Rated game validation... if (!(val.variant in VariantLeaderboards)) return false; // Invalid variant for a rated game. if (val.clock === '-') return false; // Invalid clock for a rated game. if (val.color !== null && val.publicity !== 'private') return false; // Specific colors are only allowed if the rated game is also private. } return true; // Casual games can have any properties. }, { error: 'Invalid invite parameters for a rated game.' }, ); type CreateInviteMessage = z.infer; /** * Creates a new invite from their websocket message. * @param ws - Their socket * @param messageContents - The incoming socket message that SHOULD contain the invite properties! * @param replyto - The incoming websocket message ID, to include in the reply */ function createInvite( ws: CustomWebSocket, messageContents: CreateInviteMessage, replyto?: number, ): void { // invite: { id, owner, variant, clock, color, rated, publicity } if (isSocketInAnActiveGame(ws)) return sendNotify(ws, 'server.javascript.ws-already_in_game', { replyto }); // Can't create invite because they are already in a game // Make sure they don't already have an existing invite if (userHasInvite(ws)) { sendSocketMessage( ws, 'general', 'printerror', "Can't create an invite when you have one already.", replyto, ); console.error("Player already has existing invite, can't create another!"); return; } const invite = getInviteFromWebsocketMessageContents(ws, messageContents, replyto); if (!invite) return; // Message contained invalid invite parameters. Error already sent to the client. // Invite has all legal parameters! // Check if user tries creating a rated game despite not being allowed to if (invite.rated === 'rated' && !(ws.metadata.memberInfo.signedIn && ws.metadata.verified)) { const message = getTranslation( 'server.javascript.ws-rated_invite_verification_needed', ws.metadata.cookies?.i18next, ); return sendSocketMessage(ws, 'general', 'notify', message, replyto); } // Create the invite now ... addInvite(ws, invite, replyto); } /** * Makes sure the socket message is an object, and strips it of all non-variant related properties. * STILL DO EXPLOIT checks on the specific invite values after this!! * @param ws * @param messageContents - The incoming websocket message contents (separate from route and action) * @param replyto - The incoming websocket message ID, to include in the reply * @returns The Invite object, or void it the message contents were invalid. */ function getInviteFromWebsocketMessageContents( ws: CustomWebSocket, messageContents: CreateInviteMessage, replyto?: number, ): Invite | void { // Verify their invite contains the required properties... // Is it an object? (This may pass if it is an array, but arrays won't crash when accessing property names, so it doesn't matter. It will be rejected because it doesn't have the required properties.) // We have to separately check for null because JAVASCRIPT has a bug where typeof null => 'object' if (typeof messageContents !== 'object' || messageContents === null) return sendSocketMessage( ws, 'general', 'printerror', 'Cannot create invite when incoming socket message body is not an object!', replyto, ); /** * What properties should the invite have from the incoming socket message? * variant * clock * color * rated * publicity * tag * * We further need to manually add the properties: * id * owner * usernamecontainer */ let id: string; do { id = uuid.generateID_Base36(IDLengthOfInvites); } while (existingInviteHasID(id)); const owner = ws.metadata.memberInfo; let rating: Rating | undefined; if (ws.metadata.memberInfo.signedIn) { // Fallback to the elo on the INFINITY leaderboard, if the variant does not have a leaderboard. const leaderboardId = VariantLeaderboards[messageContents.variant] ?? Leaderboards.INFINITY; rating = getEloOfPlayerInLeaderboard(ws.metadata.memberInfo.user_id, leaderboardId); } const usernamecontainer: ServerUsernameContainer = { type: owner.signedIn ? 'player' : 'guest', username: owner.signedIn ? owner.username : metadatautil.GUEST_NAME_ICN_METADATA, rating, }; return { id, owner, usernamecontainer, variant: messageContents.variant, clock: messageContents.clock, rated: messageContents.rated, color: messageContents.color, tag: messageContents.tag, publicity: messageContents.publicity, }; } export { createInvite, createinviteschem }; ================================================ FILE: src/server/game/invitesmanager/invitesmanager.ts ================================================ // src/server/game/invitesmanager/invitesmanager.ts /** * This script manages our list of all active invites, * subscribes and unsubs sockets to and from the invites * subscription list, * and broadcasts changes out to the clients. */ import type { AuthMemberInfo } from '../../types.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import type { SafeInvite, Invite } from './inviteutility.js'; import jsutil from '../../../shared/util/jsutil.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; import { getActiveGameCount } from '../gamemanager/gamecount.js'; import { isInvitePrivate, safelyCopyInvite, isInvitePublic, memberInfoEq, } from './inviteutility.js'; import { getInviteSubscribers, addSocketToInvitesSubs, removeSocketFromInvitesSubs, doesUserHaveActiveConnection, } from './invitessubscribers.js'; //------------------------------------------------------------------------------------------- /** Whether to log new invite creations/deletions to the console */ const printNewInviteCreationsAndDeletions = false; /** The number of digits generated invite IDs are. */ const IDLengthOfInvites = 5; /** The list of all active invites, including private ones. */ const invites: Invite[] = []; /** * Time to allow the client to reconnect after an UNEXPECTED (not purposeful) * socket closure before any invite of theirs is deleted! */ const cushionToDisconnectMillis = 5000; // 5 seconds /** * An object containing usernames for the keys, and setTimeout timer ID's for the values, * that represent the timers that are currently active to delete all a player's invites * since they've disconnected. */ const timersMember: Record> = {}; /** * An object containing browser-ids for the keys, and setTimeout timer ID's for the values, * that represent the timers that are currently active to delete all a browser's invites * since they've disconnected. */ const timersBrowser: Record> = {}; //------------------------------------------------------------------------------------------- /** * Gets the list of public invites with sensitive information REMOVED (such as browser-ids) * DOES NOT include private invites, not even your own, ADD THOSE SEPARATELY. */ function getPublicInvitesListSafe(): SafeInvite[] { const deepCopiedInvites: SafeInvite[] = []; for (const invite of invites) { if (isInvitePrivate(invite)) continue; // Remove private invites deepCopiedInvites.push(safelyCopyInvite(invite)); // Remove sensitive information } return deepCopiedInvites; } /** * Adds any private invite that belongs to the socket to the provided invites list. * @param ws * @param copyOfInvitesList - A copy of the invites list, so we don't modify the original */ function addMyPrivateInviteToList( ws: CustomWebSocket, copyOfInvitesList: SafeInvite[], ): SafeInvite[] { for (const invite of invites) { if (isInvitePublic(invite)) continue; // Next invite, this one isn't private if (!memberInfoEq(ws.metadata.memberInfo, invite.owner)) continue; // Doesn't belong to us const inviteSafeCopy = safelyCopyInvite(invite); // Makes a deep copy and removes sensitive information copyOfInvitesList.push(inviteSafeCopy); } return copyOfInvitesList; } // When a PUBLIC invite is added or removed.. /** * Call when a public invite is added or deleted. * @param ws - The websocket that trigerred this public invites change. * @param replyto - The ID of the incoming websocket message that triggered this method */ function onPublicInvitesChange(ws?: CustomWebSocket, replyto?: number): void { // The message that this broadcast is the reply to broadcastInvites(ws, replyto); } /** * Broadcasts the invites list out to all subbed clients. * @param ws - The websocket that trigerred this broadcast. Used to include the replyto id for ONLY THEIR message. * @param replyto - The ID of the incoming websocket message that triggered this broadcast */ function broadcastInvites(ws?: CustomWebSocket, replyto?: number): void { const newInvitesList = getPublicInvitesListSafe(); const currentGameCount = getActiveGameCount(); const subscribedClients = getInviteSubscribers() as Record; for (const subbedSocket of Object.values(subscribedClients)) { const newInvitesListCopy = jsutil.deepCopyObject(newInvitesList); // Only include the replyto code with the invite list if this socket is // THE SAME SOCKET as the one that triggered this broadcast. const includedReplyTo = ws === subbedSocket ? replyto : undefined; sendClientInvitesList(subbedSocket, { invitesList: newInvitesListCopy, currentGameCount, replyto: includedReplyTo, }); } } /** * Sends the invites list to a specified socket, including any private invites the player owns, * and also sends the current active game count. * @param ws - The socket of the player to send the invites list to. * @param options.invitesList - The list of invites to send. Defaults to the public invites list if not provided. [getPublicInvitesListSafe()] * @param options.currentGameCount - The current active game count. Defaults to the current game count if not provided. [getActiveGameCount()] * @param options.replyto - The incoming websocket message ID, to include in the reply, if applicable. */ function sendClientInvitesList( ws: CustomWebSocket, { invitesList = getPublicInvitesListSafe(), currentGameCount = getActiveGameCount(), replyto = undefined, }: { replyto?: number; invitesList?: SafeInvite[]; currentGameCount?: number } = {}, ): void { invitesList = addMyPrivateInviteToList(ws, invitesList); const message = { invitesList, currentGameCount }; sendSocketMessage(ws, 'invites', 'inviteslist', message, replyto); // In order: socket, sub, action, value } /** * Adds a new invite to the list of active invites. * Typically called when an invite is created. Sends the new invites list to the socket. * @param ws - The socket of the player that created this invite. Used to send them the new invites list with their invite. * @param invite - The invite to sdd * @param replyto - The incoming websocket message ID, to include in the reply, if applicable */ function addInvite(ws: CustomWebSocket, invite: Invite, replyto?: number): void { invites.push(invite); if (isInvitePublic(invite)) onPublicInvitesChange(ws, replyto); else sendClientInvitesList(ws, { replyto }); // Send them the new list after their invite creation! if (printNewInviteCreationsAndDeletions) { if (isInvitePrivate(invite)) console.log(`Created PRIVATE invite for user ${JSON.stringify(invite.owner)}`); else console.log(`Created invite for user ${JSON.stringify(invite.owner)}`); } } /** * Deletes an invite from the list of active invites. * Typically called when an invite is canceled. Sends the updated invites list to the socket. * @param ws - The socket of the player that canceled this invite. Used to send them the updated invites list. * @param invite - The invite object to cancel. Contains details about the invite and its owner. * @param index - The index of the invite in the invites array. This is found using {@link getInviteAndIndexByID}. * @param options.dontBroadcast - If true, prevents broadcasting the changes to all clients. [false] * @param options.replyto - The incoming websocket message ID, to include in the reply, if applicable. * @returns true if there was a public invite change */ function deleteInviteByIndex( ws: CustomWebSocket, invite: Invite, index: number, { dontBroadcast = false, replyto = undefined, }: { dontBroadcast?: boolean; replyto?: number } = {}, ): boolean { if (index > invites.length - 1) { console.error( `Cannot delete invite of index ${index} when the length of our invites list is ${invites.length}!`, ); return false; // No public invite change } invites.splice(index, 1); // Delete the invite if (!dontBroadcast) { if (isInvitePublic(invite)) onPublicInvitesChange(ws, replyto); else sendClientInvitesList(ws, { replyto }); // Send them the new list after their invite cancellation! } if (printNewInviteCreationsAndDeletions) console.log(`Deleted invite for user ${JSON.stringify(invite.owner)}`); return isInvitePublic(invite); // true if a public invite changed } /** * Returns true if the provided socket is the owner of any active invites. * If so, they aren't allowed to create more. */ function userHasInvite(ws: CustomWebSocket): boolean { for (const invite of invites) if (memberInfoEq(ws.metadata.memberInfo, invite.owner)) return true; return false; // Player doesn't have an existing invite } /** * Tests if any active invite already has the ID provided. * This is used during generation of a unique invite id. * @returns true if the ID is already in use, false if it's available */ function existingInviteHasID(id: string): boolean { for (const invite of invites) if (invite.id === id) return true; return false; } /** * Finds an index by ID, and returns an object: `{ invite, index }`, otherwise undefined. * @param id - The invite ID * @returns An object: `{ invite, index }`, or undefined if the invite wasn't found. */ function getInviteAndIndexByID(id: string): { invite: Invite; index: number } | undefined { for (let i = 0; i < invites.length; i++) { if (id === invites[i]!.id) return { invite: invites[i]!, index: i }; } return undefined; } //------------------------------------------------------------------------------------------- /** * Returns the first socket subscribed to the invites list that matches the member/browser property. * Typically called when you need to inform a player their invite was accepted. * @returns The websocket, if found, otherwise undefined. */ function findSocketFromOwner(owner: AuthMemberInfo): CustomWebSocket | undefined { // { member/browser } // Iterate through all sockets, until you find one that matches the authentication of our invite owner const subscribedClients = getInviteSubscribers(); // { id: ws } for (const ws of Object.values(subscribedClients)) { if (memberInfoEq(owner, ws.metadata.memberInfo)) return ws; } console.log( `Unable to find a socket subbed to the invites list that belongs to ${JSON.stringify(owner)}!`, ); return undefined; } /** * Subscribes a socket to the invites subscription list, * sends them the list of active invites, * and cancels any active timers to delete their invites if * their socket was previously closed by a network interruption. */ function subToInvitesList(ws: CustomWebSocket): void { if (ws.metadata.subscriptions.invites) return; // Already subscribed. Happens occasionally addSocketToInvitesSubs(ws); sendClientInvitesList(ws); cancelTimerToDeleteUsersInvitesFromNetworkInterruption(ws); } // Set closureNotByChoice to true if you don't immediately want to delete their invite, but say after 5 seconds. function unsubFromInvitesList(ws: CustomWebSocket, closureNotByChoice?: boolean): void { // data: { route, action, value, id } removeSocketFromInvitesSubs(ws); const owner = ws.metadata.memberInfo; if (!closureNotByChoice) return deleteUserInvitesIfNotConnected(owner); // Delete their existing invites // The closure WASN'T by choice! Set a 5s timer to give them time to reconnect before deleting their invite! // console.log("Setting a 5-second timer to delete a user's invites!"); const timeout = setTimeout(deleteUserInvitesIfNotConnected, cushionToDisconnectMillis, owner); if (owner.signedIn) timersMember[owner.user_id] = timeout; else timersBrowser[owner.browser_id] = timeout; } /** * Cancels any running timers to delete a users invites from a network interruption. * @param ws - The socket of the new invite subscriber */ function cancelTimerToDeleteUsersInvitesFromNetworkInterruption(ws: CustomWebSocket): void { if (ws.metadata.memberInfo.signedIn) { clearTimeout(timersMember[ws.metadata.memberInfo.user_id]); delete timersMember[ws.metadata.memberInfo.user_id]; } else if (ws.metadata) { clearTimeout(timersBrowser[ws.metadata.memberInfo.browser_id]); delete timersBrowser[ws.metadata.memberInfo.browser_id]; } } //------------------------------------------------------------------------------------------- /** * Deletes the invite associated with a specific member or browser ID, * but only if they don't have an active connection. * If the invite belongs to a signed-in member, checks username; * otherwise, it checks the browser ID. * If any public invite is deleted, it broadcasts the new invites list to all subscribers. * @param signedIn - Flag to specify if the invite is for a signed-in member (true) or for a browser ID (false) * @param identifier - The identifier of the member or browser (username for signed-in members, browser ID for non-signed-in users) */ function deleteUserInvitesIfNotConnected(info: AuthMemberInfo): void { // Don't delete invite if there is an active connection const hasActiveConnection = doesUserHaveActiveConnection(info); if (hasActiveConnection) { // console.log(`${signedIn ? `Member "${identifier}"` : `Browser "${identifier}"`} is still connected, not deleting invite.`); return; } // Proceed with deleting the invite if not connected deleteUsersExistingInvite(info); } /** * Deletes the invite associated with a specific member or browser ID. * If any public invite is deleted, it optionally broadcasts the new invites list to all subscribers. * @param info The info related to a user * @param options.broadCastNewInvites - Flag to specify whether to broadcast the new invites list after deleting (defaults to true). [true] * @returns Returns true if any public invite was deleted, otherwise false. */ function deleteUsersExistingInvite( info: AuthMemberInfo, { broadCastNewInvites = true } = {}, ): boolean { let deletedPublicInvite = false; for (let i = invites.length - 1; i >= 0; i--) { const invite = invites[i]!; if (!memberInfoEq(info, invite.owner)) continue; // Match! Delete invites.splice(i, 1); // Delete the invite if (isInvitePublic(invite)) deletedPublicInvite = true; if (printNewInviteCreationsAndDeletions) console.log( `${info.signedIn ? `Deleted member's invite. Username: ${info.username}` : `Deleted browser's invite. Browser: ${info.browser_id}`}`, ); } if (deletedPublicInvite && broadCastNewInvites) onPublicInvitesChange(); // Broadcast the change if a public invite was deleted return deletedPublicInvite; } //------------------------------------------------------------------------------------------- export { subToInvitesList, unsubFromInvitesList, existingInviteHasID, userHasInvite, addInvite, deleteInviteByIndex, getInviteAndIndexByID, deleteUsersExistingInvite, findSocketFromOwner, onPublicInvitesChange, IDLengthOfInvites, }; ================================================ FILE: src/server/game/invitesmanager/invitesrouter.ts ================================================ // src/server/game/invitesmanager/invitesrouter.ts /* * This script routes all incoming websocket messages * with the "invites" route to where they need to go. */ import type { CustomWebSocket } from '../../socket/socketUtility.js'; import * as z from 'zod'; import { createInvite, createinviteschem } from './createinvite.js'; import { cancelInvite, cancelinviteschem } from './cancelinvite.js'; import { acceptInvite, acceptinviteschem } from './acceptinvite.js'; const InvitesSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('createinvite'), value: createinviteschem }), z.strictObject({ action: z.literal('cancelinvite'), value: cancelinviteschem }), z.strictObject({ action: z.literal('acceptinvite'), value: acceptinviteschem }), ]); type InvitesMessage = z.infer; /** * Routes all incoming websocket messages related to invites. * @param ws * @param contents * @param id - The id of the incoming message. This should be included in our response as the `replyto` property. * @returns */ function routeInvitesMessage(ws: CustomWebSocket, contents: InvitesMessage, id: number): void { // data: { route, action, value, id } // Route them according to their action switch (contents.action) { case 'createinvite': createInvite(ws, contents.value, id); break; case 'cancelinvite': cancelInvite(ws, contents.value, id); break; case 'acceptinvite': acceptInvite(ws, contents.value, id); break; default: console.error( // @ts-ignore `UNKNOWN web socket action received in invites route! "${contents.action}"`, ); } } export { routeInvitesMessage, InvitesSchema }; export type {}; ================================================ FILE: src/server/game/invitesmanager/invitessubscribers.ts ================================================ // src/server/game/invitesmanager/invitessubscribers.ts /* * This script stores the list of websockets currently subscribed * to the invites list. * * On demand, it broadcasts stuff out to the players. */ import type { AuthMemberInfo } from '../../types.js'; import type { CustomWebSocket } from '../../socket/socketUtility.js'; import { memberInfoEq } from './inviteutility.js'; import { sendSocketMessage } from '../../socket/sendSocketMessage.js'; /** * List of clients currently subscribed to invites list events, with their * socket id for the keys, and their socket for the value. */ const subscribedClients: Record = {}; // { id: ws } const printSubscriberCount = false; /** * Returns the object containing all sockets currently subscribed to the invites list, * with their socket id for the keys, and their socket for the value. */ function getInviteSubscribers(): typeof subscribedClients { return subscribedClients; } /** * Broadcasts a message to all invites subscribers. * @param action - The action of the socket message (i.e. "inviteslist") * @param message - The message contents */ function broadcastToAllInviteSubs(action: string, message: any): void { for (const ws of Object.values(subscribedClients)) { sendSocketMessage(ws, 'invites', action, message); // In order: socket, sub, action, value } } /** * Adds a new socket to the invite subscriber list. */ function addSocketToInvitesSubs(ws: CustomWebSocket): void { const socketID = ws.metadata.id; if (subscribedClients[socketID]) return console.error('Cannot sub socket to invites list because they already are!'); subscribedClients[socketID] = ws; ws.metadata.subscriptions.invites = true; if (printSubscriberCount) console.log(`Invites subscriber count: ${Object.keys(subscribedClients).length}`); } /** * Removes a socket from the invite subscriber list. * DOES NOT delete any of their existing invites! That should be done before. */ function removeSocketFromInvitesSubs(ws: CustomWebSocket): void { if (!ws) return console.error("Can't remove socket from invites subs list because it's undefined!"); const socketID = ws.metadata.id; if (!subscribedClients[socketID]) return; // Cannot unsub socket from invites list because they aren't subbed. delete subscribedClients[socketID]; delete ws.metadata.subscriptions.invites; if (printSubscriberCount) console.log(`Invites subscriber count: ${Object.keys(subscribedClients).length}`); } /** * Checks if a member or browser ID has at least one active connection. * @returns true if the member or browser ID has at least one active connection, false otherwise. */ function doesUserHaveActiveConnection(info: AuthMemberInfo): boolean { return Object.values(subscribedClients).some((ws) => { return memberInfoEq(ws.metadata.memberInfo, info); }); } export { getInviteSubscribers, broadcastToAllInviteSubs, addSocketToInvitesSubs, removeSocketFromInvitesSubs, doesUserHaveActiveConnection, }; ================================================ FILE: src/server/game/invitesmanager/inviteutility.ts ================================================ // src/server/game/invitesmanager/inviteutility.ts /* * This script stores utility methods for working * with single invites, not multiple */ import type { Player } from '../../../shared/chess/util/typeutil.js'; import type { VariantCode } from '../../../shared/chess/variants/variantdictionary.js'; import type { AuthMemberInfo } from '../../types.js'; import type { ServerUsernameContainer, TimeControl } from '../../../shared/types.js'; import jsutil from '../../../shared/util/jsutil.js'; // Type Definitions /** A lobby game invite. */ interface Invite extends SafeInvite { /** Contains the identifier of the owner of the invite, whether a member or browser. */ owner: AuthMemberInfo; } /** * All properties of an invite that is safe to send to clients. * Doesn't contain sensitive information such as browser-id cookies. */ interface SafeInvite { id: string; // A unique identifier, containing lowercase letters a-z and numbers 0-9. usernamecontainer: ServerUsernameContainer; // The type of the owner (guest/player), their username, and elo if applicable. tag: string; // Used to verify if an invite is your own. variant: VariantCode; clock: TimeControl; color: Player | null; rated: 'casual' | 'rated'; publicity: 'public' | 'private'; } //------------------------------------------------------------------------------------------- /** * Returns true if the invite is private */ function isInvitePrivate(invite: Invite): boolean { return invite.publicity === 'private'; } /** * Returns true if the invite is public */ function isInvitePublic(invite: Invite): boolean { return invite.publicity === 'public'; } /** * Removes sensitive data such as their browser-id. * Returns a deep copy of the original invite. */ function makeInviteSafe(invite: Invite): SafeInvite { return { id: invite.id, usernamecontainer: jsutil.deepCopyObject(invite.usernamecontainer), tag: invite.tag, variant: invite.variant, clock: invite.clock, color: invite.color, rated: invite.rated, publicity: invite.publicity, }; } /** * Makes a deep copy of provided invite, and * removes sensitive data such as their browser-id. */ function safelyCopyInvite(invite: Invite): SafeInvite { const inviteDeepCopy = jsutil.deepCopyObject(invite); return makeInviteSafe(inviteDeepCopy); } /** Compares two MemberInfo objects to see if they are the same person or not. */ function memberInfoEq(u1: AuthMemberInfo, u2: AuthMemberInfo): boolean { if (u1.signedIn) { if (!u2.signedIn) return false; return u1.user_id === u2.user_id; } else if (u2.signedIn) return false; // This ensures if they have the same browser-id, but mi2 is signed in, they are not equal. else return u1.browser_id === u2.browser_id; } //------------------------------------------------------------------------------------------- export type { Invite, SafeInvite }; export { isInvitePrivate, isInvitePublic, safelyCopyInvite, memberInfoEq }; ================================================ FILE: src/server/game/servermetadatautil.ts ================================================ // src/server/game/servermetadatautil.ts /** * Server-side helpers for building ICN game metadata. */ import type { VariantCode } from '../../shared/chess/variants/variantdictionary.js'; import type { MetaData, TimeControl } from '../../shared/types.js'; import uuid from '../../shared/util/uuid.js'; import variant from '../../shared/chess/variants/variant.js'; import timeutil from '../../shared/util/timeutil.js'; // Types -------------------------------------------------------------------------- /** Per-player inputs for {@link buildGameMetadata}. */ export interface PlayerMetaInput { /** Display name — the player's username, or {@link GUEST_NAME_ICN_METADATA} for unauthenticated players. */ name: string; /** User ID, present only for signed-in players. */ id?: number; /** Already-formatted elo string (e.g. `'1434'` or `'1500?'`), present only for signed-in players. */ elo?: string; } // Functions ----------------------------------------------------------------------- /** * Builds a {@link MetaData} object from the common game properties. * Metadata is always in English. * @param rated - Whether the game is rated. * @param variantCode - The variant code (NOT the English translation). * @param clock - The time-control string. * @param utcTimestamp - The epoch-ms timestamp used for the `UTCDate`/`UTCTime` fields. * @param white - Identity information for the White player. * @param black - Identity information for the Black player. */ function buildGameMetadata( rated: boolean, variantCode: VariantCode, clock: TimeControl, utcTimestamp: number, white: PlayerMetaInput, black: PlayerMetaInput, ): MetaData { const variantEnglishName = variant.getVariantName(variantCode); const RatedOrCasual = rated ? 'Rated' : 'Casual'; const { UTCDate, UTCTime } = timeutil.convertTimestampToUTCDateUTCTime(utcTimestamp); const gameMetadata: MetaData = { Event: `${RatedOrCasual} ${variantEnglishName} infinite chess game`, Site: 'https://www.infinitechess.org/', Round: '-', Variant: variantEnglishName, White: white.name, Black: black.name, TimeControl: clock, UTCDate, UTCTime, }; if (white.id !== undefined) { gameMetadata.WhiteID = uuid.base10ToBase62(white.id); if (white.elo !== undefined) gameMetadata.WhiteElo = white.elo; } if (black.id !== undefined) { gameMetadata.BlackID = uuid.base10ToBase62(black.id); if (black.elo !== undefined) gameMetadata.BlackElo = black.elo; } return gameMetadata; } // Exports ----------------------------------------------------------------------- export default { buildGameMetadata, }; ================================================ FILE: src/server/game/statlogger.ts ================================================ // src/server/game/statlogger.ts import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'node:url'; import timeutil from '../../shared/util/timeutil.js'; import { logEventsAndPrint } from '../middleware/logEvents.js'; import { readFile, writeFile } from '../utility/lockFile.js'; import 'dotenv/config'; // Imports all properties of process.env, if it exists const __dirname = path.dirname(fileURLToPath(import.meta.url)); import type { ServerGame } from './gamemanager/gameutility.js'; const statsPath = path.resolve('database/stats.json'); (function ensureStatsFileExists(): void { if (fs.existsSync(statsPath)) return; // Already exists const content = JSON.stringify( { gamesPlayed: { byDay: {}, byMonth: {}, }, moveCount: {}, }, null, 2, ); fs.mkdirSync(path.dirname(statsPath), { recursive: true }); fs.writeFileSync(statsPath, content); console.log('Generated stats file'); })(); let stats: { moveCount: Record; gamesPlayed: { byDay: Record; byMonth: Record>; allTime: Record; }; }; try { stats = await readFile('database/stats.json'); } catch (error: unknown) { if (process.env['VITEST']) { console.warn('Mocking stats.json for test environment'); stats = { moveCount: {}, gamesPlayed: { byDay: {}, byMonth: {}, allTime: {}, }, }; } else { const message = error instanceof Error ? error.message : String(error); throw new Error('Unable to read stats.json on startup: ' + message); } } /** * Saves and increments the stats for the played variant * @param servergame - The game to log * @returns */ function logGame({ basegame, match }: ServerGame): void { // Only log the game if at least 2 moves were played! (resignable) // Black-moves-first games are logged if at least 1 move is played! if (basegame.moves.length < 2) return; // What is the current month? const month = timeutil.getCurrentMonth(); // 'yyyy-mm' // What is the current day? const day = timeutil.getCurrentDay(); // 'yyyy-mm-dd' // What variant was played? const variant = match.variant; // Now record the number of moves played const plyCount = basegame.moves.length; if (stats.moveCount['all'] === undefined) stats.moveCount['all'] = 0; stats.moveCount['all'] += plyCount; if (stats.moveCount[variant] === undefined) stats.moveCount[variant] = 0; stats.moveCount[variant] += plyCount; if (stats.moveCount[month] === undefined) stats.moveCount[month] = 0; stats.moveCount[month] += plyCount; // Increment the games played today if (stats.gamesPlayed.byDay[day] === undefined) stats.gamesPlayed.byDay[day] = 1; else stats.gamesPlayed.byDay[day]++; // @ts-ignore incrementMonthsGamesPlayed(stats.gamesPlayed, 'allTime', variant); incrementMonthsGamesPlayed(stats.gamesPlayed.byMonth, month, variant); //---------------------------------------------------------- void saveStats(); // Saves stats in the database. } function incrementMonthsGamesPlayed( parent: Record>, month: string, variant: string, ): void { // allTime / yyyy-mm= // Does this month's property exist yet? if (parent[month] === undefined) parent[month] = {}; // Increment this month's all-variants by 1 if (parent[month]['all'] === undefined) parent[month]['all'] = 1; else parent[month]['all']++; // Increment this month's this variant by 1 if (parent[month][variant] === undefined) parent[month][variant] = 1; else parent[month][variant]++; } // Sometimes this causes a file-already-locked error if multiple games are deleted at once. async function saveStats(): Promise { // Async function try { await writeFile(path.join(__dirname, '..', '..', '..', 'database', 'stats.json'), stats); } catch (e) { const errMsg = `Failed to lock/write stats.json after logging game! Didn't save the new stats, but it should still be accurate in memory.` + (e instanceof Error ? e.message : String(e)); void logEventsAndPrint(errMsg, 'errLog.txt'); } } export default { logGame, }; ================================================ FILE: src/server/game/timecontrol.ts ================================================ // src/server/game/timecontrol.ts /** * Stores valid time controls for lobby invites. */ import type { TimeControl } from '../../shared/types.js'; /** These are the allowed time controls in production. */ const validTimeControls = [ '-', '60+2', '120+2', '180+2', '300+2', '480+3', '600+4', '600+6', '720+5', '900+6', '1200+8', '1500+10', '1800+15', '2400+20', ]; /** These are only allowed in development. */ const devTimeControls = ['15+2']; /** Whether the given time control is valid. */ function isValid(time_control: TimeControl): boolean { return ( validTimeControls.includes(time_control) || (process.env['NODE_ENV'] === 'development' && devTimeControls.includes(time_control)) ); } export default { isValid, }; ================================================ FILE: src/server/middleware/banned.ts ================================================ // src/server/middleware/banned.ts /** * BLACKLISTED EMAILS are now handled in the email_blacklist database table! */ import fs from 'fs'; import path from 'path'; import { readFile } from '../utility/lockFile.js'; const bannedPath = path.resolve('database/banned.json'); ensureBannedFileExists: { if (fs.existsSync(bannedPath)) break ensureBannedFileExists; // Already exists const content = JSON.stringify( { emails: {}, IPs: {}, 'browser-ids': {}, }, null, 2, ); fs.mkdirSync(path.dirname(bannedPath), { recursive: true }); fs.writeFileSync(bannedPath, content); console.log('Generated banned file'); } let bannedJSON: { IPs: Record; emails: Record; 'browser-ids': Record; }; try { bannedJSON = await readFile(bannedPath); } catch (error: unknown) { if (process.env['VITEST']) { console.warn('Mocking banned.json for test environment'); bannedJSON = { IPs: {}, emails: {}, 'browser-ids': {}, }; } else { const message = error instanceof Error ? error.message : String(error); throw new Error('Unable to read banned.json on startup: ' + message); } } // EMAIL BANS are now handled in the email_blacklist database table! // function isEmailBanned(email: string): boolean { // const emailLowercase = email.toLowerCase(); // return bannedJSON.emails[emailLowercase] !== undefined; // } function isIPBanned(ip: string): boolean { return bannedJSON.IPs[ip] !== undefined; } function isBrowserIDBanned(browserID: string): boolean { return bannedJSON['browser-ids'][browserID] !== undefined; } export { isIPBanned, isBrowserIDBanned }; ================================================ FILE: src/server/middleware/errorHandler.ts ================================================ // src/server/middleware/errorHandler.ts import type { Request, Response } from 'express'; import { logEventsAndPrint } from './logEvents.js'; import { getTranslationForReq } from '../utility/translate.js'; function errorHandler(err: Error, req: Request, res: Response, _next: Function): void { // Catches errors from for example the body parser, which can throw if the body is too large. // This needs to be handled itself, as i18next was never defined. if ('status' in err) { const status = (err as Error & { status: number }).status; if (status >= 400 && status < 500) { res.status(status).json({ error: err.message || 'Bad request' }); return; } } try { const errMessage = `${err.stack}`; logEventsAndPrint(errMessage, 'errLog.txt'); // This sends back to the browser the error, instead of the ENTIRE stack which is PRIVATE. const messageForClient = getTranslationForReq('server.javascript.ws-server_error', req); res.status(500).send(messageForClient); // 500: Server error } catch (error: unknown) { // Last line of defense if an error occurs in the middleware error catcher const errMessage = error instanceof Error ? error.stack : String(error); console.error('Critical error in errorHandler middleware:', errMessage); res.status(500).send('Critical server error.'); } } export default errorHandler; ================================================ FILE: src/server/middleware/logEvents.ts ================================================ // src/server/middleware/logEvents.ts import type { IncomingMessage } from 'node:http'; import type { Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import { format } from 'date-fns'; import { v4 as uuid } from 'uuid'; import { promises as fsPromises } from 'fs'; import paths from '../config/paths.js'; import { getClientIP } from '../utility/IP.js'; import socketUtility, { CustomWebSocket } from '../socket/socketUtility.js'; const giveLoggedItemsUUID = false; /** * Logs the provided message by appending a line to the end of the specified log file. * @param message - The message to log. * @param logName - The name of the log file. */ async function logEvents(message: string, logName: string): Promise { if (typeof message !== 'string') return console.trace('Cannot log message when it is not a string.'); if (!logName) return console.trace('Log name MUST be provided when logging an event!'); const dateTime = format(new Date(), 'yyyy/MM/dd HH:mm:ss'); const logItem = giveLoggedItemsUUID ? `${dateTime} ${uuid()} ${message}\n` // With unique UUID : `${dateTime} ${message}\n`; try { fs.mkdirSync(paths.LOGS_DIR, { recursive: true }); await fsPromises.appendFile(path.join(paths.LOGS_DIR, logName), logItem); } catch (err: unknown) { if (err instanceof Error) console.error(`Error logging event: ${err.message}`); else console.error('Error logging event:', err); } } /** * Logs the provided message by appending a line to the end of the specified log file, * and prints it to the console as an error. * @param message - The message to log. * @param logName - The name of the log file. */ async function logEventsAndPrint(message: string, logName: string): Promise { if (logName === 'errLog.txt') console.error(message); else console.log(message); // Prevents non error logs from going to PM2's error logs. await logEvents(message, logName); } /** Middleware that logs the incoming request, then calls `next()`. */ function reqLogger(req: Request, res: Response, next: () => void): void { const clientIP = getClientIP(req) || 'Unknown ip'; const origin = req.headers.origin || 'Unknown origin'; // Redact sensitive tokens that appear in URL paths so they are never written to log files. const sanitizedUrl = req.url .replace(/(\/reset-password\/)([^?#/]+)/, '$1[REDACTED]') .replace(/(\/verify\/[^/]+\/)([^?#/]+)/, '$1[REDACTED]'); let logThis = `${origin} ${clientIP} ${req.method} ${sanitizedUrl} ${req.headers['user-agent']}`; // Delete passwords from incoming form data let sensoredBody; if (JSON.stringify(req.body) !== '{}') { // Not an empty object sensoredBody = { ...req.body }; delete sensoredBody.password; delete sensoredBody.username; // Since IP's are logged with each request, If you know a deleted account's username, it can be indirectly traced to their IP if we don't delete them here. delete sensoredBody.email; logThis += `\n${JSON.stringify(sensoredBody)}`; } logEvents(logThis, 'reqLog.txt'); next(); // Continue to next middleware } /** * Logs websocket connection upgrade requests into `wsInLog.txt` * @param req - The request object * @param ws - The websocket object */ function logWebsocketStart(req: IncomingMessage, ws: CustomWebSocket): void { const socketID = ws.metadata.id; const stringifiedSocketMetadata = socketUtility.stringifySocketMetadata(ws); const userAgent = req.headers['user-agent']; // const userAgent = ws.metadata.userAgent; const logThis = `Opened socket of ID "${socketID}": ${stringifiedSocketMetadata} User agent: ${userAgent}`; logEvents(logThis, 'wsInLog.txt'); } /** * Logs incoming websocket messages into `wsInLog.txt` * @param ws - The websocket object * @param messageData - The raw data of the incoming message, as a string */ function logReqWebsocketIn(ws: CustomWebSocket, messageData: string): void { const socketID = ws.metadata.id; const logThis = `From socket of ID "${socketID}": ${messageData}`; logEvents(logThis, 'wsInLog.txt'); } /** * Logs outgoing websocket messages into `wsOutLog.txt` * @param ws - The websocket object * @param messageData - The raw data of the outgoing message, as a string */ function logReqWebsocketOut(ws: CustomWebSocket, messageData: string): void { const socketID = ws.metadata.id; const logThis = `To socket of ID "${socketID}": ${messageData}`; logEvents(logThis, 'wsOutLog.txt'); } export { logEvents, logEventsAndPrint, reqLogger, logWebsocketStart, logReqWebsocketIn, logReqWebsocketOut, }; ================================================ FILE: src/server/middleware/middleware.ts ================================================ // src/server/middleware/middleware.ts /** * This module configures the middleware waterfall of our server */ import type { Express, Request, Response, NextFunction } from 'express'; import path from 'path'; import cors from 'cors'; import helmet from 'helmet'; import express from 'express'; import i18next from 'i18next'; import { handle } from 'i18next-http-middleware'; import cookieParser from 'cookie-parser'; import { fileURLToPath } from 'node:url'; import send404 from './send404.js'; import errorHandler from './errorHandler.js'; import { reqLogger } from './logEvents.js'; import { verifyJWT } from './verifyJWT.js'; import { rateLimit } from './rateLimit.js'; import EditorSavesAPI from '../api/EditorSavesAPI.js'; import secureRedirect from './secureRedirect.js'; import { rootRouter } from '../routes/root.js'; import { handleLogin } from '../controllers/loginController.js'; import { handleLogout } from '../controllers/logoutController.js'; import { verifyAccount } from '../controllers/verifyAccountController.js'; import { getMemberData } from '../api/MemberAPI.js'; import { removeAccount } from '../controllers/deleteAccountController.js'; import { processCommand } from '../api/AdminPanel.js'; import { getContributors } from '../api/GitHub.js'; import { handleSesWebhook } from '../controllers/awsWebhook.js'; import { accessTokenIssuer } from '../controllers/authenticationTokens/accessTokenIssuer.js'; import { getLeaderboardData } from '../api/LeaderboardAPI.js'; import { requestConfirmEmail } from '../controllers/emailController.js'; import { handlePrepareRestart } from '../controllers/deployController.js'; import { assignOrRenewBrowserID } from '../controllers/browserIDManager.js'; import { postPrefs, setPrefsCookie } from '../api/Prefs.js'; import { postCheckmateBeaten, setPracticeProgressCookie } from '../api/PracticeProgress.js'; import { getUnreadNewsCount, getUnreadNewsDatesEndpoint, markNewsAsRead } from '../api/NewsAPI.js'; import { handleForgotPasswordRequest, handleResetPassword, } from '../controllers/passwordResetController.js'; import { checkEmailValidity, checkUsernameAvailable, createNewMember, } from '../controllers/createAccountController.js'; import { createAccountLimiter, resendAccountVerificationLimiter, forgotPasswordLimiter, editorSaveLimiter, editorLoadLimiter, } from './rateLimiters.js'; // Constants ------------------------------------------------------------------------- const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Functions ------------------------------------------------------------------------- /** * Configures the Middleware Waterfall * * app.use adds the provided function to EVERY SINGLE router and incoming connection. * Each middleware function must call next() to go to the next middleware. * Connections that do not pass one middleware will not continue. * * @param app - The express application instance. */ export function configureMiddleware(app: Express): void { // Note: requests that are rate limited will not be logged, to mitigate slow-down during a DDOS. app.use(rateLimit); // This allows us to retrieve json-received-data as a parameter/data! // The logger can't log the request body without this. // This also ensures all requests with content-type "application/json" have a body as an object, even if empty. // Increased to 2mb to support large editor position saves (ICN data up to 1MB) app.use(express.json({ limit: '2mb' })); // Limit the size to avoid parsing excessively large objects. Beyond this should throw an error caught by our error handling middleware. app.use(reqLogger); // Log the request // Security Headers & HTTPS Enforcement app.use(secureRedirect); // Redirects http to secure https app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: [ "'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://static.cloudflareinsights.com', ], // Allows inline scripts scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allows inline event handlers objectSrc: ["'none'"], frameSrc: ["'self'", 'https://www.youtube.com'], imgSrc: ["'self'", 'data:', 'https://avatars.githubusercontent.com', 'blob:'], }, }, }), ); // Path Traversal Protection, and error protection from malformed URLs app.use((req: Request, res: Response, next: NextFunction) => { try { const decoded = decodeURIComponent(req.url); // Check 1: Raw encoded patterns (before decoding) const encodedPatterns = /(%2e%2e|%252e|%%32%65)/gi; if (encodedPatterns.test(req.url)) { // console.warn('Blocked traversal:', req.url); // console.warn('Decoded URL:', decoded); res.status(403).send('Forbidden'); return; } // Check 2: Decoded path segments const segments = decoded.split(/[\\/]/); if (segments.includes('..')) { // Console warn both the decoded and the original URL // console.warn('Blocked traversal:', req.url); // console.warn('Decoded URL:', decoded); res.status(403).send('Forbidden'); return; } next(); } catch (_err) { // console.warn('Blocked invalid URL encoding:', req.url); res.status(400).send('Invalid URL encoding'); } }); /** This sets req.i18n, and req.i18n.resolvedLanguage */ app.use(handle(i18next, { removeLngFromUrl: false })); app.use(cors()); // CUSTOM express.json() NEEDED because AWS SNS sends text/plain instead of application/json! But it is still parsable as JSON. const awsParser = express.json({ limit: '50kb', type: ['text/plain', 'application/json'], }); // Webhook endpoint for AWS Simple Email Service (SES) to notify us of bounces and complaints app.post('/webhooks/ses', awsParser, handleSesWebhook); /** * Allow processing urlencoded (FORM) data so that we can retrieve it as a parameter/variable. * (e.g. when the content-type header is 'application/x-www-form-urlencoded') */ app.use(express.urlencoded({ limit: '10kb', extended: false })); // Limit the size to avoid parsing excessively large objects // Sets the req.cookies property app.use(cookieParser()); // Serve public assets. (e.g. css, scripts, images, audio) app.use(express.static(path.join(__dirname, '../../client'))); // Serve public assets // Every request beyond this point will not be for a resource like a script or image, // but it will be a request for an HTML or API // Directory required for the ACME (Automatic Certificate Management Environment) protocol used by Certbot to validate your domain ownership. app.use( '/.well-known/acme-challenge', express.static(path.join(__dirname, '../../../cert/.well-known/acme-challenge')), ); // This sets the 'browser-id' cookie on every request for an HTML file app.use(assignOrRenewBrowserID); // This sets the user 'preferences' cookie on every request for an HTML file app.use(setPrefsCookie); // This sets the user 'checkmates_beaten' cookie on every request for an HTML file app.use(setPracticeProgressCookie); // Provide a route // Root router app.use('/', rootRouter); // Contains every html page. // Account router app.post('/createaccount', createAccountLimiter, createNewMember); // "/createaccount" POST request app.get('/createaccount/username/:username', checkUsernameAvailable); app.get('/createaccount/email/:email', checkEmailValidity); // Member router app.delete('/member/:member/delete', removeAccount); app.post('/reset-password', handleResetPassword); // API -------------------------------------------------------------------- app.post('/auth', handleLogin); // Login fetch POST request app.post('/setlanguage', (req: Request, res: Response) => { // Language cookie setter POST request res.cookie('i18next', req.i18n.resolvedLanguage); res.send(''); // Doesn't work without this for some reason }); app.get('/api/contributors', (_req: Request, res: Response) => { const contributors = getContributors(); res.send(JSON.stringify(contributors)); }); // Endpoint called by the GitHub Actions deploy workflow before pm2 reload app.post('/api/prepare-restart', handlePrepareRestart); // Token Authenticator ------------------------------------------------------- /** * Sets the req.memberInfo properties if they have an authorization * header (contains access token) or refresh cookie (contains refresh token). * Don't send unauthorized people private stuff without the proper role. * * PLACE AS LOW AS YOU CAN, BUT ABOVE ALL ROUTES THAT NEED AUTHENTICATION!! * This requires database requests. */ app.use(verifyJWT); // ROUTES THAT NEED AUTHENTICATION ------------------------------------------------------ app.post('/api/get-access-token', accessTokenIssuer); app.post('/api/set-preferences', postPrefs); app.post('/api/update-checkmatelist', postCheckmateBeaten); // News routes app.get('/api/news/unread-count', getUnreadNewsCount); app.get('/api/news/unread-dates', getUnreadNewsDatesEndpoint); app.post('/api/news/mark-read', markNewsAsRead); // Editor saves routes app.get('/api/editor-saves', EditorSavesAPI.getSavedPositions); app.post('/api/editor-saves', editorSaveLimiter, EditorSavesAPI.savePosition); app.get('/api/editor-saves/:position_name', editorLoadLimiter, EditorSavesAPI.getPosition); app.delete('/api/editor-saves/:position_name', EditorSavesAPI.deletePosition); app.get('/logout', handleLogout); app.get('/command/:command', processCommand); // Member routes that do require authentication app.get('/member/:member/data', getMemberData); app.post('/member/:member/send-email', resendAccountVerificationLimiter, requestConfirmEmail); app.get('/verify/:member/:code', verifyAccount); // Leaderboard router app.get( '/leaderboard/top/:leaderboard_id/:start_rank/:n_players/:find_requester_rank', getLeaderboardData, ); app.post('/forgot-password', forgotPasswordLimiter, handleForgotPasswordRequest); // Last Resort 404 and Error Handler ---------------------------------------------------- // If we've reached this point, send our 404 page. app.all('*', send404); // Custom error handling. Comes after 404. app.use(errorHandler); } ================================================ FILE: src/server/middleware/rateLimit.ts ================================================ // src/server/middleware/rateLimit.ts import type { IncomingMessage } from 'node:http'; import type { Request, Response, NextFunction } from 'express'; import type { CustomWebSocket } from '../socket/socketUtility.js'; import jsutil from '../../shared/util/jsutil.js'; import { isIPBanned } from './banned.js'; import { getClientIP } from '../utility/IP.js'; import { logEvents, logEventsAndPrint } from './logEvents.js'; import 'dotenv/config'; // Imports all properties of process.env, if it exists /** * Whether the server is running in development mode. * It will be hosted on a different port for local host, * and a few other minor adjustments. */ const DEV_BUILD = process.env['NODE_ENV'] === 'development'; /** Whether we are currently rate limiting connections. * Only disable temporarily for development purposes. */ const ARE_RATE_LIMITING = !DEV_BUILD; // Set to false to temporarily get around it, during development. if (!DEV_BUILD && !ARE_RATE_LIMITING) { throw new Error('ARE_RATE_LIMITING must be true in production!!'); } // For rate limiting a client... /** The maximum number of requests/messages allowed per IP address, per minute. */ const maxRequestsPerMinute = process.env['NODE_ENV'] === 'development' ? 400 : 200; // Default: 400 / 200 const minuteInMillis = 60000; /** * Interval to clear out an agent's list of recent connection timestamps if they * are longer ago than {@link minuteInMillis} */ const rateToUpdateRecentConnections = 1000; // 1 Second /** * The object containing a combination of IP addresses and user agents for the key, * and for the value - an array of timestamps of their recent connections. * The key format will be `{ "192.538.1.1|User-Agent-String": [timestamp1, timestamp2, ...] }` */ const rateLimitHash: Record = {}; // For detecting if we're under a DDOS attack... /** Interval to check if we think we're experiencing a DDOS */ const requestWindowToToggleAttackModeMillis = 2000; /** * The number of requests we can receive in our {@link requestWindowToToggleAttackModeMillis} * before thinking there's a DDOS attack happening. */ const requestCapToToggleAttackMode = 200; /** * Whether we think we're currently experiencing a DDOS. * When true, in the future we can strictly limit what actions users can request/perform! * * Ideas: * 1. All htmls, or statically served file items, should only be served once per minute to each IP. * 2. Don't rate limit player's websocket messages who are currently in a game. * 3. Temporarily disallow account creation. */ let underAttackMode = false; /** * An ordered array of timestamps of recent connections, * up to {@link requestWindowToToggleAttackModeMillis} ago. * The length of this is how many total requests we have * received during the past {@link requestWindowToToggleAttackModeMillis}. */ const recentRequests: number[] = []; // List of times of recent connections /** * Generates a key for rate limiting based on the client's IP address and user agent. * @param IP - The IP address of the request or websocket connection. * @param userAgent - The user agent string from the request headers. * @returns The combined key in the format "IP|User-Agent" or null if IP cannot be determined */ function getIpBrowserAgentKey(IP: string, userAgent: string): string { // Construct the key combining IP and user agent return `${IP}|${userAgent}`; } /** * Middleware that counts this IP address's recent connections, * and rejects this request if they've sent too many. * @param req - The request object * @param res - The response object * @param next - The function to call, when finished, to continue the middleware waterfall. */ function rateLimit(req: Request, res: Response, next: NextFunction): void { if (!ARE_RATE_LIMITING) return next(); // Not rate limiting countRecentRequests(); const clientIP = getClientIP(req); if (!clientIP) { logEvents( 'Unable to identify client IP address when rate limiting!', 'reqLogRateLimited.txt', ); res.status(400).json({ message: 'Unable to identify client IP address' }); return; } if (isIPBanned(clientIP)) { const logThis = `Banned IP ${clientIP} tried to connect! ${req.headers.origin} ${clientIP} ${req.method} ${req.url} ${req.headers['user-agent']}`; logEvents(logThis, 'bannedIPLog.txt'); res.status(403).json({ message: 'You are banned' }); return; } const userAgent = req.headers['user-agent']; if (!userAgent) { logEvents( `Unable to identify user agent for IP ${clientIP} when rate limiting!`, 'reqLogRateLimited.txt', ); res.status(400).json({ message: 'User agent is required' }); return; } const userKey = getIpBrowserAgentKey(clientIP, userAgent); // Add the current timestamp to their list of recent connection timestamps. incrementClientConnectionCount(userKey); const timestamps = rateLimitHash[userKey]; if (timestamps && timestamps.length > maxRequestsPerMinute) { // Rate limit them (too many requests sent) logEvents( `Agent ${userKey} has too many requests! Count: ${timestamps.length}`, 'reqLogRateLimited.txt', ); res.status(429).json({ message: 'Too Many Requests. Try again soon.' }); return; } next(); // Continue the middleware waterfall } /** * Counts this IP address's recent connections, * and returns false if they've sent too many requests/messages. * @param req - The request object * @param ws - The websocket object * @returns false if they've sent too many requests/messages. THEY WILL HAVE ALREADY BEEN CLOSED */ function rateLimitWebSocket(req: IncomingMessage, ws: CustomWebSocket): boolean { countRecentRequests(); const userAgent = req.headers['user-agent']; if (!userAgent) { logEvents( `Unable to identify user agent for websocket connection when rate limiting!`, 'reqLogRateLimited.txt', ); ws.close(1008, 'User agent is required'); return false; } const userKey = getIpBrowserAgentKey(ws.metadata.IP, userAgent); // Add the current timestamp to their list of recent connection timestamps. incrementClientConnectionCount(userKey); if (rateLimitHash[userKey]!.length > maxRequestsPerMinute) { logEvents( `Agent ${userKey} has too many requests after! Count: ${rateLimitHash[userKey]!.length}`, 'reqLogRateLimited.txt', ); ws.close(1009, 'Too Many Requests. Try again soon.'); return false; } return true; // Connection allowed! } /** * Increment the provided user key's recent connection count by adding the current timestamp * to their list of recent connection timestamps. * Only call if we haven't already rejected them for too many requests. * @param userKey - The unique key combining IP address and user agent. */ function incrementClientConnectionCount(userKey: string): void { // Initialize the array if it doesn't exist if (!rateLimitHash[userKey]) rateLimitHash[userKey] = []; // Add the current timestamp to the user's recent connection timestamp list rateLimitHash[userKey]!.push(Date.now()); } /** * Set an interval to periodically clear {@link rateLimitHash} * of IP addresses with no recent connections or outdated timestamps. */ setInterval(() => { const currentTimeMillis = Date.now(); for (const [key, timestamps] of Object.entries(rateLimitHash)) { const firstTimestamp = timestamps[0]; // Check if there are no timestamps if (firstTimestamp === undefined) { const logMessage = 'Agent recent connection timestamp list was empty. This should never happen! It should have been deleted.'; logEventsAndPrint(logMessage, 'errLog.txt'); delete rateLimitHash[key]; continue; } // Check the first timestamp. If the first timestamp is within the valid window, skip processing if (currentTimeMillis - firstTimestamp <= minuteInMillis) continue; // If all timestamps are older, delete the key const mostRecentTimestamp = timestamps.at(-1)!; if (currentTimeMillis - mostRecentTimestamp >= minuteInMillis) { delete rateLimitHash[key]; continue; } // Use binary search to find the index to split at const indexToSplitAt = jsutil.findIndexOfPointInOrganizedArray( timestamps, currentTimeMillis - minuteInMillis, ); // Remove all timestamps to the left of the found index timestamps.splice(0, indexToSplitAt); if (timestamps.length === 0) delete rateLimitHash[key]; } }, rateToUpdateRecentConnections); /** * Adds the current timestamp to {@link recentRequests}. * This should always be called with any request/message, * EVEN if they are rate limited. */ function countRecentRequests(): void { const currentTimeMillis = Date.now(); recentRequests.push(currentTimeMillis); } /** * Set an interval to repeatedly strip {@link recentRequests} * of timestamps that are longer than {@link requestWindowToToggleAttackModeMillis} ago. * This uses binary search to quickly find the splice point, so that * we don't potentially have to check hundreds of timestamps. * * This also activates {@link underAttackMode} if it thinks we have had SO * many recent connections that it must be a DDOS attack. */ setInterval(() => { // Delete recent requests longer than 2 seconds ago const twoSecondsAgo = Date.now() - requestWindowToToggleAttackModeMillis; const indexToSplitAt = jsutil.findIndexOfPointInOrganizedArray(recentRequests, twoSecondsAgo); recentRequests.splice(0, indexToSplitAt + 1); if (recentRequests.length > requestCapToToggleAttackMode) { if (!underAttackMode) { // Toggle on underAttackMode = true; logAttackBegin(); } } else if (underAttackMode) { underAttackMode = false; logAttackEnd(); } }, requestWindowToToggleAttackModeMillis); function logAttackBegin(): void { const logText = `Probable DDOS attack happening now. Initial recent request count: ${recentRequests.length}`; logEventsAndPrint(logText, 'reqLogRateLimited.txt'); logEvents(logText, 'hackLog.txt'); } function logAttackEnd(): void { const logText = `DDOS attack has ended.`; logEventsAndPrint(logText, 'reqLogRateLimited.txt'); logEvents(logText, 'hackLog.txt'); } export { rateLimit, rateLimitWebSocket }; ================================================ FILE: src/server/middleware/rateLimiters.ts ================================================ // src/server/middleware/rateLimiters.ts /** * Stores rate limiting rules for various endpoints. */ import type { Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { getTranslationForReq } from '../utility/translate.js'; // Options ------------------------------------------------------------- /** A handler that returns a generic rate-limiting message. */ function generic_handler(req: Request, res: Response): Response { return res.status(429).json({ message: getTranslationForReq('rate-limiting.generic', req), // More detailed human readable error: getTranslationForReq('rate-limiting.error', req), // Shorter concise message }); } /** Default options for all rate limiters. */ const default_options = { standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the outdated `X-RateLimit-*` headers handler: generic_handler, }; // Limiters ------------------------------------------------------------- /** * Account Creation Limiter * Rule: Max 6 account creations per day per IP */ export const createAccountLimiter = rateLimit({ windowMs: 1000 * 60 * 60 * 24, max: 6, ...default_options, }); /** * Resend Account Verification Email Limiter * Rule: Max 4 verification email resends per hour per IP */ export const resendAccountVerificationLimiter = rateLimit({ windowMs: 1000 * 60 * 60, max: 4, ...default_options, }); /** * Forgot Password Email Limiter * Rule: Max 8 password reset requests per 20 minutes per IP */ export const forgotPasswordLimiter = rateLimit({ windowMs: 1000 * 60 * 20, max: 8, ...default_options, }); /** * Editor Save Limiter * Rule: Max 10 position saves per 1 minute per IP */ export const editorSaveLimiter = rateLimit({ windowMs: 1000 * 60, max: 10, skip: () => process.env['NODE_ENV'] === 'test', ...default_options, }); /** * Editor Load Limiter * Rule: Max 20 position loads per 1 minute per IP */ export const editorLoadLimiter = rateLimit({ windowMs: 1000 * 60, max: 20, skip: () => process.env['NODE_ENV'] === 'test', ...default_options, }); ================================================ FILE: src/server/middleware/secureRedirect.ts ================================================ // src/server/middleware/secureRedirect.ts import type { Request, Response, NextFunction } from 'express'; import 'dotenv/config'; // Imports all properties of process.env, if it exists /** * Middleware that redirects all http requests to https * @param req - The request object * @param res - The response object * @param next - The function to call, when finished, to continue the middleware waterfall. */ const secureRedirect = (req: Request, res: Response, next: NextFunction): void => { // 1-year is minimum remember time with preload parameter. Preload means google will always pre-tell clickers-of-your-site to connect via https. res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); if (req.secure) return next(); // Force redirect to https... const httpsPort = process.env['NODE_ENV'] !== 'production' ? ':' + (process.env['HTTPSPORT_LOCAL'] || '3443') : ''; res.redirect(`https://${req.hostname}${httpsPort}${req.url}`); }; export default secureRedirect; ================================================ FILE: src/server/middleware/send404.ts ================================================ // src/server/middleware/send404.ts import type { Request, Response } from 'express'; import path from 'path'; import { fileURLToPath } from 'node:url'; import { getLanguageToServe, getTranslationForReq } from '../utility/translate.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function send404(req: Request, res: Response): void { res.status(404); if (req.accepts('html')) { res.sendFile( path.join( __dirname, '../../../dist/client/views', getLanguageToServe(req), 'errors/404.html', ), ); } else if (req.accepts('json')) { res.json({ error: getTranslationForReq('server.javascript.ws-not_found', req) }); } else { res.type('txt').send(getTranslationForReq('server.javascript.ws-not_found', req)); } } export default send404; ================================================ FILE: src/server/middleware/verifyJWT.ts ================================================ // src/server/middleware/verifyJWT.ts /* * This module reads incoming requests, searching for a * valid authorization header, or a valid refresh token cookie, * to verify their identity, and sets the `user` and `role` * properties of the request (or of the websocket metadata) * if they are logged in. */ import type { Request, Response, NextFunction } from 'express'; import { getClientIP } from '../utility/IP.js'; import { CustomWebSocket } from '../socket/socketUtility.js'; import { logEventsAndPrint } from './logEvents.js'; import { IdentifiedRequest, isRequestIdentified, ParsedCookies } from '../types.js'; import { freshenSession, revokeSession, } from '../controllers/authenticationTokens/sessionManager.js'; import { isAccessTokenValid, isRefreshTokenValid, } from '../controllers/authenticationTokens/tokenValidator.js'; /** * [HTTP] Reads the request's bearer token (from the authorization header) * OR the refresh cookie (contains refresh token), * sets req.memberInfo properties if it is valid (are signed in). * Further middleware can read these properties to not send * private information to unauthorized users.\ */ function verifyJWT(req: Request, res: Response, next: NextFunction): void { const cookies: ParsedCookies = req.cookies; req.memberInfo = { signedIn: false, browser_id: cookies['browser-id'] }; // After this line, typescript then thinks the req is of the IdentifiedRequest type. if (!isRequestIdentified(req)) throw Error('Not all required IdentifiedRequest properties were set!'); const hasAccessToken = verifyAccessToken(req, res); if (!hasAccessToken) verifyRefreshToken(req, res); next(); // Continue down the middleware waterfall } /** * [HTTP] Reads the request's bearer token (from the authorization header), * sets the connections `memberInfo` property if it is valid (are signed in). * * Returns whether they have a valid access token or not. */ function verifyAccessToken(req: IdentifiedRequest, res: Response): boolean { const authHeader = req.headers.authorization; if (!authHeader) return false; // No authentication header included if (!authHeader.startsWith('Bearer ')) return false; // Authentication header doesn't look correct const accessToken = authHeader.split(' ')[1]; if (!accessToken) return false; // Authentication header doesn't contain a token const result = isAccessTokenValid(accessToken); if (!result.isValid) { logEventsAndPrint( `Invalid access token, expired or tampered! "${accessToken}"`, 'errLog.txt', ); // Revoke their session now, in case they were manually logged out, and their client didn't know that. // The client should never use an expired token unless it's a bug. revokeSession(res); return false; } // Token is valid and hasn't hit the 15m expiry // console.log('A valid access token was used! :D :D'); req.memberInfo = { ...req.memberInfo, signedIn: true, ...result.payload }; // Username was our payload when we generated the access token return true; } /** * [HTTP] Reads the request's refresh token cookie, * updates the connections `memberInfo` property if it is valid (are signed in). * Only call if they did not have a valid access token, as this performs database queries! */ function verifyRefreshToken(req: IdentifiedRequest, res: Response): void { const cookies: ParsedCookies = req.cookies; const refreshToken = cookies.jwt; if (!refreshToken) return; // No refresh token present const result = isRefreshTokenValid(refreshToken, getClientIP(req)); if (!result.isValid) { // Token was expired or tampered, or manually invalidated. console.log( `Invalid refresh token: Expired, tampered, or account deleted! Reason: "${result.reason}"`, ); // Revoke their session now, in case they were manually logged out, and their client didn't know that. revokeSession(res); return; } const payload = result.payload; try { // Renew the session if it was issued more than a day ago. freshenSession( req, res, payload.user_id, payload.username, payload.roles, result.tokenRecord, ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); logEventsAndPrint(`Error freshening session: ${errMsg}`, 'errLog.txt'); } // Valid! Set their req.memberInfo property! req.memberInfo = { ...req.memberInfo, signedIn: true, ...result.payload }; // Username was our payload when we generated the access token } /** * [WebSocket] Reads the refresh cookie token, * Modifies ws.metadata.memberInfo if they are signed in * to add the user_id, username, and roles properties. * @param req * @param ws - The websocket object */ function verifyJWTWebSocket(ws: CustomWebSocket): void { verifyRefreshToken_WebSocket(ws); } /** * [WebSocket] If they have a valid refresh token cookie (http-only), set's * the socket metadata's `user` property, ands returns true. * @param ws - The websocket object * @returns true if a valid token was found. */ function verifyRefreshToken_WebSocket(ws: CustomWebSocket): void { const cookies = ws.metadata.cookies; const refreshToken = cookies.jwt; if (!refreshToken) return; // Not logged in, don't set their user property // { isValid (boolean), user_id, username, reason (string, if not valid) } const ip = ws.metadata.IP; const result = isRefreshTokenValid(refreshToken, ip); // True for refresh token if (!result.isValid) { console.log( `Invalid refresh token (websocket): Expired, tampered, or account deleted! Reason: "${result.reason}". Token: "${refreshToken}"`, ); return; // Token was expired or tampered } ws.metadata.memberInfo = { ...ws.metadata.memberInfo, signedIn: true, ...result.payload }; } export { verifyJWT, verifyJWTWebSocket }; ================================================ FILE: src/server/routes/root.ts ================================================ // src/server/routes/root.ts import path from 'path'; import { fileURLToPath } from 'node:url'; import express, { Request, Response } from 'express'; import { getLanguageToServe } from '../utility/translate.js'; const router = express.Router(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const htmlDirectory = path.join(__dirname, '../../../dist/client/views'); /** * Serves an HTML file based on the requested path and language. * @param filePath - The relative file path to serve. * @param localized - If the file is not localized to other languages. * @returns Express middleware handler. */ const serveFile = (filePath: string, localized: boolean = true) => (req: Request, res: Response) => { const language: string = localized ? getLanguageToServe(req) : ''; const file: string = path.join(htmlDirectory, language, filePath); /** * sendFile() will AUTOMATICALLY check if the file's Last-Modified * value is after the request's 'If-Modified-Since' header... * * If so, it will send 200 OK with the updated file content! * * Otherwise, it sends 304 Not Modified, signaling the client * to use their cached version for another duration of the * max-age property of the Cache-Control header we send! */ res.sendFile(file); }; // Regular pages router.get('^/$|/index(.html)?', serveFile('index.html')); router.get('/credits(.html)?', serveFile('credits.html')); router.get('/play(.html)?', serveFile('play.html')); router.get('/guide(.html)?', serveFile('guide.html')); router.get('/news(.html)?', serveFile('news.html')); router.get('/leaderboard(.html)?', serveFile('leaderboard.html')); router.get('/login(.html)?', serveFile('login.html')); router.get('/createaccount(.html)?', serveFile('createaccount.html')); router.get('/reset-password/:token', serveFile('resetpassword.html')); router.get('/termsofservice(.html)?', serveFile('termsofservice.html')); router.get('/member(.html)?/:member', serveFile('member.html')); router.get('/admin(.html)?', serveFile('admin.html')); router.get('/icnvalidator(.html)?', serveFile('icnvalidator.html', false)); // Error pages router.get('/400(.html)?', serveFile('errors/400.html', true)); router.get('/401(.html)?', serveFile('errors/401.html', true)); router.get('/404(.html)?', serveFile('errors/404.html', true)); router.get('/409(.html)?', serveFile('errors/409.html', true)); router.get('/500(.html)?', serveFile('errors/500.html', true)); export { router as rootRouter }; ================================================ FILE: src/server/server.ts ================================================ // src/server/server.ts import { initDatabase } from './database/databaseTables.js'; import { initDevEnvironment } from './config/setupDev.js'; import 'dotenv/config'; // Imports all properties of process.env, if it exists initDatabase(); // Ensure our workspace is ready for the dev environment initDevEnvironment(); // Dependancy/built-in imports import https from 'https'; // Other imports import app from './app.js'; import db from './database/database.js'; import socketServer from './socket/socketServer.js'; import { prepGamesForShutdown, restoreLiveGames } from './game/gamemanager/gamemanager.js'; import { getCertOptions } from './config/certOptions.js'; import { logServerStarted, logServerStopped } from './utility/startupLogger.js'; const httpsServer = https.createServer(getCertOptions(), app); // Restore live games from the database into memory before accepting new connections. restoreLiveGames(); // Start the server const DEV_BUILD = process.env['NODE_ENV'] === 'development'; const HTTPPORT = DEV_BUILD ? process.env['HTTPPORT_LOCAL'] : process.env['HTTPPORT']; const HTTPSPORT = DEV_BUILD ? process.env['HTTPSPORT_LOCAL'] : process.env['HTTPSPORT']; app.listen(HTTPPORT, () => console.log(`HTTP listening on port ${HTTPPORT}`)); httpsServer.listen(HTTPSPORT, () => { console.log(`HTTPS listening on port ${HTTPSPORT}`); logServerStarted(); }); // WebSocket server socketServer.start(httpsServer); // On closing... let cleanupDone = false; process.on('SIGUSR2', () => handleCleanup('SIGUSR2')); // A file was saved (nodemon auto restarts) process.on('SIGINT', () => handleCleanup('SIGINT')); // Ctrl>C was pressed (force terminates nodemon) process.on('SIGTERM', () => handleCleanup('SIGTERM')); // PM2 graceful shutdown function handleCleanup(signal: string): void { if (cleanupDone) return; // Sometimes this is called twice cleanupDone = true; // console.log(`\nReceived ${signal}. Cleaning up...`); console.log('Closing...'); logServerStopped(signal); prepGamesForShutdown(); db.close(); // Close the database when the server is shutting down. process.exit(0); } ================================================ FILE: src/server/socket/closeSocket.ts ================================================ // src/server/socket/closeSocket.ts /** * This script terminates websockets. */ import type { CustomWebSocket } from './socketUtility.js'; import wsutil from '../../shared/util/wsutil.js'; import { removeConnectionFromConnectionLists, unsubSocketFromAllSubs } from './socketManager.js'; // Functions --------------------------------------------------------------------------- function onclose(ws: CustomWebSocket, code: number, reason: Buffer): void { const reasonString = reason.toString(); // Delete connection from object. removeConnectionFromConnectionLists(ws, code, reasonString); // What if the code is 1000, and reason is "Connection closed by client"? // I then immediately want to delete their invite. // But what other reasons could it close... ? // Code 1006, Message "" is just a network failure. // True if client had no power over the closure, // DON'T COUNT this as a disconnection! // They would want to keep their invite, AND remain in their game! const closureNotByChoice = wsutil.wasSocketClosureNotByTheirChoice(code, reasonString); // Unsubscribe them from all. NO LIST. It doesn't matter if they want to keep their invite or remain // connected to their game, without a websocket to send updates to, there's no point in any SUBSCRIPTION service! // Unsubbing them from their game will start their auto-resignation timer. unsubSocketFromAllSubs(ws, closureNotByChoice); cancelRenewConnectionTimer(ws); } function cancelRenewConnectionTimer(ws: CustomWebSocket): void { clearTimeout(ws.metadata.renewConnectionTimeoutID); ws.metadata.renewConnectionTimeoutID = undefined; } export { onclose }; ================================================ FILE: src/server/socket/echoTracker.ts ================================================ // src/server/socket/echoTracker.ts /** * This script keeps track of the echos we are expecting from recent websocket-out messages. * * Typically, if we don't receive an echo within five seconds, * we think the connection was lost, so we terminate the websocket. */ // Variables --------------------------------------------------------------------------- /** * * An object containing the timeout ID's for the timers that auto terminate * websockets if we never hear an echo back: `{ messageID: timeoutID }` */ const echoTimers: { [messageID: number]: NodeJS.Timeout | number } = {}; /** * The time, after which we don't hear an expected echo from a websocket, * in which it be assumed disconnected, and auto terminated, in milliseconds. */ const timeToWaitForEchoMillis: number = 5000; // 5 seconds until we assume we've disconnected! // Functions --------------------------------------------------------------------------- function addTimeoutToEchoTimers(messageID: number, timeout: NodeJS.Timeout | number): void { echoTimers[messageID] = timeout; } /** * Cancel the timer that will close the socket when we don't hear an expected echo from a sent socket message. * If there was no timer, this will return false, meaning it was an invalid echo. */ function deleteEchoTimerForMessageID(messageIDEchoIsFor: number): boolean { const timeout: NodeJS.Timeout | number | undefined = echoTimers[messageIDEchoIsFor]; if (timeout === undefined) return false; // Invalid echo (message ID wasn't from any recently sent socket message) clearTimeout(timeout); delete echoTimers[messageIDEchoIsFor]; return true; // Valid echo } export { addTimeoutToEchoTimers, deleteEchoTimerForMessageID, timeToWaitForEchoMillis }; ================================================ FILE: src/server/socket/generalrouter.ts ================================================ // src/server/socket/generalrouter.ts /** * This script handles the incoming general websocket message route. */ import type { CustomWebSocket } from './socketUtility.js'; import * as z from 'zod'; import { unsubClientFromGameBySocket } from '../game/gamemanager/gamemanager.js'; import { subToInvitesList, unsubFromInvitesList } from '../game/invitesmanager/invitesmanager.js'; const validUnsubs = ['invites', 'game'] as const; type ValidUnsub = (typeof validUnsubs)[number]; const GeneralSchema = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('sub'), value: z.literal(['invites']) }), z.strictObject({ action: z.literal('unsub'), value: z.literal(validUnsubs) }), ]); type GeneralMessage = z.infer; // Functions ------------------------------------------------------------------- // Route for this incoming message is "general". What is their action? function routeGeneralMessage(ws: CustomWebSocket, message: GeneralMessage): void { // data: { route, action, value, id } // Route them according to their action switch (message.action) { case 'sub': handleSubbing(ws, message.value); break; case 'unsub': handleUnsubbing(ws, message.value); break; default: console.error( // @ts-ignore `UNKNOWN web socket action received in general route! "${message.action}"`, ); } } // Actions ------------------------------------------------------------------- function handleSubbing(ws: CustomWebSocket, value: 'invites'): void { // What are they wanting to subscribe to for updates? switch (value) { case 'invites': // Subscribe them to the invites list subToInvitesList(ws); break; default: console.error(`UNKNOWN subscription list to subscribe client to! "${value}"`); } } // Set closureNotByChoice to true if you don't immediately want to disconnect them, but say after 5 seconds function handleUnsubbing(ws: CustomWebSocket, key: ValidUnsub, closureNotByChoice?: boolean): void { // What are they wanting to unsubscribe from updates from? switch (key) { case 'invites': // Unsubscribe them from the invites list unsubFromInvitesList(ws, closureNotByChoice); break; case 'game': // If the unsub is not by choice (network interruption instead of closing tab), then we give them // a 5 second cushion before starting an auto-resignation timer unsubClientFromGameBySocket(ws, { unsubNotByChoice: closureNotByChoice }); break; default: console.error(`UNKNOWN subscription list to unsubscribe client from! "${key}"`); } } // Exports ------------------------------------------------------------ export { routeGeneralMessage, handleUnsubbing, GeneralSchema }; ================================================ FILE: src/server/socket/openSocket.ts ================================================ // src/server/socket/openSocket.ts /** * This script handles socket upgrade connection requests, and creating new sockets. */ import type WebSocket from 'ws'; import type { IncomingMessage } from 'http'; import type { CustomWebSocket } from './socketUtility.js'; import { GAME_VERSION } from '../../shared/game_version.js'; import { onclose } from './closeSocket.js'; import socketUtility from './socketUtility.js'; import { onmessage } from './receiveSocketMessage.js'; import { executeSafely } from '../utility/errorGuard.js'; import { sendSocketMessage } from './sendSocketMessage.js'; import { verifyJWTWebSocket } from '../middleware/verifyJWT.js'; import { rateLimitWebSocket } from '../middleware/rateLimit.js'; import { getMemberDataByCriteria } from '../database/memberManager.js'; import { logEvents, logEventsAndPrint, logWebsocketStart } from '../middleware/logEvents.js'; import { addConnectionToConnectionLists, doesClientHaveMaxSocketCount, doesSessionHaveMaxSocketCount, generateUniqueIDForSocket, terminateAllIPSockets, } from './socketManager.js'; // Variables --------------------------------------------------------------------------- // Functions --------------------------------------------------------------------------- function onConnectionRequest(socket: WebSocket, req: IncomingMessage): void { const ws = closeIfInvalidAndAddMetadata(socket, req); if (ws === undefined) return; // We will have already closed the socket // Rate Limit Here // A false could either mean: // 1. Too many requests // 2. Message too big // In ALL these cases, we are terminating all the IPs sockets for now! if (!rateLimitWebSocket(req, ws)) { // Connection not allowed return terminateAllIPSockets(ws.metadata.IP); } // Check if ip has too many connections if (doesClientHaveMaxSocketCount(ws.metadata.IP)) { console.log(`Client IP ${ws.metadata.IP} has too many sockets! Not connecting this one.`); return ws.close(1009, 'Too Many Sockets'); } // Initialize who they are. Member? Browser ID?... verifyJWTWebSocket(ws); // Modifies ws.metadata.memberInfo if they are signed in to add the user_id, username, and roles properties. if ( ws.metadata.memberInfo.signedIn && doesSessionHaveMaxSocketCount(ws.metadata.cookies.jwt!) ) { console.log( `Member "${ws.metadata.memberInfo.username}" has too many sockets for this session! Not connecting this one.`, ); return ws.close(1009, 'Too Many Sockets'); } addConnectionToConnectionLists(ws); logWebsocketStart(req, ws); // Log the request addListenersToSocket(req, ws); // If user is signed in, use the database to correctly set the property ws.metadata.verified if (ws.metadata.memberInfo.signedIn) { const record = getMemberDataByCriteria( ['is_verified'], 'user_id', ws.metadata.memberInfo.user_id, ); // Set the verified status. 1 means true. if (record?.is_verified === 1) ws.metadata.verified = true; } // Send the current game vesion, so they will know whether to refresh. sendSocketMessage(ws, 'general', 'gameversion', GAME_VERSION); } function closeIfInvalidAndAddMetadata( socket: WebSocket, req: IncomingMessage, ): CustomWebSocket | undefined { // Make sure the connection is secure https const origin = req.headers.origin; if (origin === undefined || !origin.startsWith('https')) { console.error( `WebSocket connection request rejected. Reason: Not Secure. Origin: "${origin}"`, ); socket.close(1009, 'Not Secure'); return; } // Make sure the origin is our website // In DEV_BUILD, allow all origins. if (process.env['NODE_ENV'] !== 'development' && origin !== process.env['APP_BASE_URL']) { logEvents( `WebSocket connection request rejected. Reason: Origin Error. "Origin: ${origin}" Should be: "${process.env['APP_BASE_URL']}"`, 'hackLog.txt', ); socket.close(1009, 'Origin Error'); return; } const IP = socketUtility.getIPFromWebsocketUpgradeRequest(req); if (IP === undefined) { logEvents('Unable to identify IP address from websocket connection!', 'hackLog.txt'); socket.close(1008, 'Unable to identify client IP address'); // Code 1008 is Policy Violation return; } const cookies = socketUtility.getCookiesFromWebsocket(req); if (cookies['browser-id'] === undefined) { // console.log(`Authentication needed for WebSocket connection request!!`); socket.close(1008, 'Authentication needed'); // Code 1008 is Policy Violation return; } // Initialize the metadata and cast to a custom websocket object const ws = socket as CustomWebSocket; // Cast WebSocket to CustomWebSocket ws.metadata = { // Parse cookies from the Upgrade http headers cookies, subscriptions: {}, userAgent: req.headers['user-agent'], memberInfo: { signedIn: false, browser_id: cookies['browser-id'] }, verified: false, id: generateUniqueIDForSocket(), // Sets the ws.metadata.id property of the websocket IP, }; return ws; } /** * Adds the 'message', 'close', and 'error' event listeners to the socket */ function addListenersToSocket(req: IncomingMessage, ws: CustomWebSocket): void { ws.on('message', (message: Buffer) => { executeSafely( () => onmessage(req, ws, message), 'Error caught within websocket on-message event:', ); }); ws.on('close', (code, reason) => { executeSafely( () => onclose(ws, code, reason), 'Error caught within websocket on-close event:', ); }); ws.on('error', (error) => { executeSafely(() => onerror(ws, error), 'Error caught within websocket on-error event:'); }); } function onerror(ws: CustomWebSocket, error: Error): void { const errText = `An error occurred in a websocket. The socket: ${socketUtility.stringifySocketMetadata(ws)}\n${error.stack}`; logEventsAndPrint(errText, 'errLog.txt'); } export { onConnectionRequest }; ================================================ FILE: src/server/socket/receiveSocketMessage.ts ================================================ // src/server/socket/receiveSocketMessage.ts /** * This script receives incoming socket messages, rate limits them, logs them, * cancels their echo timer, sends an echo, then sends the message to our router. */ import type { IncomingMessage } from 'http'; import type { CustomWebSocket } from './socketUtility.js'; import * as z from 'zod'; import socketUtility from './socketUtility.js'; import { GameSchema } from '../game/gamemanager/gamerouter.js'; import { logZodError } from '../utility/zodlogger.js'; import { InvitesSchema } from '../game/invitesmanager/invitesrouter.js'; import { GeneralSchema } from './generalrouter.js'; import { rateLimitWebSocket } from '../middleware/rateLimit.js'; import { routeIncomingSocketMessage } from './socketRouter.js'; import { deleteEchoTimerForMessageID } from './echoTracker.js'; import { logEvents, logReqWebsocketIn } from '../middleware/logEvents.js'; import { rescheduleRenewConnection, sendSocketMessage } from './sendSocketMessage.js'; // Types -------------------------------------------------------------------------------------- /** The schema for validating all non-echo incoming websocket messages. */ const MasterSchema = z.discriminatedUnion('route', [ z.strictObject({ id: z.int(), route: z.literal('general'), contents: GeneralSchema }), z.strictObject({ id: z.int(), route: z.literal('invites'), contents: InvitesSchema }), z.strictObject({ id: z.int(), route: z.literal('game'), contents: GameSchema }), ]); /** Represents all possible types a non-echo incoming websocket message could be! */ export type WebsocketInMessage = z.infer; /** This is the id of the message being replied to. */ const EchoSchema = z.strictObject({ /** The route to forward the message to (e.g., "general", "invites", "game"). */ route: z.literal('echo'), /** The contents of the message, for the router to read. */ contents: z.int(), }); /** The schema for validating all incoming websocket messages, including echos. */ const MasterSchemaWithEchos = z.discriminatedUnion('route', [MasterSchema, EchoSchema]); // Constants --------------------------------------------------------------------------- /** * The maximum size of an incoming websocket message, in bytes. * Above this will be rejected, and an error sent to the client. * * DIRECTLY CONTROLS THE maximum distance players can move in online games! * 500 KB allows moves up to 1e100000 squares away, with some padding. * On mobile it would take 6 hours of zooming out at * MAXIMUM speed to reach that distance, without rest. * It would take WAYYYY longer on desktop! */ const maxWebsocketMessageSizeBytes = 500_000; // 500 KB // Functions --------------------------------------------------------------------------- /** * Callback function that is executed whenever we receive an incoming websocket message. * Sends an echo (unless this message itself **is** an echo), rate limits, * logs the message, then routes the message where it needs to go. */ function onmessage(req: IncomingMessage, ws: CustomWebSocket, rawMessage: Buffer): void { // Test if the message is too big. People could DDOS this way // THIS MAY NOT WORK if the bytes get read before we reach this part of the code, it could still DDOS us before we reject them. if (Buffer.byteLength(rawMessage) > maxWebsocketMessageSizeBytes) { logEvents(`Client sent too big a websocket message.`, 'reqLogRateLimited.txt'); ws.close(1009, 'Message Too Big'); return; } const messageStr = rawMessage.toString('utf8'); let parsedUnvalidatedMessage: any; try { // Parse the stringified JSON message. // Incoming message is in binary data, which can also be parsed into JSON parsedUnvalidatedMessage = JSON.parse(messageStr); } catch (error: unknown) { if (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed. const errText = `'Error parsing incoming message as JSON: ${JSON.stringify(error)}. Socket: ${socketUtility.stringifySocketMetadata(ws)}`; logEvents(errText, 'hackLog.txt'); sendSocketMessage(ws, 'general', 'printerror', `Invalid JSON format!`); return; } const zod_result = MasterSchemaWithEchos.safeParse(parsedUnvalidatedMessage); if (!zod_result.success) { sendSocketMessage( ws, 'general', 'notify', 'Your browser is running outdated code, please hard refresh the page!', ); logZodError( parsedUnvalidatedMessage, zod_result.error, 'Received malformed websocket in-message.', ); return; } // Validation was a success! Message contains valid parameters. const message = zod_result.data; if (message.route === 'echo') { const incomingEcho: number = message.contents; const validEcho = deleteEchoTimerForMessageID(incomingEcho); // Cancel timer to assume they've disconnected if (!validEcho) { if (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed. // This occasionally happens when the echo arrives after timeToWaitForEchoMillis has elapsed, // the timeout has already fired, the socket was already closed, and the echo timer was already deleted. } return; } // Not an echo... if (!rateLimitAndLogMessage(req, ws, messageStr)) return; // The socket will have already been closed. // Send our echo here! We always send an echo to every message except echos themselves. sendSocketMessage(ws, 'general', 'echo', message.id); // console.log('Received message: ' + rawMessage); rescheduleRenewConnection(ws); // We know they are connected, so reset this routeIncomingSocketMessage(ws, message); } /** * Logs and rate limits on incoming socket message. * Returns true if the message is allowed, or false if the message * is being rate limited and the socket has already been closed. */ function rateLimitAndLogMessage( req: IncomingMessage, ws: CustomWebSocket, rawMessage: string, ): boolean { if (!rateLimitWebSocket(req, ws)) return false; // They are being rate limited, the socket will have already been closed. logReqWebsocketIn(ws, rawMessage); // Only logged the message if it wasn't rate limited. return true; } export { onmessage }; ================================================ FILE: src/server/socket/sendSocketMessage.ts ================================================ // src/server/socket/sendSocketMessage.ts /** * This script sends socket messages, * and regularly sends messages by itself to confirm the socket is still connected and responding (we will hear an echo). */ import type { TranslationKeys } from '../../types/translations.js'; import { WebSocket } from 'ws'; import uuid from '../../shared/util/uuid.js'; import jsutil from '../../shared/util/jsutil.js'; import wsutil from '../../shared/util/wsutil.js'; import socketUtility from './socketUtility.js'; import { getTranslation } from '../utility/translate.js'; import { logEventsAndPrint, logReqWebsocketOut } from '../middleware/logEvents.js'; import { addTimeoutToEchoTimers, deleteEchoTimerForMessageID, timeToWaitForEchoMillis, } from './echoTracker.js'; // Types -------------------------------------------------------------------------------------- /** Represents an outgoing WebSocket server message. */ interface WebsocketOutMessage { /** The route to forward the message to (e.g., "general", "invites", "game", "echo"). * Undefined if it's a reply-only message. */ route?: string; /** The message contents. For echo messages, this is the message ID being echoed. * For other messages, this is an object with action and value. * Absent for reply-only acknowledgement messages (route and action are both undefined). */ contents?: any; /** The ID of the message to echo, indicating the connection is still active. * Or undefined if this message itself is an echo. */ id?: number; /** Optionally, we can include the id of the incoming message that this outgoing message is the reply to. */ replyto?: number; } import type { CustomWebSocket } from './socketUtility.js'; // Variables --------------------------------------------------------------------------- /** * The amount of latency to add to websocket replies, in millis. ONLY USE IN DEV!! * I recommend 2 seconds of latency for testing slow networks. */ const simulatedWebsocketLatencyMillis = 0; // const simulatedWebsocketLatencyMillis = 1000; // 1 Second // const simulatedWebsocketLatencyMillis = 2000; // 2 Seconds if (process.env['NODE_ENV'] !== 'development' && simulatedWebsocketLatencyMillis !== 0) { throw new Error('simulatedWebsocketLatencyMillis must be 0 in production!!'); } // Sending Messages --------------------------------------------------------------------------- /** * Sends a message to this websocket's client. * @param ws - The websocket * @param route - What subscription/route this message should be forwarded to. * @param action - What type of action the client should take within the subscription route. * @param value - The contents of the message. * @param [replyto] If applicable, the id of the socket message this message is a reply to. * @param [options] - Additional options for sending the message. * @param [options.skipLatency=false] - If true, we send the message immediately, without waiting for simulated latency again. */ function sendSocketMessage( ws: CustomWebSocket, route: string | undefined, action: string | undefined, value?: any, replyto?: number, { skipLatency }: { skipLatency?: boolean } = {}, ): void { // socket, invites, createinvite, inviteinfo, messageIDReplyingTo // If we're applying simulated latency delay, set a timer to send this message. if (simulatedWebsocketLatencyMillis !== 0 && !skipLatency) { setTimeout(() => { sendSocketMessage(ws, route, action, value, replyto, { skipLatency: true }); }, simulatedWebsocketLatencyMillis); return; } if (ws.readyState === WebSocket.CLOSED) { const errText = `Websocket is in a CLOSED state, can't send message. Action: ${action}. Value: ${jsutil.ensureJSONString(value)}\nSocket: ${socketUtility.stringifySocketMetadata(ws)}`; logEventsAndPrint(errText, 'errLog.txt'); return; } const isEcho = action === 'echo'; // Reply-only messages should have no empty "contents" field const isReplyOnly = route === undefined; const payload: WebsocketOutMessage = isEcho ? { route: 'echo', contents: value, // For echo, value contains the message ID replyto, } : isReplyOnly ? { id: uuid.generateNumbID(10), replyto, } : { route, contents: { action, value, }, id: uuid.generateNumbID(10), // Only include an id (and accept an echo back) if this is NOT an echo itself! replyto, }; const stringifiedPayload = JSON.stringify(payload); // if (!isEcho) console.log(`Sending: ${stringifiedPayload}`); ws.send(stringifiedPayload); // Send the message if (!isEcho) { // Not an echo logReqWebsocketOut(ws, stringifiedPayload); // Log the sent message // Set a timer. At the end, if we have heard no echo, just assume they've disconnected, terminate the socket. const timeout = setTimeout(() => { ws.close(1014, 'No echo heard'); deleteEchoTimerForMessageID(payload.id!); }, timeToWaitForEchoMillis); // We pass in an arrow function so it doesn't lose scope of ws. //console.log(`Set timer of message id "${id}"`) addTimeoutToEchoTimers(payload.id!, timeout); rescheduleRenewConnection(ws); } } /** * Sends a notification message to the client through the WebSocket connection, to be displayed on-screen. * @param ws - The WebSocket connection object. * @param translationCode - The code corresponding to the message that needs to be retrieved for language-specific translation. For example, `"server.javascript.ws-already_in_game"`. * @param [options] - An object containing additional options. * @param [options.replyto] - The ID of the incoming WebSocket message to which this message is replying. */ function sendNotify( ws: CustomWebSocket, translationCode: TranslationKeys, { replyto }: { replyto?: number } = {}, ): void { const i18next = ws.metadata.cookies.i18next; const text = getTranslation(translationCode, i18next); sendSocketMessage(ws, 'general', 'notify', text, replyto); } /** * Sends a message to the client through the websocket, to be displayed on-screen as an ERROR. * @param ws - The socket * @param translationCode - The code of the message to retrieve the language-specific translation for. For example, `"server.javascript.ws-already_in_game"` */ function sendNotifyError(ws: CustomWebSocket, translationCode: TranslationKeys): void { sendSocketMessage( ws, 'general', 'notifyerror', getTranslation(translationCode, ws.metadata.cookies.i18next), ); } // Renewing Connection if we haven't sent a message in a while ---------------------------------------------------------- /** * Reschedule the timer to send an empty message to the client * to verify they are still connected and responding. */ function rescheduleRenewConnection(ws: CustomWebSocket): void { cancelRenewConnectionTimer(ws); // Only reset the timer if they have at least one subscription! if (Object.keys(ws.metadata.subscriptions).length === 0) return; // No subscriptions ws.metadata.renewConnectionTimeoutID = setTimeout( () => renewConnection(ws), wsutil.timeOfInactivityToRenewConnection, ); } function cancelRenewConnectionTimer(ws: CustomWebSocket): void { clearTimeout(ws.metadata.renewConnectionTimeoutID); ws.metadata.renewConnectionTimeoutID = undefined; } /** * Send an empty message to the client, expecting an echo * within five seconds to make sure they are still connected. */ function renewConnection(ws: CustomWebSocket): void { sendSocketMessage(ws, 'general', 'renewconnection'); } export { sendSocketMessage, sendNotify, sendNotifyError, rescheduleRenewConnection }; ================================================ FILE: src/server/socket/socketManager.ts ================================================ // src/server/socket/socketManager.ts /** * This script stores all open websockets organized by ID, IP, and session. * * This contains methods for terminating all websockets by given criteria, * Rate limiting the socket count per user, * And unsubbing a socket from subscriptions. */ import type { CustomWebSocket } from './socketUtility.js'; import uuid from '../../shared/util/uuid.js'; import { handleUnsubbing } from './generalrouter.js'; // Variables --------------------------------------------------------------------------- /** * An object containing all active websocket connections, with their ID's for the keys: `{ 21: websocket }` */ const websocketConnections: { [id: string]: CustomWebSocket } = {}; // Object containing all active web socket connections, with their ID's for the KEY /** * An object with IP addresses for the keys, and arrays of their * socket id's they have open for the value: `{ "83.28.68.253": ['fighe26'] }` */ const connectedIPs: { [IP: string]: string[] } = {}; // Keys are the IP. Values are array lists containing all connection IDs they have going. /** * An object with refresh tokens for the keys, and arrays of their * socket id's they have open for the value: `{ uHrU85835...: ['fighe26'] }` */ const connectedSessions: { [username: string]: string[] } = {}; /** * A mapping of user IDs to arrays of socket IDs representing their active WebSocket connections. */ const connectedMembers: { [user_id: string]: string[] } = {}; const maxSocketsAllowedPerIP = 10; const maxSocketsAllowedPerSession = 5; /** * The maximum age a websocket connection will live before auto terminating, in milliseconds. * Users have to provide authentication whenever they open a new socket. */ const maxWebSocketAgeMillis = 1000 * 60 * 15; // 15 minutes. // const maxWebSocketAgeMillis = 1000 * 10; // 10 seconds for dev testing // Adding / Removing from the lists --------------------------------------------------------------------------- function addConnectionToConnectionLists(ws: CustomWebSocket): void { websocketConnections[ws.metadata.id] = ws; addConnectionToList(connectedIPs, ws.metadata.IP, ws.metadata.id); // Add IP connection if (ws.metadata.cookies.jwt) addConnectionToList(connectedSessions, ws.metadata.cookies.jwt, ws.metadata.id); // Add session connection if (ws.metadata.memberInfo.signedIn) addConnectionToList(connectedMembers, ws.metadata.memberInfo.user_id, ws.metadata.id); // Add user connection startTimerToExpireSocket(ws); // console.log( // `New WebSocket connection established. Socket count: ${Object.keys(websocketConnections).length}. Metadata: ${socketUtility.stringifySocketMetadata(ws)}`, // ); } /** * Adds a socket ID to the specified collection under the provided key. * @param collection - The collection (e.g., connectedIPs, connectedSessions, etc.) * @param key - The key in the collection (e.g., IP, session ID, user ID) * @param id - The socket ID to add to the collection. */ function addConnectionToList( collection: { [key: string]: string[] }, key: number | string, id: string, ): void { if (!collection[key]) collection[key] = []; // Initialize the array if it doesn't exist collection[key].push(id); // Add the socket ID to the list } function startTimerToExpireSocket(ws: CustomWebSocket): void { ws.metadata.clearafter = setTimeout( () => ws.close(1000, 'Connection expired'), maxWebSocketAgeMillis, ); // We pass in an arrow function so it doesn't lose scope of ws. } /** * Removes the given WebSocket connection from all tracking lists. * @param ws - The WebSocket connection to remove. * @param _code - The WebSocket closure code. * @param _reason - The reason for the WebSocket closure. */ function removeConnectionFromConnectionLists( ws: CustomWebSocket, _code: number, _reason: string, ): void { delete websocketConnections[ws.metadata.id]; removeConnectionFromList(connectedIPs, ws.metadata.IP, ws.metadata.id); // Remove IP connection if (ws.metadata.cookies.jwt) removeConnectionFromList(connectedSessions, ws.metadata.cookies.jwt, ws.metadata.id); // Remove session connection if (ws.metadata.memberInfo.signedIn) removeConnectionFromList(connectedMembers, ws.metadata.memberInfo.user_id, ws.metadata.id); // Remove member connection clearTimeout(ws.metadata.clearafter); // Cancel the timer to auto delete it at the end of its life // console.log( // `WebSocket connection has been closed. Code: ${_code}. Reason: ${_reason}. Socket count: ${Object.keys(websocketConnections).length}`, // ); } /** * Removes a socket ID from the specified collection under the provided key. * @param collection - The collection (e.g., connectedIPs, connectedSessions, etc.) * @param key - The key in the collection (e.g., IP, session ID, user ID) * @param id - The socket ID to remove from the collection. */ function removeConnectionFromList( collection: { [key: string]: string[] }, key: string | number, id: string, ): void { if (key === undefined || !collection[key]) return; // No key or collection doesn't exist const index = collection[key].indexOf(id); if (index !== -1) { collection[key].splice(index, 1); // Remove the socket ID from the list // Clean up if no connections left if (collection[key].length === 0) delete collection[key]; } } // Terminating all sockets of criteria --------------------------------------------------------------------------- function terminateAllIPSockets(IP: string): void { const connectionList = connectedIPs[IP]; if (connectionList === undefined) return; // IP is defined, but they don't have any sockets to terminate! for (const id of connectionList) { //console.log(`Terminating 1.. id ${id}`) const ws = websocketConnections[id]; ws?.close(1009, 'Message Too Big'); } // console.log(`Terminated all of IP ${IP}`) // console.log(connectedIPs) // This will still be full because they aren't actually spliced out of their list until the close() is complete! } /** * Closes all sockets a given member has open. * @param jwt - The member's session/refresh token. * @param closureCode - The code of the socket closure, sent to the client. * @param closureReason - The closure reason, sent to the client. */ function closeAllSocketsOfSession(jwt: string, closureCode: number, closureReason: string): void { connectedSessions[jwt]?.slice().forEach((socketID) => { // slice() makes a copy of it const ws = websocketConnections[socketID]; if (!ws) return; ws.close(closureCode, closureReason); }); } /** * Closes all sockets associated with a given user ID. * @param user_id - The unique ID of the user. * @param closureCode - The code for closing the socket, sent to the client. * @param closureReason - The reason for closure, sent to the client. */ function closeAllSocketsOfMember( user_id: number, closureCode: number, closureReason: string, ): void { const socketIDs = connectedMembers[user_id]; if (!socketIDs) return; // This member doesn't have any connected sockets socketIDs.slice().forEach((socketID) => { // slice() makes a copy of it const ws = websocketConnections[socketID]; if (!ws) return; ws.close(closureCode, closureReason); }); } /** * Sets the metadata.verified entry of all sockets of a given user to true. * @param user_id - The unique ID of the user. */ function AddVerificationToAllSocketsOfMember(user_id: number): void { const socketIDs = connectedMembers[user_id]; if (!socketIDs) return; // This member doesn't have any connected sockets socketIDs.slice().forEach((socketID) => { // slice() makes a copy of it const ws = websocketConnections[socketID]; if (!ws) return; ws.metadata.verified = true; }); } // Limiting the socket count per user --------------------------------------------------------------------------- /** * Returns true if the given IP has the maximum number of websockets opened. * @param IP - The IP address * @returns *true* if they have too many sockets. */ function doesClientHaveMaxSocketCount(IP: string): boolean { if (connectedIPs[IP] === undefined) return false; return connectedIPs[IP].length >= maxSocketsAllowedPerIP; } /** * Returns true if the given member has the maximum number of websockets opened. * @param jwt - The member's session/refresh token, if they are signed in. * @returns *true* if they have too many sockets. */ function doesSessionHaveMaxSocketCount(jwt: string): boolean { if (connectedSessions[jwt] === undefined) return false; return connectedSessions[jwt].length >= maxSocketsAllowedPerSession; } // Unsubbing --------------------------------------------------------------------------- // Set closureNotByChoice to true if you don't immediately want to disconnect them, but say after 5 seconds function unsubSocketFromAllSubs(ws: CustomWebSocket, closureNotByChoice: boolean): void { if (!ws.metadata.subscriptions) return; // No subscriptions const subscriptions = ws.metadata.subscriptions; const subscriptionsKeys = Object.keys(subscriptions) as Array; for (const key of subscriptionsKeys) handleUnsubbing(ws, key, closureNotByChoice); } // Miscellaneous --------------------------------------------------------------------------- function generateUniqueIDForSocket(): string { return uuid.genUniqueID(4, websocketConnections); } export { addConnectionToConnectionLists, removeConnectionFromConnectionLists, terminateAllIPSockets, doesClientHaveMaxSocketCount, doesSessionHaveMaxSocketCount, generateUniqueIDForSocket, unsubSocketFromAllSubs, closeAllSocketsOfSession, closeAllSocketsOfMember, AddVerificationToAllSocketsOfMember, }; ================================================ FILE: src/server/socket/socketRouter.ts ================================================ // src/server/socket/socketRouter.ts /** * This script receives routes incoming socket messages them where they need to go. * * * It also handles subbing to subscription lists. */ import type { CustomWebSocket } from './socketUtility.js'; import type { WebsocketInMessage } from './receiveSocketMessage.js'; import { routeGameMessage } from '../game/gamemanager/gamerouter.js'; import { routeGeneralMessage } from './generalrouter.js'; import { routeInvitesMessage } from '../game/invitesmanager/invitesrouter.js'; // Functions --------------------------------------------------------------------------- function routeIncomingSocketMessage(ws: CustomWebSocket, message: WebsocketInMessage): void { // Route them to their specified location switch (message.route) { case 'general': routeGeneralMessage(ws, message.contents); break; case 'invites': routeInvitesMessage(ws, message.contents, message.id); break; case 'game': routeGameMessage(ws, message.contents, message.id); break; default: // @ts-ignore console.error(`UNKNOWN web socket route received! "${message.route}"`); } } export { routeIncomingSocketMessage }; ================================================ FILE: src/server/socket/socketServer.ts ================================================ // src/server/socket/socketServer.ts import type { Server as HttpsServer } from 'https'; import WebSocket from 'ws'; import { IncomingMessage } from 'http'; import { WebSocketServer as Server } from 'ws'; import { executeSafely } from '../utility/errorGuard.js'; import { onConnectionRequest } from './openSocket.js'; let WebSocketServer: Server; function start(httpsServer: HttpsServer): void { WebSocketServer = new Server({ server: httpsServer }); // Create a WebSocket server instance // WebSocketServer.on('connection', onConnectionRequest); // Event handler for new WebSocket connections WebSocketServer.on('connection', (socket: WebSocket, req: IncomingMessage) => { executeSafely( () => onConnectionRequest(socket, req), 'Error caught within websocket on-connection request:', ); }); // Event handler for new WebSocket connections } export default { start, }; ================================================ FILE: src/server/socket/socketUtility.ts ================================================ // src/server/socket/socketUtility.ts // This script contains generalized methods for working with websocket objects. import type WebSocket from 'ws'; import type { IncomingMessage } from 'http'; // Used for the socket upgrade http request TYPE import type { Player } from '../../shared/chess/util/typeutil.js'; import type { AuthMemberInfo, ParsedCookies } from '../types.js'; import jsutil from '../../shared/util/jsutil.js'; // Types -------------------------------------------------------------------------------------- /** The socket object that contains all properties a normal socket has, * plus an additional `metadata` property that we define ourselves. */ interface CustomWebSocket extends WebSocket { /** Our custom-entered information about this websocket. */ metadata: { /** What subscription lists they are subscribed to. Possible: "invites" / "game" */ subscriptions: { /** Whether they are subscribed to the invites list. */ invites?: boolean; /** Will be defined if they are subscribed to, or in, a game. */ game?: { /** The id of the game they're in. */ id: number; /** The color they are playing as. */ color: Player; }; }; /** The parsed cookie object */ cookies: ParsedCookies; /** The user-agent property of the original websocket upgrade's req.headers */ userAgent?: string; memberInfo: AuthMemberInfo; /** The account verification status of the user */ verified: boolean; /** The id of their websocket. */ id: string; /** The socket's IP address. */ IP: string; /** The timeout ID that can be used to cancel the timer that will * expire the socket connection. This is useful if it closes early. */ clearafter?: NodeJS.Timeout; /** The timeout ID to cancel the timer that will send an empty * message to this socket just to verify they are alive and thinking. */ renewConnectionTimeoutID?: NodeJS.Timeout; }; } // Functions --------------------------------------------------------------------------- /** * Prints the websocket to the console, temporarily removing self-referencing first. * @param ws - The websocket */ function printSocket(ws: CustomWebSocket): void { console.log(stringifySocketMetadata(ws)); } /** * Simplifies the websocket's metadata and stringifies it. * @param ws - The websocket object * @returns The stringified simplified websocket metadata. */ function stringifySocketMetadata(ws: CustomWebSocket): string { // Removes the recursion from the metadata, making it safe to stringify. const simplifiedMetadata = getSimplifiedMetadata(ws); return jsutil.ensureJSONString(simplifiedMetadata, 'Error while stringifying socket metadata:'); } /** * Creates a new object with simplified metadata information from the websocket, * and removes recursion. This can be safely be JSON.stringified() afterward. * Excludes the stuff like the sendmessage() function and clearafter timer. * * BE CAREFUL not to modify the return object, for it will modify the original socket! * @param ws - The websocket object * @returns A new object containing simplified metadata. */ function getSimplifiedMetadata(ws: CustomWebSocket): Partial { const metadata = ws.metadata; // Using Partial takes an existing type and makes all of its properties optional const metadataCopy: Partial = { memberInfo: jsutil.deepCopyObject(metadata.memberInfo), cookies: { 'browser-id': ws.metadata.cookies['browser-id'], i18next: ws.metadata.cookies['i18next'], }, // Only copy these 2 cookies, NOT their refresh token!!! verified: metadata.verified, id: metadata.id, IP: metadata.IP, subscriptions: jsutil.deepCopyObject(metadata.subscriptions), }; return metadataCopy; } /** * Parses cookies from the WebSocket upgrade request headers. * @param req - The WebSocket upgrade request object * @returns An object with cookie names as keys and their corresponding values */ function getCookiesFromWebsocket(req: IncomingMessage): { [cookieName: string]: string } { // req.cookies is only defined from our cookie parser for regular requests, // NOT for websocket upgrade requests! We have to parse them manually! const rawCookies = req.headers.cookie; const cookies: { [cookieName: string]: string } = {}; if (!rawCookies) return cookies; for (const cookie of rawCookies.split(';')) { const parts = cookie.split('='); if (parts.length < 2) continue; // Skip if no value part exists const name = parts[0]!.trim(); const value = parts[1]!.trim(); if (name && value) cookies[name] = value; } return cookies; } /** * Reads the IP address attached to the incoming websocket connection request, * and sets the websocket metadata's `IP` property to that value, then returns that IP. * @param req - The request object. * @param ws - The websocket object. * @returns The IP address of the websocket connection, or `undefined` if not present. */ function getIPFromWebsocketUpgradeRequest(req: IncomingMessage): string | undefined { // Check the headers for the forwarded IP (useful if behind a proxy like Cloudflare) const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; // ws._socket.remoteAddress // If we didn't get a string IP, return undefined if (typeof clientIP !== 'string') return undefined; return clientIP; } export default { printSocket, stringifySocketMetadata, getCookiesFromWebsocket, getIPFromWebsocketUpgradeRequest, }; export type { CustomWebSocket }; ================================================ FILE: src/server/types.ts ================================================ // src/server/types.ts import type { Role } from './controllers/roles'; import { Request } from 'express'; /** * A req object, but with their memberInfo defined. This may include * information about their signed-in status, or their browser-id cookie. * Point is we now have an identifier for them. */ interface IdentifiedRequest extends Request { memberInfo: MemberInfo; } /** * Single source of truth for determining whether a req object has been * given all properties required for the {@link IdentifiedRequest} type. */ function isRequestIdentified(req: Request): req is IdentifiedRequest { return !!req.memberInfo; } /** Information to identify a specific user, logged in or not. */ type MemberInfo = SignedInMemberInfo | SignedOutMemberInfo; type SignedInMemberInfo = { signedIn: true; user_id: number; username: string; roles: Role[] | null; browser_id?: string; }; type SignedOutMemberInfo = { signedIn: false; browser_id?: string; }; /** * @type {MemberInfo}, but the browser_id is guaranteed to be defined. * This means the user is fully authenticated, cause we only need one * identifier to identify them. */ type AuthMemberInfo = MemberInfo & { browser_id: string }; /** All possible cookies we set on the client. */ interface ParsedCookies { /** The unique id of the browser. Almost always defined, but may not be on first connection, or if client's cookies are disabled. */ 'browser-id'?: string; /** Their preferred language. For example, 'en-US'. This is determined by their `i18next` cookie. */ i18next?: string; /** Their refresh/session token, if they are signed in. Can be decoded to obtain their payload. */ jwt?: string; /** * Information about the session for the user to read. * The server must NOT trust this information as it can be tampered! */ memberInfo?: string; // Stringified: { user_id: number, username: string, issued: number, expires: number } } export { isRequestIdentified }; export type { IdentifiedRequest, MemberInfo, AuthMemberInfo, ParsedCookies }; ================================================ FILE: src/server/utility/IP.ts ================================================ // src/server/utility/IP.ts /** * This module reads the IP address attached to incoming * requests and websocket connection requests. */ import type { Request } from 'express'; /** * Reads the IP address attached to the incoming request. * It prioritizes the 'x-forwarded-for' header, which is commonly used by * reverse proxies and load balancers like Cloudflare to convey the original client IP. * * @param req - The Express request object. * @returns The IP address of the request as a string, or `undefined` if not present. */ export function getClientIP(req: Request): string | undefined { const forwardedFor = req.headers['x-forwarded-for']; if (typeof forwardedFor === 'string') { // The 'x-forwarded-for' header can be a comma-separated list of IPs. // The first one is the original client IP. return forwardedFor.split(',')[0]!.trim(); } // Fallback to req.ip, which is derived from req.socket.remoteAddress // (and should match the first entry in 'x-forwarded-for' if behind a proxy) return req.ip; } ================================================ FILE: src/server/utility/errorGuard.ts ================================================ // src/server/utility/errorGuard.ts /** * This module contains methods for safely executing functions, * catching any errors that may occur, logging them to the error log. */ import { logEventsAndPrint } from '../middleware/logEvents.js'; /** * Executes a callback function with provided arguments and catches any errors that occur. * @param callback - The function to execute safely. * @param errorMessage - A custom error message to log if an error occurs. * @param args - Arguments to pass to the callback function. * @returns true if the callback executed without error. */ function executeSafely(callback: () => void, errorMessage: string): boolean { try { callback(); } catch (e) { const stack = e instanceof Error ? e.stack : 'Exception is not of Error type!'; const errText = `${errorMessage}\n${stack}`; logEventsAndPrint(errText, 'errLog.txt'); return false; // Yes error } return true; // No error } /** * A variant of {@link executeSafely} that works with an async function. * * Executes a callback function with provided arguments and catches any errors that occur. * @param callback - The function to execute safely. * @param errorMessage - A custom error message to log if an error occurs. * @param args - Arguments to pass to the callback function. * @returns true if the callback executed without error. */ async function executeSafely_async( callback: () => Promise, errorMessage: string, ): Promise { try { await callback(); } catch (e) { const stack = e instanceof Error ? e.stack : 'Exception is not of Error type!'; const errText = `${errorMessage}\n${stack}`; await logEventsAndPrint(errText, 'errLog.txt'); return false; // Yes error } return true; // No error } export { executeSafely, executeSafely_async }; ================================================ FILE: src/server/utility/generateDependancyGraph.ts ================================================ // src/server/utility/generateDependancyGraph.ts /* * This script generates the dependency tree graph of the project. * To use it, enter the command: * * npm run generate-dependency-graph */ import madge, { MadgeInstance } from 'madge'; const pathOfFileToGenerateDependencyGraphFor: string = 'dist/server/server.js'; // Enable for the server-side code // const pathOfFileToGenerateDependencyGraphFor = 'dist/client/scripts/esm/game/main.js'; // Enable for the client-side code const nameToGiveDependencyGraph: string = 'dependencyGraph.svg'; madge(pathOfFileToGenerateDependencyGraphFor) .then((res: MadgeInstance) => res.image(nameToGiveDependencyGraph)) .then((writtenImagePath: string) => { console.log('Dependency graph image written to ' + writtenImagePath); }); ================================================ FILE: src/server/utility/lockFile.ts ================================================ // src/server/utility/lockFile.ts /** * This module extends the 'fs' module with methods * that lock a file while it is being read/written. * If you try to read/write a file while it is locked, * you will get an error. * * This prevents data corruption when multiple code points * try to read/write the members file at the same time. */ import fs from 'fs/promises'; import lockfile from 'proper-lockfile'; // Locks the file while reading, then immediately unlocks and returns the data. // MUST BE CALLED WITH 'await' or this returns a promise! async function readFile(path: string, buffer: BufferEncoding = 'utf-8'): Promise { let data: D | undefined; await lockfile.lock(path).then(async (releaseFunc) => { // Do something while the file is locked try { const fileContents = await fs.readFile(path, buffer); data = JSON.parse(fileContents); } finally { // Don't CATCH the error, but always release the lock, even if we encounter an error! releaseFunc(); // Unlocks file } }); return data!; // Guaranteed to be defined, since if it isn't, the function will have thrown anyway. } // Returns false when failed to lock/write file. // MUST BE CALLED WITH 'await' or this returns a promise! async function writeFile(path: string, object: any): Promise { await lockfile.lock(path).then(async (release) => { // Do something while the file is locked try { await fs.writeFile(path, JSON.stringify(object, null, 1)); } finally { // Don't CATCH the error, but always release the lock, even if we encounter an error! release(); // Unlocks file } }); } export { readFile, writeFile }; ================================================ FILE: src/server/utility/mailer.ts ================================================ // src/server/utility/mailer.ts /* * This module sets up the email transporter (AWS SES via nodemailer) * and exposes a low-level sendMail helper for dispatching prepared emails. */ import nodemailer from 'nodemailer'; import { fromEnv } from '@aws-sdk/credential-providers'; import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; /** Options for sending an email. */ export type SendMailOptions = { to: string; subject: string; } & ({ html: string } | { text: string }); // --- Module Setup --- const AWS_REGION = process.env['AWS_REGION']; const EMAIL_FROM_ADDRESS = process.env['EMAIL_FROM_ADDRESS']; const AWS_ACCESS_KEY_ID = process.env['AWS_ACCESS_KEY_ID']; const AWS_SECRET_ACCESS_KEY = process.env['AWS_SECRET_ACCESS_KEY']; /** * Who our sent emails will appear as if they're from. */ const FROM = EMAIL_FROM_ADDRESS; // Create SES client const sesClient = AWS_REGION && EMAIL_FROM_ADDRESS && AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY ? new SESv2Client({ region: AWS_REGION, credentials: fromEnv(), }) : null; // Create nodemailer transporter using SES const transporter = sesClient ? nodemailer.createTransport({ SES: { sesClient, SendEmailCommand }, } as nodemailer.TransportOptions) : null; // --- Functions --- /** * Sends a prepared email via the transporter. * Logs a message and returns false if env variables are not configured. * @param options - Email options including recipient, subject, and content (html or text) * @returns Whether the email was sent, which won't be the case if env variables aren't present. */ async function send(options: SendMailOptions): Promise { if (!transporter) { console.log('Email environment variables not specified. Not sending email.'); return false; } await transporter.sendMail({ from: `"Infinite Chess" <${FROM}>`, ...options, }); return true; } // --- Exports --- export default { FROM, send }; ================================================ FILE: src/server/utility/newsUtil.ts ================================================ // src/server/utility/newsUtil.ts /** * Utility functions for handling news posts and tracking read status. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** The code of the language that is gauranteed to contain all current news posts. */ const language_code = 'en-US'; /** * Gets the date of the latest news post by reading filenames from the news directory. * News posts are named with dates like "2025-11-01.md" * @returns The date string of the latest news post (e.g., '2025-11-01'), or null if no news posts exist */ function getLatestNewsDate(): string | null { const newsPath = path.join(__dirname, '../../../translation/news', language_code); if (!fs.existsSync(newsPath)) { console.error(`News directory ${language_code} not found`); return null; } const files = fs.readdirSync(newsPath); const newsFiles = files.filter((file) => file.endsWith('.md')); if (newsFiles.length === 0) { return null; } // Extract dates from filenames (format: YYYY-MM-DD.md) const dates = newsFiles.map((file) => file.replace('.md', '')).sort(); // Return the most recent date const latestDate = dates[dates.length - 1]; return latestDate !== undefined ? latestDate : null; } /** * Gets all news post dates. * @returns Array of date strings sorted from oldest to newest */ function getAllNewsDates(): string[] { const newsPath = path.join(__dirname, '../../../translation/news', language_code); if (!fs.existsSync(newsPath)) { return []; } const files = fs.readdirSync(newsPath); const newsFiles = files.filter((file) => file.endsWith('.md')); // Extract dates and sort const dates = newsFiles.map((file) => file.replace('.md', '')).sort(); return dates; } /** * Counts the number of unread news posts for a user. * @param lastReadDate - The date of the last news post the user read (format: 'YYYY-MM-DD'), or null if never read * @returns The number of unread news posts */ function countUnreadNews(lastReadDate: string | null): number { const allDates = getAllNewsDates(); if (allDates.length === 0) { return 0; } // If user has never read news, all posts are unread if (!lastReadDate) { return allDates.length; } // Count posts newer than the last read date const unreadCount = allDates.filter((date) => date > lastReadDate).length; return unreadCount; } /** * Gets the dates of unread news posts for a user. * @param lastReadDate - The date of the last news post the user read, or null if never read * @returns Array of unread news post dates */ function getUnreadNewsDates(lastReadDate: string | null): string[] { const allDates = getAllNewsDates(); if (allDates.length === 0) { return []; } // If user has never read news, all posts are unread if (!lastReadDate) { return allDates; } // Return posts newer than the last read date return allDates.filter((date) => date > lastReadDate); } export { getLatestNewsDate, countUnreadNews, getUnreadNewsDates }; ================================================ FILE: src/server/utility/startupLogger.ts ================================================ // src/server/utility/startupLogger.ts /** * This module logs server startup and shutdown events to a log file. */ import fs from 'fs'; import path from 'path'; import { format } from 'date-fns'; import paths from '../config/paths.js'; // Helpers ------------------------------------------------------------------------------- /** Writes a log entry to logs/startupLog.txt with the provided message and a timestamp. */ function writeStartupLog(message: string): void { const timestamp = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); const line = `${timestamp} | ${message}. PID: ${process.pid}\n`; try { fs.mkdirSync(paths.LOGS_DIR, { recursive: true }); fs.appendFileSync(path.join(paths.LOGS_DIR, 'startupLog.txt'), line); } catch (err) { console.error('Failed to write to startupLog.txt:', err); } } // API ----------------------------------------------------------------------------------- /** Logs a server startup entry to logs/startupLog.txt. */ function logServerStarted(): void { writeStartupLog('🟢 Server started'); } /** * Logs a server shutdown entry to logs/startupLog.txt. * Uses synchronous I/O so the write completes before process.exit(). * @param signal - The signal that triggered the shutdown (e.g. 'SIGTERM'). */ function logServerStopped(signal: string): void { writeStartupLog(`🔴 Server stopped (${signal})`); } // Exports ------------------------------------------------------------------------------- export { logServerStarted, logServerStopped }; ================================================ FILE: src/server/utility/translate.ts ================================================ // src/server/utility/translate.ts /** * Retrieves the translation for the code and language specified. */ import type { Request } from 'express'; import type { TranslationKeys } from '../../types/translations.js'; import i18next from 'i18next'; // Constnats ----------------------------------------------------------------- const DEFAULT_LANGUAGE = 'en-US'; // Functions ----------------------------------------------------------------- /** * Determines the language to be used for serving an HTML file to a request. * The language is determined in the following order of precedence: * 1. The 'lng' query parameter, which can be different than the others. * 2. The 'i18next' cookie, which can also be different than the others. * 3. The value of req.i18n.resolvedLanguage (typical of users' first-connection to the site), * which is ALWAYS defined! This is determined by several different factors, * but i18next also takes into account the 'Accept-Language' header for this property. * 4. A default language, if none of the above are supported. * * The selected language is validated against supported languages, * using a default language if none are supported. * @param req - The Express request object. * @returns The language to be used. */ function getLanguageToServe(req: Request): string { const cookies = req.cookies; const supportedLngs = i18next.options.supportedLngs; if (!(supportedLngs instanceof Array)) { throw new Error('i18next.options.supportedLngs was not set'); } let language = req.query['lng'] || cookies['i18next'] || req.i18n.resolvedLanguage; if (!supportedLngs.includes(language)) language = cookies['i18next']; // Query param language not supported if (!supportedLngs.includes(language)) language = req.i18n.resolvedLanguage; // Cookie language not supported if (!supportedLngs.includes(language)) language = DEFAULT_LANGUAGE; // Resolved language from i18next not supported return language; } /** * Retrieves the translation for a given key and language. * @param key - The translation key to look up. For example, `"play.javascript.termination.checkmate"` * @param language - The language code for the translation. Default: `"en-US"` * @param options - Additional i18next options (e.g., returnObjects for array translations) * @returns The translated string or object. */ function getTranslation(key: TranslationKeys, language: string = DEFAULT_LANGUAGE): string { const options = { lng: language }; return i18next.t(key, options); } /** * Retrieves the translation for a given key and req. It reads the req's cookies for its preferred language. * @param key - The translation key to look up. For example, `"play.javascript.termination.checkmate"` * @param req - The request object * @returns The translated string. */ function getTranslationForReq(key: TranslationKeys, req: Request): string { const language = getLanguageToServe(req); return getTranslation(key, language); } // Exports ------------------------------------------------------------------- export { DEFAULT_LANGUAGE, getLanguageToServe, getTranslation, getTranslationForReq }; ================================================ FILE: src/server/utility/urlUtils.ts ================================================ // src/server/utility/urlUtils.ts import 'dotenv/config'; // Imports all properties of process.env, if it exists /** * Gets the base URL for the application, respecting the environment. * @returns The full base URL for the current environment. */ export function getAppBaseUrl(): string { if (process.env['NODE_ENV'] !== 'production') { // In development, construct the localhost URL return `https://localhost:${process.env['HTTPSPORT_LOCAL']}`; } else { // In production, use the base URL from the environment variables return process.env['APP_BASE_URL']!; } } ================================================ FILE: src/server/utility/zodlogger.ts ================================================ // src/server/utility/zodlogger.ts import * as z from 'zod'; import { logEvents, logEventsAndPrint } from '../middleware/logEvents.js'; /** * A consistent way of logging all malformed incoming messages, * whether websocket message, API request, etc. * Puts all details in `zodLog.txt`, and a one-liner notifier in `errLog.txt` and in the console. * @param json - The pre-parsed JSON message that was malformed. * @param zodError - The ZodError from the zod result during validation. * @param contextMessage - Brief description of where this error occurred. e.g. "Received malformed websocket in-message." */ export function logZodError(json: any, zodError: z.ZodError, contextMessage: string): void { const treeifiedErrors = JSON.stringify(z.treeifyError(zodError), null, 2); const logText = `${contextMessage} - Message contents: ${JSON.stringify(json, null, 2)} Zod treeified errors: ${treeifiedErrors} =================================================================== `; logEvents(logText, 'zodLog.txt'); logEventsAndPrint(`${contextMessage} - Check zodLog.txt for more details.`, 'errLog.txt'); } ================================================ FILE: src/shared/chess/logic/boardchanges.ts ================================================ // src/shared/chess/logic/boardchanges.ts /** * This script both contructs the changes list of a Move, and executes them * when requested, modifying the piece lists according to what moved * or was captured, forward or backward. * * The change functions here do NOT modify the mesh or animate anything, * however, graphicalchanges.ts may rely on these changes present to * know how to change the mesh, or what to animate. */ import jsutil from '../../util/jsutil.js'; import typeutil from '../util/typeutil.js'; import boardutil from '../util/boardutil.js'; import organizedpieces from './organizedpieces.js'; import coordutil, { CoordsKey } from '../util/coordutil.js'; // Variables ------------------------------------------------------------------------- /** All Change actions that cannot be undone to return to the same board position later in the game, unless in the future it's possible to add pieces mid-game. */ const oneWayActions: string[] = ['capture', 'delete']; // Type Definitions------------------------------------------------------------------------- import type { MoveFull } from './movepiece.js'; import type { Coords } from '../util/coordutil.js'; import type { Piece } from '../util/boardutil.js'; import type { FullGame } from './gamefile.js'; /** * Generic type to describe any changes to the board */ type Change = { /** Whether this change affects the main piece moved. * This would be true if the change was for moving the king during castling, but false for moving the rook. */ main: boolean; /** The main piece affected by the move. If this is a move/capture action, it's the piece moved. If it's an add/delete action, it's the piece added/deleted. */ piece: Piece; } & ( | { /** The type of action this change performs. */ action: 'add' | 'delete'; } | { action: 'move'; endCoords: Coords; path?: Coords[]; } | { action: 'capture'; /** * This is used by animations to tell when this piece was captured. * 0 based. 1 means the piece was captured at the 2nd path point. * `-1` implies the end of the path the piece moved along */ order: number; } ); /** * A generic function that takes the changes list of a move, and modifies either * the piece lists to reflect that move, or modifies the mesh of the pieces, * depending on the function, BUT NOT BOTH. */ type genericChangeFunc = (_actiondata: T, _change: Change) => void; /** * An actionlist is a dictionary links actions to functions. * The function uses the change data for operations. Eg animation, updating mesh, logic * It won't always include every action. * If an action is looked up and there isn't a function for it, it's change is ignored */ interface ActionList { [actionName: string]: F; } /** * A change application is used for applying the changelist of a move in both directions. */ interface ChangeApplication { forward: ActionList; backward: ActionList; } /** * An object mapping move changes to a function that performs the piece list changes for that action. */ const changeFuncs: ChangeApplication> = { forward: { add: addPiece, delete: deletePiece, move: movePiece, capture: deletePiece, }, backward: { delete: addPiece, add: deletePiece, move: returnPiece, capture: addPiece, }, }; // Adding changes to a Move ---------------------------------------------------------------------------------------- /** * Queues a move with catpure * Need to differentiate this from move so animations can work and so that royal capture can be recognised * @param changes * @param piece The piece captured. * @param main Whether this change is affecting the main piece moved, not a secondary piece. * @param order This is used by animations to tell when this piece was captured. `-1` implies the end of the path the piece moved along */ function queueCapture( changes: Array, main: boolean, piece: Piece, order: number = -1, ): Change[] { const change: Change = { action: 'capture', main, piece, order }; changes.push(change); return changes; } /** * Queues the addition of a piece to the board * @param changes * @param piece the piece to add * the pieces index is optional and will get assigned one if none is present */ function queueAddPiece(changes: Array, piece: Piece): Change[] { changes.push({ action: 'add', main: false, piece }); // It's impossible for an 'add' change to affect the main piece moved, because before this move this piece didn't exist. return changes; } /** * Queues the removal of a piece by adding that Change to the Changes list. * @param changes - The running list of Changes for the move. * @param piece - The piece this change affects * @param main - Whether this change is affecting the main piece moved, not a secondary piece. */ function queueDeletePiece(changes: Array, main: boolean, piece: Piece): Change[] { changes.push({ action: 'delete', main, piece }); return changes; } /** * Moves a piece without capture * @param changes * @param piece The piece moved. Its coords are used as starting coords * @param main - Whether this change is affecting the main piece moved, not a secondary piece. * @param endCoords */ function queueMovePiece( changes: Array, main: boolean, piece: Piece, endCoords: Coords, path?: Coords[], ): Change[] { const change: Change = { action: 'move', main, piece, endCoords }; if (path !== undefined) change.path = path; changes.push(change); return changes; } // Executing changes of a Move ---------------------------------------------------------------------------------------- /** * Applies the board changes of a move either forward or backward, * either modifying the piece lists, or modifying the mesh, * depending on what changeFuncs are passed in. */ function runChanges( actiondata: T, changes: Change[], changeFuncs: ChangeApplication>, forward: boolean = true, ): void { const funcs = forward ? changeFuncs.forward : changeFuncs.backward; applyChanges(actiondata, changes, funcs, forward); } /** * Applies the logical board changes of a change list in the provided order, modifying the piece lists. * @param actiondata the data to apply the changes to * @param changes the changes to apply * @param funcs the object contain change funcs * @param forward whether to apply changes in forward order (true) or reverse order (false) */ function applyChanges( actiondata: T, changes: Array, funcs: ActionList>, forward: boolean, ): void { if (forward) { // Iterate forwards through the changes array for (const change of changes) { if (!(change.action in funcs)) throw Error( `Missing change function for likely-invalid change action "${change.action}"!`, ); funcs[change.action]!(actiondata, change); } } else { // Iterate backwards through the changes array so the move's changes are reverted in the correct order for (let i = changes.length - 1; i >= 0; i--) { const change = changes[i]!; if (!(change.action in funcs)) throw Error( `Missing change function for likely-invalid change action "${change.action}"!`, ); funcs[change.action]!(actiondata, change); } } } // Standard Chagne Functions -------------------------------------------------------------------------------------- /** * Most basic add-a-piece method. Adds it the gamefile's piece list, * organizes the piece in the organized lists */ function addPiece({ boardsim, basegame }: FullGame, change: Change): void { // desiredIndex optional const pieces = boardsim.pieces; const typedata = pieces.typeRanges.get(change.piece.type); if (typedata === undefined) throw Error( `Type: "${typeutil.debugType(change.piece.type)}" is not expected to be in the game`, ); let idx: number; if (change.piece.index === -1) { // Does not have an index yet, assign it one from undefined list if (typedata.undefineds.length === 0) { if ( organizedpieces.getTypeUndefinedsBehavior( change.piece.type, boardsim.editor, basegame.gameRules.promotionsAllowed, ) === 0 ) throw Error( `Type: ${typeutil.debugType(change.piece.type)} is not expected to be added after initial position!`, ); organizedpieces.regenerateLists( boardsim.pieces, boardsim.editor, basegame.gameRules.promotionsAllowed, ); } idx = typedata.undefineds.shift()!; change.piece.index = boardutil.getRelativeIdx(pieces, idx); } else { idx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range const { found, index } = jsutil.binarySearch(typedata.undefineds, idx); if (!found) throw Error( `Newly added piece ${JSON.stringify(change.piece)} attemped to overwrite an occupied index`, ); typedata.undefineds.splice(index, 1); } pieces.XPositions[idx] = change.piece.coords[0]; pieces.YPositions[idx] = change.piece.coords[1]; // Don't need to set it's type, because it's spot in the type range already has its type. organizedpieces.registerPieceInSpace(idx, pieces); } /** * Most basic delete-a-piece method. Deletes it from the gamefile's piece list, * from the organized lists. */ function deletePiece({ boardsim }: FullGame, change: Change): void { const pieces = boardsim.pieces; const typedata = pieces.typeRanges.get(change.piece.type); if (typedata === undefined) throw Error( `Type: "${typeutil.debugType(change.piece.type)}" is not expected to be in the game`, ); if (change.piece.index === -1) throw Error('Piece has not been allocated in organizedPieces'); const idx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range organizedpieces.removePieceFromSpace(idx, pieces); jsutil.addElementToOrganizedArray(typedata.undefineds, idx); // Set the undefined piece's coordinates to [0,0] to keep things tidy. pieces.XPositions[idx] = 0n; pieces.YPositions[idx] = 0n; // Don't need to delete its type because every spot in a type range is expected to have the same type. } /** * Most basic move-a-piece method. Adjusts its coordinates in the gamefile's piece lists, * reorganizes the piece in the organized lists, and updates its mesh data. * * If the move is a capture, then use capturePiece() instead, so that we can animate it. * @param gamefile - The gamefile * @param change - the move data */ function movePiece({ boardsim }: FullGame, change: Change): void { if (change.action !== 'move') throw new Error(`movePiece called with a non-move change: ${change.action}`); const pieces = boardsim.pieces; const idx = boardutil.getAbsoluteIdx(pieces, change.piece); // Remove the relative-ness to the start of its type range organizedpieces.removePieceFromSpace(idx, pieces); pieces.XPositions[idx] = change.endCoords[0]; pieces.YPositions[idx] = change.endCoords[1]; organizedpieces.registerPieceInSpace(idx, pieces); } /** * Reverses `movePiece` */ function returnPiece({ boardsim }: FullGame, change: Change): void { if (change.action !== 'move') throw new Error(`returnPiece called with a non-move change: ${change.action}`); const pieces = boardsim.pieces; const range = pieces.typeRanges.get(change.piece.type)!; const idx = change.piece.index + range.start; organizedpieces.removePieceFromSpace(idx, pieces); pieces.XPositions[idx] = change.piece.coords[0]; pieces.YPositions[idx] = change.piece.coords[1]; organizedpieces.registerPieceInSpace(idx, pieces); } // Other Change Functions ----------------------------------------------------------------------------------- /** * This modifies only a Position Map where number is the type of piece. * It does NOT modify a gamefile or its organized pieces. * This also only works applying a move's changes FORWARD. * * This is intended for updating a simplified board state, one that is used in gamecompressor.GameToPosition */ function runChanges_Position(position: Map, changes: Change[]): void { for (const change of changes) { const startCoordsKey = coordutil.getKeyFromCoords(change.piece.coords); switch (change.action) { case 'move': position.delete(startCoordsKey); position.set(coordutil.getKeyFromCoords(change.endCoords), change.piece.type); break; case 'capture': position.delete(startCoordsKey); break; case 'add': position.set(startCoordsKey, change.piece.type); break; case 'delete': position.delete(startCoordsKey); break; default: // @ts-ignore throw Error(`Unknown change action: ${change.action}`); } } } // Helper Functions ---------------------------------------------------------------------------------------- /** * Gets every captured piece in changes */ function getCapturedPieceTypes(move: MoveFull): Set { const pieceTypes: Set = new Set(); move.changes.forEach((change) => { if (change.action === 'capture') pieceTypes.add(change.piece.type); }); return pieceTypes; } /** * Returns true if any piece was captured by the move, whether directly or by special actions. */ function wasACapture(move: MoveFull): boolean { // Safety net if we ever accidentally call this method too soon. // There will never be a valid move with zero changes, that's just absurd. if (move.changes.length === 0) throw Error("Move doesn't have it's changes calculated yet, do that before this."); return move.changes.some((change) => change.action === 'capture'); } // Exports ---------------------------------------------------------------------------------------- export type { genericChangeFunc, ChangeApplication, Change }; export default { changeFuncs, queueCapture, queueAddPiece, queueDeletePiece, queueMovePiece, runChanges, runChanges_Position, getCapturedPieceTypes, wasACapture, oneWayActions, applyChanges, }; ================================================ FILE: src/shared/chess/logic/checkdetection.ts ================================================ // src/shared/chess/logic/checkdetection.ts /** * This script is used to detect check, * or detect if a specific square is being attacked by any * other piece, be it individual, special, or sliding move. */ import type { Player } from '../util/typeutil.js'; import type { CheckInfo } from './state.js'; import type { CoordsTagged } from './movepiece.js'; import type { Board, FullGame } from './gamefile.js'; import type { Coords, CoordsKey } from '../util/coordutil.js'; import typeutil from '../util/typeutil.js'; import boardutil from '../util/boardutil.js'; import coordutil from '../util/coordutil.js'; import legalmoves from './legalmoves.js'; import organizedpieces from './organizedpieces.js'; import { players as p } from '../util/typeutil.js'; import vectors, { Vec2 } from '../../util/math/vectors.js'; // Functions ---------------------------------------------------------------- /** * Tests if the provided player color is in check in the current position of the gamefile. * @param gamefile - The gamefile * @param color - The player color to test if any of their royals are in check in the current position. * @param trackChecks - If true, the results object will contain a list of check pairs for the player's royals. This is useful for calculating blocking moves that may resolve the check. Should be true if we're using checkmate, and left out if we're using royal capture, to save compute. * @returns An object containing information such as whether the given color is in check in the current position, which royals are in check, and if applicable, the check pairs (each checked royal with its attacker). */ function detectCheck( gamefile: FullGame, color: Player, trackChecks?: boolean, ): { check: boolean; royalsInCheck: Coords[]; checks?: CheckInfo[] } { // Coordinates of ALL royals of this color! const royalCoords: Coords[] = boardutil.getRoyalCoordsOfColor(gamefile.boardsim.pieces, color); // Array of coordinates of royal pieces that are in check const royalsInCheck: Coords[] = []; const checks: CheckInfo[] | undefined = trackChecks ? [] : undefined; royalCoords.forEach((thisRoyalCoord) => { if (isSquareBeingAttacked(gamefile, thisRoyalCoord, color, checks)) { royalsInCheck.push(thisRoyalCoord); } }); return { check: royalsInCheck.length > 0, royalsInCheck, checks, }; } /** * Checks if an opponent player color is attacking a specific square. * @param gamefile * @param coord - The square of which to check if an opponent player color is attacking. * @param colorOfFriendly - The color of the friendly player. All other player colors will be tested to see if they attack the square. * @param [checks] If provided, any opponent attacking the square will be appended to this array as a CheckInfo pair. If it is not provided, we may exit early as soon as one attacker is discovered. */ function isSquareBeingAttacked( gamefile: FullGame, coord: Coords, colorOfFriendly: Player, checks?: CheckInfo[], ): boolean { let atleast1Attacker = false; // How do we find out if this square is attacked? // 1. We check every square within a 3 block radius to see if there's any attacking pieces. if (doesVicinityAttackSquare(gamefile.boardsim, coord, colorOfFriendly, checks)) { if (checks) atleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square... else return true; // Not keeping track of checks, exit early } // What about specials (e.g. pawns, roses...)? Could they capture us? if (doesSpecialAttackSquare(gamefile, coord, colorOfFriendly, checks)) { if (checks) atleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square... else return true; // Not keeping track of checks, exit early } // 2. We check every orthogonal and diagonal to see if there's any attacking pieces. if (doesSlideAttackSquare(gamefile, coord, colorOfFriendly, checks)) { if (checks) atleast1Attacker = true; // ARE keeping track of checks, continue checking if there are more attacking the same square... else return true; // Not keeping track of checks, exit early } return atleast1Attacker; // Being attacked if true } /** * Checks to see if any opponent jumper within the immediate vicinity of the coordinates can attack them with an individual move (discounting special movers). * @param boardsim * @param square - The square to check if any opponent jumpers are attacking. * @param friendlyColor - The friendly player color * @param [checks] If provided, any opponent jumper attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one jumper attacker is discovered. * @returns true if the square is being attacked by at least one opponent jumper with an individual move (discounting special movers). */ function doesVicinityAttackSquare( boardsim: Board, square: Coords, friendlyColor: Player, checks?: CheckInfo[], ): boolean { for (const [coordsKey, thisVicinity] of Object.entries(boardsim.vicinity)) { const thisSquare = coordutil.getCoordsFromKey(coordsKey as CoordsKey); // [1,2], [2,1], ... // Subtract the offset of our square const actualSquare: Coords = [square[0] - thisSquare[0], square[1] - thisSquare[1]]; // Fetch the piece type currently on that square const typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, actualSquare); if (typeOnSquare === undefined) continue; // Nothing there to capture us // Is it the same color? const [trimmedTypeOnSquare, typeOnSquareColor] = typeutil.splitType(typeOnSquare); if (friendlyColor === typeOnSquareColor) continue; // A friendly can't capture us if (typeOnSquareColor === p.NEUTRAL) continue; // Neutrals can't capture us either (GARGOYLE ALERT) // Is that a match with any piece type on this vicinity square? if ((thisVicinity as number[]).includes(trimmedTypeOnSquare)) { checks?.push({ royal: square, attacker: actualSquare, slidingCheck: false }); return true; // There'll never be more than 1 short-range/jumping checks! UNLESS it's multiplayer, but multiplayer won't use checkmate anyway so checks won't be specified } } return false; // No jumper attacks the square } /** * Checks to see if any piece within the immediate vicinity of the coordinates can attack them with via a special individual move (e.g. pawns, roses...) * @param {gamefile} gamefile * @param square - The square to check if any opponent jumpers are attacking. * @param friendlyColor - The friendly player color * @param [checks] If provided, any opponent jumper attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one jumper attacker is discovered. * @returns true if the square is being attacked by at least one piece via a special individual move. */ function doesSpecialAttackSquare( gamefile: FullGame, square: CoordsTagged, friendlyColor: Player, checks?: CheckInfo[], ): boolean { const { boardsim } = gamefile; for (const [coordsKey, thisVicinity] of Object.entries(boardsim.specialVicinity)) { const thisSquare = coordutil.getCoordsFromKey(coordsKey as CoordsKey); // [1,2], [2,1], ... // Subtract the offset of our square const actualSquare: Coords = [square[0] - thisSquare[0], square[1] - thisSquare[1]]; // Fetch the piece type currently on that square const typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, actualSquare); if (typeOnSquare === undefined) continue; // Nothing there to capture us // Is it the same color? const [trimmedTypeOnSquare, typeOnSquareColor] = typeutil.splitType(typeOnSquare); if (friendlyColor === typeOnSquareColor) continue; // A friendly can't capture us if (typeOnSquareColor === p.NEUTRAL) continue; // Neutrals can't capture us either (GARGOYLE ALERT) // Is that a match with any piece type on this vicinity square? if ((thisVicinity as number[]).includes(trimmedTypeOnSquare)) { // This square can POTENTIALLY be captured via special move... // Calculate that special piece's legal moves to see if it ACTUALLY can capture on that square const pieceOnSquare = boardutil.getPieceFromCoords(boardsim.pieces, actualSquare)!; const moveset = legalmoves.getPieceMoveset(boardsim, pieceOnSquare.type); const specialPiecesLegalMoves = legalmoves.getEmptyLegalMoves(moveset); legalmoves.appendSpecialMoves( gamefile, pieceOnSquare, moveset, specialPiecesLegalMoves, false, ); // console.log("Calculated special pieces legal moves:"); // console.log(jsutil.deepCopyObject(specialPiecesLegalMoves)); if ( !legalmoves.checkIfMoveLegal( gamefile, specialPiecesLegalMoves, actualSquare, square, friendlyColor, ) ) continue; // This special piece can't make the capture THIS time... oof // console.log("SPECIAL PIECE CAN MAKE THE CAPTURE!!!!"); if (checks) { /** * If the `path` special flag is present (which it would be for Roses), * attach that to the CheckInfo, so that checkresolver can test if any * legal moves can block the path to stop this check. */ const checkInfo: CheckInfo = { royal: square, attacker: actualSquare, slidingCheck: false, }; if (square.path !== undefined) checkInfo.path = square.path; checks.push(checkInfo); } return true; // There'll never be more than 1 short-range/jumping checks! UNLESS it's multiplayer, but multiplayer won't use checkmate anyway so checks won't be specified } } return false; // No special mover attacks the square } /** * Calculates if any sliding piece can attack the specified square. * @param boardsim * @param square - The square to check if any opponent sliders are attacking. * @param friendlyColor - The friendly player color * @param [checks] If provided, any opponent slider attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one slider attacker is discovered. * @returns true if the square is being attacked by at least one opponent slider. */ function doesSlideAttackSquare( gamefile: FullGame, square: Coords, friendlyColor: Player, checks?: CheckInfo[], ): boolean { let atleast1Attacker = false; for (const [directionkey, lineSet] of gamefile.boardsim.pieces.lines) { // [dx,dy] const direction = coordutil.getCoordsFromKey(directionkey); const key = organizedpieces.getKeyFromLine(direction, square); if ( doesLineAttackSquare( gamefile, lineSet.get(key), direction, square, friendlyColor, checks, ) ) { if (!checks) return true; // Not keeping track of checks, exit early atleast1Attacker = true; } } return atleast1Attacker; } /** * Tests if a piece on the specified organized line can capture on the specified square via a sliding move. * REQUIRES the square be on the line!!! * @param boardsim * @param line - The organized line of pieces * @param direction - The step of the line: [dx,dy] * @param coords - The coordinates of the square to test if any piece on the line can slide to. MUST be on the line!!! * @param color - The player color of friendlies. Friendlies can't capture us. * @param [checks] - If provided, any opponent slider attacking the square will be appended to this array as a CheckInfo. If it is not provided, we may exit early as soon as one slider attacker is discovered. * @returns true if the square is under threat */ function doesLineAttackSquare( gamefile: FullGame, line: number[] | undefined, direction: Vec2, coords: Coords, color: Player, checks?: CheckInfo[], ): boolean { if (!line) return false; // This line doesn't exist, then obviously no pieces can attack our square const directionKey = vectors.getKeyFromVec2(direction); // 'dx,dy' let atleast1Attacker = false; // Iterate through every piece on the line, and test if they can attack our square for (const thisPieceIdx of line) { // { coords, type } const thisPiece = boardutil.getPieceFromIdx(gamefile.boardsim.pieces, thisPieceIdx)!; const thisPieceColor = typeutil.getColorFromType(thisPiece.type); if (color === thisPieceColor) continue; // Same team, can't capture us, CONTINUE to next piece! if (thisPieceColor === p.NEUTRAL) continue; // Neutrals can't move, that means they can't make captures, right? const thisPieceMoveset = legalmoves.getPieceMoveset(gamefile.boardsim, thisPiece.type); if (!thisPieceMoveset.sliding) continue; // Piece has no sliding movesets. const moveset = thisPieceMoveset.sliding[directionKey]; if (!moveset) continue; // Piece can't slide in the direction our line is going const blockingFunc = legalmoves.getBlockingFuncFromPieceMoveset(thisPieceMoveset); const thisPieceLegalSlide = legalmoves.slide_CalcLegalLimit( gamefile.basegame.gameRules.worldBorder, blockingFunc, gamefile.boardsim.pieces, line, direction, moveset, thisPiece.coords, thisPieceColor, false, ); if (!thisPieceLegalSlide) continue; // This piece can't move in the direction of this line, NEXT piece! const ignoreFunc = legalmoves.getIgnoreFuncFromPieceMoveset(thisPieceMoveset); // prettier-ignore if (!legalmoves.doesSlidingMovesetContainSquare(thisPieceLegalSlide, direction, thisPiece.coords, coords, ignoreFunc)) continue; // This piece can't slide so far as to reach us, NEXT piece! // This piece is attacking this square! if (!checks) { return true; // Checks array isn't being tracked, just insta-return to save compute not finding other checks! } else { checks.push({ royal: coords, attacker: thisPiece.coords, slidingCheck: true, colinear: thisPieceMoveset.colinear, }); } atleast1Attacker = true; } return atleast1Attacker; } // Exports ---------------------------------------------------------------- export default { detectCheck, doesLineAttackSquare, }; export type {}; ================================================ FILE: src/shared/chess/logic/checkmate.ts ================================================ // src/shared/chess/logic/checkmate.ts /** * This script contains our checkmate algorithm. */ import type { FullGame } from './gamefile.js'; import type { GameConclusion } from '../util/winconutil.js'; import typeutil from '../util/typeutil.js'; import moveutil from '../util/moveutil.js'; import boardutil from '../util/boardutil.js'; import legalmoves from './legalmoves.js'; import { rawTypes } from '../util/typeutil.js'; import gamefileutility from '../util/gamefileutility.js'; /** The maximum number of pieces in-game to still use the checkmate algorithm. Above this uses "royalcapture". */ const pieceCountToDisableCheckmate = 50_000; /** The maximum number of royal pieces in-game to still use the checkmate algorithm. Above this uses "royalcapture". */ const royalCountToDisableCheckmate = 6; /** * Calculates if the provided gamefile is over by checkmate or stalemate * @param gamefile - The gamefile to detect if it's in checkmate * @returns The color of the player who won by checkmate. * `{ victor: 1, condition: 'checkmate' }`, `{ victor: 2, condition: 'checkmate' }`, * or `{ victor: 0, condition: 'stalemate' }`. Or *undefined* if the game isn't over. */ function detectCheckmateOrStalemate(gamefile: FullGame): GameConclusion | undefined { const { basegame, boardsim } = gamefile; // The game will be over when the player has zero legal moves remaining, lose or draw. // Iterate through every piece, calculating its legal moves. The first legal move we find, we // know the game is not over yet... for (const rType of Object.values(rawTypes)) { const thisType = typeutil.buildType(rType, basegame.whosTurn); const thesePieces = boardsim.pieces.typeRanges.get(thisType); if (!thesePieces) continue; // The game doesn't have this type of piece for (let idx = thesePieces.start; idx < thesePieces.end; idx++) { const thisPiece = boardutil.getPieceFromIdx(boardsim.pieces, idx); if (!thisPiece) continue; // Piece undefined. We leave in deleted pieces so others retain their index! const moves = legalmoves.calculateAll(gamefile, thisPiece); if (legalmoves.hasAtleast1Move(moves, gamefile, thisPiece)) return undefined; // Not checkmate } } // We made it through every single piece without finding a single move. // So is this draw or checkmate? Depends on whether the current state is check! // Also make sure that checkmate can't happen if the winCondition is NOT checkmate! const usingCheckmate = gamefileutility.isOpponentUsingWinCondition( basegame, basegame.whosTurn, 'checkmate', ); if (gamefileutility.isCurrentViewedPositionInCheck(boardsim) && usingCheckmate) { const colorThatWon = moveutil.getColorThatPlayedMoveIndex( basegame, boardsim.moves.length - 1, ); return { victor: colorThatWon, condition: 'checkmate' }; } else return { victor: null, condition: 'stalemate' }; } export { pieceCountToDisableCheckmate, royalCountToDisableCheckmate, detectCheckmateOrStalemate }; ================================================ FILE: src/shared/chess/logic/checkresolver.ts ================================================ // src/shared/chess/logic/checkresolver.ts /** * This script contains methods that reduce the legal moves of a piece * to only the ones that don't leave the player in check. * * This could be not dodging/blocking/capturing an existing check, * or pinned pieces opening a discovered. */ import type { Piece } from '../util/boardutil.js'; import type { Coords } from '../util/coordutil.js'; import type { Player } from '../util/typeutil.js'; import type { FullGame } from './gamefile.js'; import type { CheckInfo } from './state.js'; import type { LegalMoves } from './legalmoves.js'; import type { Vec2, Vec2Key } from '../../util/math/vectors.js'; import type { CoordsTagged, MoveTagged, MoveSpecialTags } from './movepiece.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import jsutil from '../../util/jsutil.js'; import bimath from '../../util/math/bimath.js'; import vectors from '../../util/math/vectors.js'; import typeutil from '../util/typeutil.js'; import moveutil from '../util/moveutil.js'; import geometry from '../../util/math/geometry.js'; import bdcoords from '../util/bdcoords.js'; import boardutil from '../util/boardutil.js'; import coordutil from '../util/coordutil.js'; import movepiece from './movepiece.js'; import legalmoves from './legalmoves.js'; import boardchanges from './boardchanges.js'; import specialdetect from './specialdetect.js'; import checkdetection from './checkdetection.js'; import gamefileutility from '../util/gamefileutility.js'; import { players as p } from '../util/typeutil.js'; import bounds, { BoundingBox } from '../../util/math/bounds.js'; // Functions ------------------------------------------------------------------------------ /** * Deletes individual and sliding moves from the provided LegalMoves object that, * if they were to be played, would result in that player being in check. * These moves are illegal if your opponent has the 'checkmate' win condition. * * This could be pinned pieces opening a discovered, * or not dodging/blocking/capturing an existing check. * * If only a finite number of squares of a slide are legal, the whole slide is * still deleted, and those finite number of squares added as new individual moves. * @param gamefile * @param moves - The LegalMoves object * @param pieceSelected - The piece of which the legalMoves were calculated for * @param color - The color of the player owning the piece */ function removeCheckInvalidMoves( gamefile: FullGame, pieceSelected: Piece, moves: LegalMoves, ): void { const color = typeutil.getColorFromType(pieceSelected.type); if (color === p.NEUTRAL) return; // Neutral pieces can't be in check if (!gamefileutility.isOpponentUsingWinCondition(gamefile.basegame, color, 'checkmate')) return; if (boardutil.getRoyalCoordsOfColor(gamefile.boardsim.pieces, color).length === 0) return; // No royals -> zero checks possible, ever. // There's a couple type of moves that put you in check: // 1. Sliding moves. Possible they can open a discovered check, or fail to address an existing check. // Check these FIRST because in situations where we are in existing check, additional individual moves may be added, which are then simulated below to see if they're legal. removeCheckInvalidMoves_Sliding(gamefile, moves, pieceSelected, color); // 2. Individual moves. We can iterate through these and use detectCheck() to test them. removeCheckInvalidMoves_Individual(gamefile, moves.individual, pieceSelected, color); // console.log("Legal moves after removing check invalid:"); // console.log(moves); } /** * Deletes moves from the provided legal individual moves list that, * if they were to be played, would result in that player being in check. * * This could be pinned pieces opening a discovered, * or not dodging/blocking/capturing an existing check. * @param gamefile * @param individualMoves - The precalculated legal individual (jumping) moves for a piece. * @param piece - The piece of which the legal individual moves are for. * @param color - The color of the player the piece belongs to. */ function removeCheckInvalidMoves_Individual( gamefile: FullGame, individualMoves: CoordsTagged[], piece: Piece, color: Player, ): void { // [ [x,y], [x,y] ] // Simulate the move, then check the game state for check for (let i = individualMoves.length - 1; i >= 0; i--) { // Iterate backwards so we don't run into issues as we delete indices while iterating const thisMove: CoordsTagged = individualMoves[i]!; // [x,y] if (isMoveCheckInvalid(gamefile, piece, thisMove, color)) individualMoves.splice(i, 1); // Remove the move } } /** * Deletes sliding moves from the provided legal moves object that are illegal (i.e. they result in check). * This can happen if they don't address an existing check, OR they open a discovered attack on your king. * * If finitely many moves of a slide protect against check, the slide is still deleted, and each * one is added to the legal individual moves. * @param gamefile * @param moves - The precalculated legalMoves object for a piece. * @param piece - The piece of which the running legal moves are for. * @param color - The color of the player the piece belongs to. */ function removeCheckInvalidMoves_Sliding( gamefile: FullGame, moves: LegalMoves, piece: Piece, color: Player, ): void { if (Object.keys(moves.sliding).length === 0) return; // No sliding moves to being with. const rawType = typeutil.getRawType(piece.type); const isRoyal = typeutil.royals.includes(rawType); // There are 3 ways a sliding move can put you in check... // 1. The piece making the sliding move IS A ROYAL itself (royalqueen) and it moves into check. if (isRoyal) moves.brute = true; // Flag the sliding moves to brute force check each move to see if it results in check, disallowing it if so. // 2. By not blocking, dodging, or capturing the attacker of an already-existing check. addressChecks(gamefile, moves, piece.coords, isRoyal); // 3. By opening a new discovered attack on one of our royals. addressPins(gamefile, moves, piece, color, isRoyal); } /** * Collapses all sliding moves that don't have a chance at addressing * the checks, replacing them with individual moves to be simulated later. * @param gamefile - The gamefile * @param moves - The legal moves object of which to delete moves that don't address check. * @param selectedPieceCoords - The coordinates of the piece we're calculating the legal moves for. * @param color - The color of friendlies * @param isRoyal - Whether the provided legal moves are for a royal piece. */ function addressChecks( gamefile: FullGame, moves: LegalMoves, selectedPieceCoords: Coords, isRoyal: boolean, ): void { const { boardsim } = gamefile; const checks = boardsim.state.local.checks; if (checks.length === 0) return; // Nothing in check if (Object.keys(moves.sliding).length === 0) return; // No sliding moves to collapse into more individuals that address the existing checks. // Does this piece have a sliding moveset that will either... // 1. Capture the checking piece // Collect which attackers are currently reachable by slide, BEFORE any slide modifications. // We'll add them as individual moves afterward. const attackersCaptureableBySlide: CoordsTagged[] = []; for (const c of checks) { if (legalmoves.doSlideRangesContainSquare(moves, selectedPieceCoords, c.attacker)) { attackersCaptureableBySlide.push(c.attacker); } } // 2. Dodge the check(s) - only if we're the one in check (royal queen) const sortedChecks = sortChecks(checks); for (const check of sortedChecks) { // Early exit if all slides have already been collapsed by a previous check. if (Object.keys(moves.sliding).length === 0) break; if ( !check.slidingCheck || // The check isn't even made along a slide check.colinear || // Don't need to delete the same slide as the check if it's a colinear check !isRoyal || // Can't be the piece in check if you're not a royal to begin with !coordutil.areCoordsEqual(check.royal, selectedPieceCoords) // Must be the piece in check ) continue; // We ARE the piece in check. Delete all slides that don't dodge the check. const checkLineGeneralForm = vectors.getLineGeneralFormFrom2Coords( check.royal, check.attacker, ); for (const slideDir of Object.keys(moves.sliding)) { const slideDirVec = vectors.getVec2FromKey(slideDir as Vec2Key); const slideLineGeneralForm = vectors.getLineGeneralFormFromCoordsAndVec( selectedPieceCoords, slideDirVec, ); if (!vectors.areLinesInGeneralFormEqual(checkLineGeneralForm, slideLineGeneralForm)) continue; // Non-coincident slides are legitimate dodges, the brute flag handles their verification. // This slide can only ever remain in line of sight of the attacker. delete moves.sliding[slideDir as Vec2Key]; // Collapse the slide. // For as long as sliding royals can't move colinearly, there // can only be one slide direction of the same vector to delete. if (!moves.colinear) break; } } // 3. Block the check(s) for (const check of sortedChecks) { // Early exit if all slides have been deleted/collapsed by a previous check. if (Object.keys(moves.sliding).length === 0) break; if (coordutil.areCoordsEqual(check.royal, selectedPieceCoords)) continue; // Must NOT be the piece in check (you can't block your own check) const dist = vectors.chebyshevDistance(check.royal, check.attacker); if ( isRoyal || // Royals can't block checks, PERIOD, without also putting themselves in check. (check.slidingCheck && dist === 1n) || // Can't get between royal & attacker (1 square apart) (!check.slidingCheck && (check.path?.length ?? 2) < 3) // Can't block jumping check (or path check with only 2 points) ) { moves.sliding = {}; // Collapse all slides, none can block this check. break; // No more slides left to collapse to resolve other checks. } if (check.slidingCheck) { // prettier-ignore // Has a chance to delete all sliding moves except one, adding the `brute` flag, if the check is colinear. appendBlockingMoves(check.royal, check.attacker, moves, selectedPieceCoords, check.colinear); } else { // Guaranteed non-arbitrary interpose squares. appendPathBlockingMoves(check.path!, moves, selectedPieceCoords); } } // --------------------------- // (Deferred) Add attacker captures as individual moves, but only for those whose slide was // collapsed during steps 2 or 3. If the slide was retained (e.g. a colinear check on the royal), // the slide already covers the capture — adding an individual would be a duplicate. for (const attacker of attackersCaptureableBySlide) { if (!legalmoves.doSlideRangesContainSquare(moves, selectedPieceCoords, attacker)) { appendMoveToIndividualsAvoidDuplicates(moves.individual, attacker); } } } /** * Deletes any sliding moves from the provided running legal moves that * open up a discovered attack on any of our royals. * Reads the current checks from the gamefile and ignores any that are already present — * only newly-exposed checks (from deleting the piece) are treated as pins. * @param gamefile * @param moves - The running legal moves of the selected piece * @param pieceSelected - The piece with the provided running legal moves * @param color - The color of the player the piece belongs to. * @param isRoyal - Whether the provided legal moves are for a royal piece. */ function addressPins( gamefile: FullGame, moves: LegalMoves, pieceSelected: Piece, color: Player, isRoyal: boolean, ): void { if (Object.keys(moves.sliding).length === 0) return; // No sliding moves to remove (may have already all been removed in addressChecks()) // Does not reflect checks for `color` if it's not currently their turn to move. // This is fine because only for whoever's turn it is, moves are check-respected. const preExistingChecks = gamefile.boardsim.state.local.checks; /** * To find out if our piece is pinned (or opens a discovered), we delete it, then test for check. * Any check that surfaces and is NOT in preExistingChecks resulted from breaking the pin. */ const deleteChange = boardchanges.queueDeletePiece([], true, pieceSelected); boardchanges.runChanges(gamefile, deleteChange, boardchanges.changeFuncs, true); const checkResults = checkdetection.detectCheck(gamefile, color, true); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] } // Filter to only the newly-exposed checks (ignore the pre-existing ones). const newChecks: CheckInfo[] = checkResults.checks!.filter((c) => { return !preExistingChecks.some( (p) => coordutil.areCoordsEqual(p.royal, c.royal) && coordutil.areCoordsEqual(p.attacker, c.attacker), ); }); // console.log('New checks:', newChecks); /** * Iterate through all newly-exposed check pairs. * Delete all sliding moves but the one in the direction of the line between the attacker and our royal. * If it was a `path` check (rose), then collapse all slides into only individuals that block the path. */ outer: for (const check of sortChecks(newChecks)) { // Early exit if all slides have been deleted/collapsed by a previous new check. if (Object.keys(moves.sliding).length === 0) break; const { royal, attacker } = check; // If the piece can capture the attacker, append it as an individual move // to be simulated later (removes the pin) BEFORE collapsing the slides. if (legalmoves.doSlideRangesContainSquare(moves, pieceSelected.coords, attacker)) { appendMoveToIndividualsAvoidDuplicates(moves.individual, attacker); } // If this piece is a royal, retaining the pin also keeps itself in check. So just collapse all slides. if (isRoyal) { moves.sliding = {}; break outer; // No more slides left to collapse to resolve other pins } if (!check.slidingCheck) { // Case 1: Individual jumping `path` check was exposed (Rose). if (!check.path) throw Error( `Attacker giving non-sliding check has no path! It's impossible for a sliding move to expose a pathless jumping check. Either the position is illegal, or this check was pre-existing and was not correctly filtered out. Color: ${typeutil.strcolors[color]}`, ); // Append any legal blocking squares on the path, then collapse all slides. // Guaranteed non-arbitrary interpose squares. appendPathBlockingMoves(check.path, moves, pieceSelected.coords); // We don't have to keep iterating through check pairs, since // if none of these newly added path-blocking/capture moves are legal, nothing else will be. // They are all simulated to see if they resolve the check. There are only finitely many. break outer; } // Case 2: Sliding check - That means this piece is on the same // line between the attacker and royal, AND in between them! const checkLineGeneralForm = vectors.getLineGeneralFormFrom2Coords(royal, attacker); // Delete all sliding moves but the one in the direction of the line between the attacker and the royal. for (const slideDir of Object.keys(moves.sliding)) { // 'dx,dy' const slideDirVec = vectors.getVec2FromKey(slideDir as Vec2Key); // [dx,dy] // Delete the slide if it is NOT along the pin line. const slideLineGeneralForm = vectors.getLineGeneralFormFromCoordsAndVec( pieceSelected.coords, slideDirVec, ); if (!vectors.areLinesInGeneralFormEqual(checkLineGeneralForm, slideLineGeneralForm)) { delete moves.sliding[slideDir as Vec2Key]; // Not the same line, delete it. continue; } // Slide is along the pin line. // Restrict to the zone strictly between the royal and the attacker (both exclusive, capturing move is added separately above). // prettier-ignore restrictSlideBetweenSquares(moves, slideDir as Vec2Key, slideDirVec, pieceSelected.coords, royal, attacker, check.colinear); } } boardchanges.runChanges(gamefile, deleteChange, boardchanges.changeFuncs, false); // Add the piece back // console.log("Legal moves after removing sliding moves that open discovered:"); // console.log(moves); } /** * Restricts the slide `slideDir` in `moves.sliding` to the zone strictly between `royal` and `attacker`, * intersected with the slide's current physical limits. Deletes the slide if no overlap remains. * Both the royal and attacker squares are excluded; captures are appended as individual moves by the caller. * @param direction - How much the piece moves in each step of the slide. * @param colinear - If true, sets `moves.brute` so every surviving square is verified by simulation. */ function restrictSlideBetweenSquares( moves: LegalMoves, slideDir: Vec2Key, direction: Vec2, pieceCoords: Coords, royal: Coords, attacker: Coords, colinear: boolean, ): void { const sliding = moves.sliding!; const axis: 0 | 1 = direction[0] === 0n ? 1 : 0; const stepsToRoyal: BigDecimal = bd.divide( bd.fromBigInt(royal[axis] - pieceCoords[axis]), bd.fromBigInt(direction[axis]), ); const stepsToAttacker: BigDecimal = bd.divide( bd.fromBigInt(attacker[axis] - pieceCoords[axis]), bd.fromBigInt(direction[axis]), ); // Both endpoints are excluded; captures are handled as individual moves. // `floor(min) + 1` and `ceil(max) - 1` give correct integer bounds even when step counts are fractional (e.g. direction [2,0]). const zoneMin = bd.toBigInt(bd.floor(bd.min(stepsToRoyal, stepsToAttacker))) + 1n; const zoneMax = bd.toBigInt(bd.ceil(bd.max(stepsToRoyal, stepsToAttacker))) - 1n; if (zoneMin > zoneMax) { delete sliding[slideDir]; // Zone is empty. // console.log('Deleting slide: No squares between the royal and the attacker.'); return; } const currentLimits = sliding[slideDir]!; // console.log( // `For slide ${slideDir}, intersecting current limits [${currentLimits[0]}, ${currentLimits[1]}] with blocking zone between royal ${royal} and attacker ${attacker} at steps [${zoneMin}, ${zoneMax}]`, // ); const newMin = currentLimits[0] === null ? zoneMin : bimath.max(currentLimits[0], zoneMin); const newMax = currentLimits[1] === null ? zoneMax : bimath.min(currentLimits[1], zoneMax); if (newMin > newMax) { delete sliding[slideDir]; // Slide can't reach the zone. // console.log("Deleting slide because it can't reach the blocking zone."); return; } sliding[slideDir] = [newMin, newMax]; // console.log( // `Narrowing slide to steps [${newMin}, ${newMax}] to only include the blocking zone.`, // ); if (colinear) moves.brute = true; } /** * Appends legal blocking moves to the provided moves object if the piece * is able to get between squares 1 & 2. * Should NOT be called if the piece with the legal moves is a royal piece. * * If colinears are present and the piece is on the same line as the line between * the attacker and the royal, sliding moves may be deleted. * @param gamefile * @param square1 - `[x,y]` * @param square2 - `[x,y]` * @param moves - The legal moves object of the piece selected, to see if it is able to block between squares 1 & 2 * @param coords - The coordinates of the piece with the provided legal moves: `[x,y]` * @param attackerColinear - Whether the attacker piece giving check is a more complicated colinear mover (huygen). */ function appendBlockingMoves( square1: Coords, square2: Coords, moves: LegalMoves, coords: Coords, attackerColinear: boolean, ): void { /** The minimum bounding box that contains our 2 squares, at opposite corners. */ const box: BoundingBox = { left: bimath.min(square1[0], square2[0]), right: bimath.max(square1[0], square2[0]), top: bimath.max(square1[1], square2[1]), bottom: bimath.min(square1[1], square2[1]), }; for (const lineKey in moves.sliding) { // 'dx,dy' const line = coordutil.getCoordsFromKey(lineKey as Vec2Key); // [dx,dy] const line1GeneralForm = vectors.getLineGeneralFormFromCoordsAndVec(coords, line); const line2GeneralForm = vectors.getLineGeneralFormFrom2Coords(square1, square2); const blockPoint = geometry.calcIntersectionPointOfLines( ...line1GeneralForm, ...line2GeneralForm, ); // The intersection point of the 2 lines. const coincident = vectors.areLinesInGeneralFormEqual(line1GeneralForm, line2GeneralForm); if (blockPoint === undefined && !coincident) { // Case 1: Parallel, but not coincident -> no intersection point. delete moves.sliding[lineKey as Vec2Key]; // Collapse the slide. } else if (blockPoint) { // Case 2: Not parallel, and has a single intersection point. if (!bdcoords.areCoordsIntegers(blockPoint)) { // It doesn't intersect at a whole number, impossible for our piece to move here! delete moves.sliding[lineKey as Vec2Key]; // Collapse the slide. continue; } const blockPointInt = bdcoords.coordsToBigInt(blockPoint); // Zero precision loss since we're already confident they are integers. if ( !bounds.boxContainsSquare(box, blockPointInt) || // Intersection point not between our 2 points, but outside of them. coordutil.areCoordsEqual(blockPointInt, square1) || // Can't move onto our piece that's in check, coordutil.areCoordsEqual(blockPointInt, square2) || // nor to the piece that is checking us (those are considered outside this method) // Does our piece's slide range include that block point? The slide must be intact to test this correctly, so we can't collapse it before this. !legalmoves.doSlideRangesContainSquare(moves, coords, blockPointInt) ) { delete moves.sliding[lineKey as Vec2Key]; // Collapse the slide. continue; } // Can block! delete moves.sliding[lineKey as Vec2Key]; // Collapse the slide (can do this now because doSlideRangesContainSquare() was already called, which needed the slide to be intact). // Add as an individual move to be simulated later. appendMoveToIndividualsAvoidDuplicates(moves.individual, blockPointInt); } else { // Case 3: Coincident (Our piece is on the same line as the check) // -> Restrict the slide to the blocking zone (strictly between the royal and checker), // and add the `brute` flag if the check is colinear. // DON'T collapse the slide. // console.log('Entered coincident blocking case.'); // prettier-ignore restrictSlideBetweenSquares(moves, lineKey as Vec2Key, line, coords, square1, square2, attackerColinear); } } } /** * Takes a `path` special flag of a checking attacker piece, and appends any legal individual * blocking moves our selected piece can land on. * Should NOT be called if the piece with the legal moves is a royal piece. * @param gamefile * @param path - Individual move's `path` special move flag, with guaranteed at least 3 waypoints within it. * @param legalMoves - The precalculated legal moves of the selected piece * @param selectedPieceCoords */ function appendPathBlockingMoves( path: MoveSpecialTags['path'], legalMoves: LegalMoves, selectedPieceCoords: Coords, ): void { /** * How do we tell if our selected piece can block an individual move with a path (Rose piece)? * * Whether it can move to any of the waypoints in the path (exluding start and end waypoints). * The reason we exclude the start waypoint is because we already check earlier * if it's legal to capure the attacker. */ for (let i = 1; i < path.length - 1; i++) { // Iterate through all path points, EXCLUDING start and end. const blockPoint = path[i]!; // Can our selected piece move to this square? if (legalmoves.doSlideRangesContainSquare(legalMoves, selectedPieceCoords, blockPoint)) appendMoveToIndividualsAvoidDuplicates(legalMoves.individual, blockPoint); // Can block! } legalMoves.sliding = {}; // Collapse all sliding moves } /** Appends the provided move to the list of legal individual moves if it's not already present. */ function appendMoveToIndividualsAvoidDuplicates(individuals: CoordsTagged[], move: Coords): void { if (!individuals.some((im: CoordsTagged) => coordutil.areCoordsEqual(im, move))) { individuals.push(move); } } /** * Sorts checks by `path` first (guaranteed non-arbitrary interpose squares), * then non-colinear sliding checks (to avoid adding the `brute` flag whenever possible), * then colinear sliding checks last. * Mutating. Sorts in place. */ function sortChecks(checks: CheckInfo[]): CheckInfo[] { return checks.sort((a, b) => { const rank = (c: CheckInfo): number => { if (!c.slidingCheck) return 0; // path check if (!c.colinear) return 1; // non-colinear sliding check return 2; // colinear sliding check }; return rank(a) - rank(b); }); } /** * Simulates moving the piece to the destination coords, * then tests if it results in the player who owns the piece being in check. * @param gamefile * @param piece - The piece moving to the destination coords * @param destCoords - The coords to move the piece to, with any attached special tags to execute with the move. * @param color - The color of the player the piece belongs to. * @returns Whether the move would result in the player owning the piece being in check. */ function isMoveCheckInvalid( gamefile: FullGame, piece: Piece, destCoords: CoordsTagged, color: Player, ): boolean { // pieceSelected: { type, index, coords } const moveTagged: MoveTagged = { startCoords: jsutil.deepCopyObject(piece.coords), endCoords: moveutil.stripSpecialMoveTagsFromCoords(destCoords), }; specialdetect.transferSpecialTags_FromCoordsToMove(destCoords, moveTagged); return getSimulatedCheck(gamefile, moveTagged, color).check; } /** * Simulates a move to get the check * @returns false if the move does not result in check, otherwise a list of the coords of all the royals in check. */ function getSimulatedCheck( gamefile: FullGame, moveTagged: MoveTagged, colorToTestInCheck: Player, ): ReturnType { return movepiece.simulateMoveWrapper(gamefile, moveTagged, () => checkdetection.detectCheck(gamefile, colorToTestInCheck), ); } // Exports -------------------------------------------------------------------------------- export default { removeCheckInvalidMoves, isMoveCheckInvalid, getSimulatedCheck, }; ================================================ FILE: src/shared/chess/logic/clock.ts ================================================ // src/shared/chess/logic/clock.ts /** * This script keeps track of both players timer, * updates them each frame, * and the update() method will return the loser * if somebody loses on time. */ import type { Player } from '../util/typeutil.js'; import type { PlayerGroup } from '../util/typeutil.js'; import type { ClockDependant, Game } from './gamefile.js'; import type { ClockValues, TimeControl } from '../../types.js'; import typeutil from '../util/typeutil.js'; import moveutil from '../util/moveutil.js'; import timeutil from '../../util/timeutil.js'; import clockutil from '../util/clockutil.js'; import gamefileutility from '../util/gamefileutility.js'; // Types -------------------------------------------------------------------------- export type ClockData = { /** The time each player has remaining, in milliseconds.*/ currentTime: PlayerGroup; /** Contains information about the start time of the game. */ startTime: { /** The number of minutes both sides started with. */ minutes: number; /** The number of miliseconds both sides started with. */ millis: number; /** The increment used, in milliseconds. */ increment: number; }; } & ( | { /** We need this separate from gamefile's "whosTurn", because when we are * in an online game and we make a move, we want our Clock to continue * ticking until we receive the Clock information back from the server!*/ colorTicking: Player; /** The amount of time in millis the current player had at the beginning of their turn, in milliseconds. * When set to undefined no clocks are ticking*/ timeRemainAtTurnStart: number; /** The time at the beginning of the current player's turn, in milliseconds elapsed since the Unix epoch.*/ timeAtTurnStart: number; } | { /** We need this separate from gamefile's "whosTurn", because when we are * in an online game and we make a move, we want our Clock to continue * ticking until we receive the Clock information back from the server!*/ colorTicking: undefined; /** The amount of time in millis the current player had at the beginning of their turn, in milliseconds. * When set to undefined no clocks are ticking*/ timeRemainAtTurnStart: undefined; /** The time at the beginning of the current player's turn, in milliseconds elapsed since the Unix epoch.*/ timeAtTurnStart: undefined; } ); // Functions ----------------------------------------------------------------------- /** * Sets the clocks. If no current clock values are specified, clocks will * be set to the starting values, according to the game's TimeControl metadata. */ function init(players: Iterable, time_control: TimeControl): ClockDependant { const untimed = clockutil.isClockValueInfinite(time_control); if (untimed) return { untimed: true, clocks: undefined }; const clockPartsSplit = clockutil.getMinutesAndIncrementFromClock(time_control)!; // { minutes, increment } const clocks: ClockData = { startTime: { minutes: clockPartsSplit.minutes, millis: timeutil.minutesToMillis(clockPartsSplit.minutes), increment: clockPartsSplit.increment, }, currentTime: {}, colorTicking: undefined, timeAtTurnStart: undefined, timeRemainAtTurnStart: undefined, }; // start both players with the default. for (const color of players) { clocks.currentTime[color] = clocks.startTime.millis; } return { untimed: false, clocks }; } /** * Updates the gamefile with new clock information received from the server. * @param basegame - The game to update the clocks of. * @param clockValues - The new clock values to set. */ function edit(currentClocks: ClockData, clockValues: ClockValues): void { const colorTicking = clockValues.colorTicking; const now = Date.now(); if (colorTicking !== undefined) { // Adjust the clock value according to the precalculated time they will lost by timeout. if (clockValues.timeColorTickingLosesAt === undefined) throw Error( 'clockValues should have been modified to account for ping BEFORE editing the clocks. Use adjustClockValuesForPing() beore edit()', ); const colorTickingTrueTimeRemaining = clockValues.timeColorTickingLosesAt - now; clockValues.clocks[colorTicking] = colorTickingTrueTimeRemaining; } currentClocks.colorTicking = colorTicking; currentClocks.currentTime = { ...clockValues.clocks }; if (colorTicking !== undefined) { currentClocks.timeAtTurnStart = now; currentClocks.timeRemainAtTurnStart = currentClocks.currentTime[colorTicking]; } } /** * Call after flipping whosTurn. Flips colorTicking in local games. * @returns The time in milliseconds the player who just moved has remaining, if the clocks are ticking. */ function push(basegame: Game, clocks: ClockData): number | undefined { const prevcolor = moveutil.getWhosTurnAtMoveIndex(basegame, basegame.moves.length - 2); if (!moveutil.isGameResignable(basegame)) return clocks.currentTime[prevcolor]!; // Add increment to the previous player's clock and capture their remaining time to later insert into move. if (clocks.timeAtTurnStart !== undefined) { // Update current values const timePassedSinceTurnStart = Date.now() - clocks.timeAtTurnStart; clocks.currentTime[clocks.colorTicking] = clocks.timeRemainAtTurnStart - timePassedSinceTurnStart; // 3+ moves clocks.currentTime[prevcolor]! += timeutil.secondsToMillis(clocks.startTime.increment!); } // Set up clocksticking for the new turn. clocks.colorTicking = basegame.whosTurn; clocks.timeRemainAtTurnStart = clocks.currentTime[clocks.colorTicking]!; clocks.timeAtTurnStart = Date.now(); return clocks.currentTime[prevcolor]; } function stop(basegame: Game): void { if (basegame.untimed) return; const clocks = basegame.clocks; if (clocks.colorTicking === undefined) return; // Clocks already stopped const timeSpent = Date.now() - clocks.timeAtTurnStart!; let newTime = clocks.timeRemainAtTurnStart! - timeSpent; if (newTime < 0) newTime = 0; clocks.currentTime[clocks.colorTicking]! = newTime; endGame(basegame); } function endGame(basegame: Game): void { if (basegame.untimed) return; const clocks = basegame.clocks; delete clocks.timeRemainAtTurnStart; delete clocks.timeAtTurnStart; delete clocks.colorTicking; } /** * Called every frame, updates values. * @param basegame * @returns undefined if clocks still have time, otherwise it's the color who won. */ function update(basegame: Game): Player | undefined { if ( basegame.untimed || gamefileutility.isGameOver(basegame) || !moveutil.isGameResignable(basegame) ) return; const clocks = basegame.clocks; if (clocks.timeAtTurnStart === undefined) return; // Update current values const timePassedSinceTurnStart = Date.now() - clocks.timeAtTurnStart; clocks.currentTime[clocks.colorTicking] = Math.ceil( clocks.timeRemainAtTurnStart - timePassedSinceTurnStart, ); for (const [playerStr, time] of Object.entries(clocks.currentTime)) { const player: Player = Number(playerStr) as Player; if ((time as number) <= 0) { clocks.currentTime[player] = 0; return typeutil.invertPlayer(player); // The color who won on time } } return; // Without this, typescript complains not all code paths return a value. } /** * Returns the true time remaining for the player whos clock is ticking. * Independant of reading clocks.currentTime, because that isn't updated * every frame if the user unfocuses the window. */ function getColorTickingTrueTimeRemaining(clocks: ClockData): number | undefined { if (clocks.colorTicking === undefined) return; const timeElapsedSinceTurnStartMillis = Date.now() - clocks.timeAtTurnStart; return clocks.timeRemainAtTurnStart - timeElapsedSinceTurnStartMillis; } function printClocks(basegame: Game): void { if (basegame.untimed) return console.log('Game is untimed.'); const clocks = basegame.clocks!; for (const color in clocks.currentTime) { console.log(`${color} time: ${clocks.currentTime[Number(color) as Player]}`); } console.log(`timeRemainAtTurnStart: ${clocks.timeRemainAtTurnStart}`); console.log(`timeAtTurnStart: ${clocks.timeAtTurnStart}`); } function createEdit(clocks: ClockData): ClockValues { const tickingData: Omit = {}; if (clocks.colorTicking !== undefined) { tickingData.colorTicking = clocks.colorTicking; tickingData.timeColorTickingLosesAt = clocks.timeAtTurnStart + clocks.timeRemainAtTurnStart; } return { clocks: clocks.currentTime, ...tickingData, }; } export default { init, createEdit, edit, stop, endGame, update, push, getColorTickingTrueTimeRemaining, printClocks, }; ================================================ FILE: src/shared/chess/logic/fourdimensionalmoves.ts ================================================ // src/shared/chess/logic/fourdimensionalmoves.ts /** * This script contains overrides for calculating the legal moves * of pieces in four dimensional variants, and for executing those moves. * * Pieces cannot jump to other timelike boards using spacelike movements, * nor can they jump out of bounds. */ import type { Piece } from '../util/boardutil.js'; import type { Coords } from '../util/coordutil.js'; import type { Player } from '../util/typeutil.js'; import type { MoveRunning } from './specialmove.js'; import type { CoordsTagged } from './movepiece.js'; import type { UnboundedRectangle } from '../../util/math/bounds.js'; import type { Game, Board, FullGame } from './gamefile.js'; import state from './state.js'; import bimath from '../../util/math/bimath.js'; import typeutil from '../util/typeutil.js'; import coordutil from '../util/coordutil.js'; import boardutil from '../util/boardutil.js'; import legalmoves from './legalmoves.js'; import boardchanges from './boardchanges.js'; import specialdetect from './specialdetect.js'; import { players as p } from '../util/typeutil.js'; import fourdimensionalgenerator from '../variants/fourdimensionalgenerator.js'; // Pawn Legal Move Calculation and Execution ----------------------------------------------------------------- /** Calculates the legal pawn moves in the four dimensional variant. */ function fourDimensionalPawnMove( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): CoordsTagged[] { const legalMoves: CoordsTagged[] = []; legalMoves.push(...pawnLegalMoves(gamefile, coords, color, 'spacelike', premove)); // Spacelike legalMoves.push(...pawnLegalMoves(gamefile, coords, color, 'timelike', premove)); // Timelike return legalMoves; } /** * Calculates legal pawn moves for either the spacelike or timelike dimensions. * @param gamefile * @param coords - The coordinates of the pawn * @param color - The color of the pawn * @param movetype - spacelike move or timelike move */ function pawnLegalMoves( gamefile: FullGame, coords: Coords, color: Player, movetype: 'spacelike' | 'timelike', premove: boolean, ): CoordsTagged[] { const { basegame, boardsim } = gamefile; const dim = fourdimensionalgenerator.get4DBoardDimensions(); const distance = movetype === 'spacelike' ? 1n : dim.BOARD_SPACING; const distance_complement = movetype === 'spacelike' ? dim.BOARD_SPACING : 1n; // White and black pawns move and capture in opposite directions. const yDistanceParity = color === p.WHITE ? distance : -distance; const individualMoves: CoordsTagged[] = []; // How do we go about calculating a pawn's legal moves? // 1. It can move forward if there is no piece there // Is there a piece in front of it? And do not allow pawn to leave the 4D board const singlePushCoord: CoordsTagged = [coords[0], coords[1] + yDistanceParity]; let moveValidity = legalmoves.testSquareValidity( boardsim, basegame.gameRules.worldBorder, singlePushCoord, color, premove, false, ); if ( moveValidity === 0 && // Pawns forward-motion validity check must be 0, as they can't capture forward. singlePushCoord[0] > dim.MIN_X && singlePushCoord[0] < dim.MAX_X && singlePushCoord[1] > dim.MIN_Y && singlePushCoord[1] < dim.MAX_Y // Pawn within boundaries ) { appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, singlePushCoord, color); // No piece, add the move // Is the double push legal? const doublePushCoord: CoordsTagged = [ singlePushCoord[0], singlePushCoord[1] + yDistanceParity, ]; moveValidity = legalmoves.testSquareValidity( boardsim, basegame.gameRules.worldBorder, doublePushCoord, color, premove, false, ); if ( doesPieceHaveSpecialRight(boardsim, coords) && moveValidity === 0 && doublePushCoord[0] > dim.MIN_X && doublePushCoord[0] < dim.MAX_X && doublePushCoord[1] > dim.MIN_Y && doublePushCoord[1] < dim.MAX_Y ) { // Add the double push! doublePushCoord.enpassantCreate = specialdetect.getEnPassantGamefileProperty( coords, doublePushCoord, ); appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, doublePushCoord, color); // Add the double push! } } // 2. It can capture diagonally if there are opponent pieces there const strong_pawns = fourdimensionalgenerator.getMovementType().STRONG_PAWNS; const coordsToCapture: CoordsTagged[] = [ [coords[0] - distance, coords[1] + yDistanceParity], [coords[0] + distance, coords[1] + yDistanceParity], ]; if (strong_pawns) coordsToCapture.push( // Add the brawn-like captures [coords[0] - distance_complement, coords[1] + yDistanceParity], [coords[0] + distance_complement, coords[1] + yDistanceParity], ); for (const captureCoords of coordsToCapture) { const moveValidity = legalmoves.testSquareValidity( boardsim, basegame.gameRules.worldBorder, captureCoords, color, premove, true, ); // true for capture is required if (moveValidity <= 1) appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, captureCoords, color); // Good to add the capture! } // 3. It can capture en passant if a pawn next to it just pushed twice. if (!premove) { // Only add if we're not premoving, since premove captures are added above addPossibleEnPassant(gamefile, individualMoves, coords, color, distance, distance); if (strong_pawns) addPossibleEnPassant( gamefile, individualMoves, coords, color, distance_complement, distance, ); } return individualMoves; } /** * Adds the en passant capture to the list of individual moves if it is possible. * @param gamefile * @param individualMoves - The list of individual moves to add the en passant capture to * @param coords - The coordinates of the pawn * @param color - The color of the pawn * @param xdistance * @param ydistance */ function addPossibleEnPassant( { basegame, boardsim }: FullGame, individualMoves: CoordsTagged[], coords: Coords, color: Player, xdistance: bigint, ydistance: bigint, ): void { if (!boardsim.state.global.enpassant) return; // No enpassant flag on the game, no enpassant possible if (color !== basegame.whosTurn) return; // Not our turn (the only color who can legally capture enpassant is whos turn it is). If it IS our turn, this also guarantees the captured pawn will be an enemy pawn. const enpassantCapturedPawnType = boardutil.getTypeFromCoords( boardsim.pieces, boardsim.state.global.enpassant.pawn, )!; if (typeutil.getColorFromType(enpassantCapturedPawnType) === color) return; // The captured pawn is not an enemy pawn. THIS IS ONLY EVER NEEDED if we can move opponent pieces on our turn, which is the case in EDIT MODE. const xDifference = boardsim.state.global.enpassant.square[0] - coords[0]; if (bimath.abs(xDifference) !== xdistance) return; // Not immediately left or right of us // prettier-ignore const yDistanceParity = color === p.WHITE ? ydistance : color === p.BLACK ? -ydistance : (() => { throw new Error("Invalid color!"); })(); if (coords[1] + yDistanceParity !== boardsim.state.global.enpassant.square[1]) return; // Not one in front of us // It is capturable en passant! /** The square the pawn lands on. */ const enPassantSquare: CoordsTagged = coordutil.copyCoords( boardsim.state.global.enpassant.square, ); // TAG THIS MOVE as an en passant capture!! gamefile looks for this tag // on the individual move to detect en passant captures and to know what piece to delete enPassantSquare.enpassant = true; appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, enPassantSquare, color); } /** * Appends the provided move to the running individual moves list, * and adds the `promoteTrigger` special flag to it if it landed on a promotion rank. */ function appendPawnMoveAndAttachPromoteTag( basegame: Game, individualMoves: CoordsTagged[], landCoords: CoordsTagged, color: Player, ): void { if (basegame.gameRules.promotionRanks !== undefined) { const teamPromotionRanks = basegame.gameRules.promotionRanks[color]; if (teamPromotionRanks?.includes(landCoords[1])) landCoords.promoteTrigger = true; } individualMoves.push(landCoords); } function doesPieceHaveSpecialRight(boardsim: Board, coords: Coords): boolean { const key = coordutil.getKeyFromCoords(coords); return boardsim.state.global.specialRights.has(key); } /** Executes a four dimensional pawn move. */ function doFourDimensionalPawnMove(boardsim: Board, piece: Piece, move: MoveRunning): boolean { const moveChanges = move.changes; // If it was a double push, then queue adding the new enpassant square to the gamefile! if (move.enpassantCreate !== undefined) state.createEnPassantState(move, boardsim.state.global.enpassant, move.enpassantCreate); if (!move.enpassant && move.promotion === undefined) return false; // No special move to execute, return false to signify we didn't move the piece. const captureCoords = move.enpassant ? boardsim.state.global.enpassant!.pawn : move.endCoords; const capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, captureCoords); if (capturedPiece) boardchanges.queueCapture(moveChanges, true, capturedPiece); // Delete the piece captured boardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords); // Move the pawn if (move.promotion !== undefined) { // Handle promotion special move boardchanges.queueDeletePiece(moveChanges, true, { type: piece.type, coords: move.endCoords, index: piece.index, }); // Delete original pawn boardchanges.queueAddPiece(moveChanges, { type: move.promotion, coords: move.endCoords, index: -1, }); // Add promoted piece } return true; // Special move was executed! } // Knight Legal Move Calculation -------------------------------------------------------------------------------- /** * Calculates the legal knight moves in the current four dimensional variant * for both spacelike and timelike dimensions. * @param gamefile * @param coords - The coordinates of the knight * @param color - The color of the knight */ function fourDimensionalKnightMove( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): Coords[] { const individualMoves: Coords[] = []; const dim = fourdimensionalgenerator.get4DBoardDimensions(); for (let baseH = 2n; baseH >= -2n; baseH--) { for (let baseV = 2n; baseV >= -2n; baseV--) { for (let offsetH = 2n; offsetH >= -2n; offsetH--) { for (let offsetV = 2n; offsetV >= -2n; offsetV--) { // If the squared distance to the tile is 5, then add the move if ( baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV === 5n ) { const x = coords[0] + dim.BOARD_SPACING * baseH + offsetH; const y = coords[1] + dim.BOARD_SPACING * baseV + offsetV; const endCoords: Coords = [x, y]; // Don't allow the move if it's blocked by a friendly piece or void if ( legalmoves.testSquareValidity( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, endCoords, color, premove, false, ) === 2 ) continue; // do not allow knight to leave the 4D board if ( endCoords[0] <= dim.MIN_X || endCoords[0] >= dim.MAX_X || endCoords[1] <= dim.MIN_Y || endCoords[1] >= dim.MAX_Y ) continue; // do not allow the knight to make move if (baseH, baseV) do not match change in 2D chessboard if ( (endCoords[0] - dim.MIN_X) / dim.BOARD_SPACING - (coords[0] - dim.MIN_X) / dim.BOARD_SPACING !== baseH || (endCoords[1] - dim.MIN_Y) / dim.BOARD_SPACING - (coords[1] - dim.MIN_Y) / dim.BOARD_SPACING !== baseV ) continue; individualMoves.push(endCoords); } } } } } return individualMoves; } // King Legal Move Calculation ------------------------------------------------------------------------------ /** Calculates the legal king moves in the four dimensional variant. */ function fourDimensionalKingMove( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): Coords[] { const legalMoves: Coords[] = kingLegalMoves( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, coords, color, premove, ); legalMoves.push(...specialdetect.kings(gamefile, coords, color, premove)); // Adds legal castling return legalMoves; } /** * Calculates legal king moves for either the spacelike and timelike dimensions. * @param gamefile * @param coords - The coordinates of the king * @param color - The color of the king */ function kingLegalMoves( boardsim: Board, worldBorder: UnboundedRectangle | undefined, coords: Coords, color: Player, premove: boolean, ): Coords[] { const individualMoves: Coords[] = []; const dim = fourdimensionalgenerator.get4DBoardDimensions(); for (let baseH = 1n; baseH >= -1n; baseH--) { for (let baseV = 1n; baseV >= -1n; baseV--) { for (let offsetH = 1n; offsetH >= -1n; offsetH--) { for (let offsetV = 1n; offsetV >= -1n; offsetV--) { // only allow moves that change one or two dimensions if triagonals and diagonals are disabled if ( !fourdimensionalgenerator.getMovementType().STRONG_KINGS_AND_QUEENS && baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV > 2 ) continue; if (baseH === 0n && baseV === 0n && offsetH === 0n && offsetV === 0n) continue; const x = coords[0] + dim.BOARD_SPACING * baseH + offsetH; const y = coords[1] + dim.BOARD_SPACING * baseV + offsetV; const endCoords: Coords = [x, y]; // Do not allow the move if it's blocked by a friendly piece or void if ( legalmoves.testSquareValidity( boardsim, worldBorder, endCoords, color, premove, false, ) === 2 ) continue; // do not allow king to leave the 4D board if ( endCoords[0] <= dim.MIN_X || endCoords[0] >= dim.MAX_X || endCoords[1] <= dim.MIN_Y || endCoords[1] >= dim.MAX_Y ) continue; individualMoves.push(endCoords); } } } } return individualMoves; } // Exports --------------------------------------------------------------------- export default { fourDimensionalPawnMove, doFourDimensionalPawnMove, fourDimensionalKnightMove, fourDimensionalKingMove, }; ================================================ FILE: src/shared/chess/logic/gamefile.ts ================================================ // src/shared/chess/logic/gamefile.ts import type { CoordsKey } from '../util/coordutil.js'; import type { GameRules } from '../util/gamerules.js'; import type { ClockData } from './clock.js'; import type { MovePacket } from '../../types.js'; import type { BoundingBox } from '../../util/math/bounds.js'; import type { VariantCode } from '../variants/variantdictionary.js'; import type { PieceMoveset } from './movesets.js'; import type { GameConclusion } from '../util/winconutil.js'; import type { VariantOptions } from './initvariant.js'; import type { OrganizedPieces } from './organizedpieces.js'; import type { SpecialMoveFunction } from './specialmove.js'; import type { MoveFull, MoveRecord } from './movepiece.js'; import type { ClockValues, MetaData } from '../../types.js'; import type { GameState, GlobalGameState } from './state.js'; import type { Player, RawType, RawTypeGroup } from '../util/typeutil.js'; import clock from './clock.js'; import jsutil from '../../util/jsutil.js'; import variant from '../variants/variant.js'; import typeutil from '../util/typeutil.js'; import boardutil from '../util/boardutil.js'; import movepiece from './movepiece.js'; import gamerules from '../util/gamerules.js'; import legalmoves from './legalmoves.js'; import initvariant from './initvariant.js'; import wincondition from './wincondition.js'; import checkdetection from './checkdetection.js'; import organizedpieces from './organizedpieces.js'; import gamefileutility from '../util/gamefileutility.js'; interface Snapshot { /** In key format 'x,y':'type' */ position: Map; /** The global state of the game beginning */ state_global: GlobalGameState; /** This is the full-move number at the start of the game. Used for converting to ICN notation. */ fullMove: number; /** The bounding box surrounding the starting position, without padding. INTEGER coords, not floating. */ box: BoundingBox; } /** * Purely game data * Used on both sides */ type Game = { /** Information about the game */ metadata: MetaData; /** The game's start timestamp in milliseconds since epoch, derived from UTCDate/UTCTime metadata. */ dateTimestamp: number; moves: MoveRecord[]; gameRules: GameRules; whosTurn: Player; gameConclusion?: GameConclusion; } & ClockDependant; /** * The Game variables that depend on the clock. */ type ClockDependant = | { untimed: true; clocks: undefined; } | { untimed: false; clocks: ClockData; }; /** * Game data used for simulating game logic and board state * Use by client always, may not be used by the server. */ type Board = { /** An array of all types of pieces that are in this game, without their color extension: `['pawns','queens']` */ existingTypes: number[]; /** An array of all RAW piece types that are in this game. */ existingRawTypes: RawType[]; moves: MoveFull[]; pieces: OrganizedPieces; state: GameState; pieceMovesets: RawTypeGroup<() => PieceMoveset>; specialMoves: RawTypeGroup; specialVicinity: Record; vicinity: Record; /** Whether the gamefile is for the board editor. If true, the piece list will contain MUCH more undefined placeholders, and for every single type of piece, as pieces are added commonly in that! */ editor: boolean; /** * The variant code. Null for custom/pasted positions without a known variant. * Is used to infer variant-specific game rules, such as piece movesets. */ variant: VariantCode | null; /** * Information about the beginning snapshot of the game (position, positionString, specialRights, turn) */ startSnapshot: Snapshot; }; /** * Both game data AND board state used on the client-side, * and in the future *sometimes* used on the server-side, * when the server starts doing legal move validation. */ type FullGame = { basegame: Game; boardsim: Board; }; /** Additional options that may go into the gamefile constructor. * Typically used if we're pasting a game, or reloading an online one. */ interface Additional { /** Existing moves, if any, to forward to the front of the game. Should be specified if reconnecting to an online game or pasting a game. */ moves?: MovePacket[]; /** If a custom position is needed, for instance, when pasting a game, then these options should be included. */ variantOptions?: VariantOptions; /** The conclusion of the game, if loading an online game that has already ended. */ gameConclusion?: GameConclusion; /** Any already existing clock values for the gamefile. */ clockValues?: ClockValues; /** Whether the gamefile is for the board editor. If true, the piece list will contain MUCH more undefined placeholders, and for every single type of piece, as pieces are added commonly in that! */ editor?: boolean; /** If present, the resulting gamefile will have a world border this distance away from the starting position's bounding box. */ worldBorderDist?: bigint; /** Exact dimensions of the world border. OVERRIDES {@link worldBorderDist} if both are specified. */ worldBorder?: BoundingBox; } /** Creates a new {@link Game} object from provided arguments */ function initGame( metadata: MetaData, dateTimestamp: number, variantCode: VariantCode | null, gameConclusion?: GameConclusion, clockValues?: ClockValues, variantOptions?: VariantOptions, ): Game { const gameRules = initvariant.getVariantGamerules(variantCode, dateTimestamp, variantOptions); const clockDependantVars: ClockDependant = clock.init( new Set(gameRules.turnOrder), metadata.TimeControl ?? '-', // Fallback to untimed if TimeControl metadata not specified ); const game: Game = { metadata, dateTimestamp, moves: [], gameRules, whosTurn: gameRules.turnOrder[0]!, ...clockDependantVars, }; if (clockValues) { if (game.untimed) throw Error( 'Cannot set clock values for untimed game. Should not have specified clockValues.', ); clock.edit(game.clocks, clockValues); } gamefileutility.setConclusion(game, gameConclusion); return game; } /** Creates a new {@link Board} object from provided arguments */ function initBoard( gameRules: GameRules, variantCode: VariantCode | null, dateTimestamp: number, variantOptions?: VariantOptions, editor: boolean = false, /** Only has an effect if the `worldBorder` gamerule is not present. */ worldBorderDist?: bigint, ): Board { const { position, state_global, fullMove } = initvariant.getVariantVariantOptions( gameRules, variantCode, dateTimestamp, variantOptions, ); const state: GameState = { local: { moveIndex: -1, inCheck: false, checks: [], }, global: jsutil.deepCopyObject(state_global), }; const { pieceMovesets, specialMoves } = initvariant.getPieceMovesets( variantCode, dateTimestamp, gameRules.slideLimit, ); const { pieces, existingTypes, existingRawTypes } = organizedpieces.processInitialPosition( position, pieceMovesets, gameRules.turnOrder, editor, gameRules.promotionsAllowed, ); typeutil.deleteUnusedFromRawTypeGroup(existingRawTypes, specialMoves); let startingPositionBox = boardutil.getBoundingBoxOfAllPieces(pieces); // Fallback if no pieces present if (startingPositionBox === undefined) startingPositionBox = { left: 1n, right: 8n, bottom: 1n, top: 8n }; // worldBorder: Receives the smaller of the two, if either the variant property or the override are defined. let worldBorderProperty: bigint | undefined = variant.getVariantWorldBorder(variantCode); if (worldBorderDist !== undefined) { if (worldBorderProperty === undefined) worldBorderProperty = worldBorderDist; // Use the provided world border if the variant doesn't have one. else if (worldBorderDist < worldBorderProperty) worldBorderProperty = worldBorderDist; // Use the smaller of the two if both exist. } if (gameRules.worldBorder === undefined && worldBorderProperty !== undefined) { // No override for exact world border dimensions provided, calculate it using the provided distance. gameRules.worldBorder = { left: startingPositionBox.left - worldBorderProperty, right: startingPositionBox.right + worldBorderProperty, bottom: startingPositionBox.bottom - worldBorderProperty, top: startingPositionBox.top + worldBorderProperty, }; } const startSnapshot: Snapshot = { position, state_global, fullMove, box: startingPositionBox, }; const vicinity = legalmoves.genVicinity(pieceMovesets); const specialVicinity = legalmoves.genSpecialVicinity( variantCode, dateTimestamp, existingRawTypes, ); const moves: MoveFull[] = []; return { pieces, existingTypes, existingRawTypes, state, moves, vicinity, specialVicinity, pieceMovesets, specialMoves, editor, variant: variantCode, startSnapshot, }; } /** * Attaches a board to a specific game. Used for loading a game after it was started. * @param validateMoves - During game construction, throws an error if any move played is illegal. */ function loadGameWithBoard( basegame: Game, boardsim: Board, moves: MovePacket[] = [], validateMoves?: boolean, ): FullGame { const gamefile = { basegame, boardsim }; // Do we need to convert any checkmate win conditions to royalcapture? if (!wincondition.isCheckmateCompatibleWithGame(gamefile)) gamerules.swapCheckmateForRoyalCapture(basegame.gameRules); { // Set the game's `inCheck` and `checks` properties at the front of the game. const trackChecks = gamefileutility.isOpponentUsingWinCondition( basegame, basegame.whosTurn, 'checkmate', ); const checkResults = checkdetection.detectCheck(gamefile, basegame.whosTurn, trackChecks); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] } boardsim.state.local.inCheck = checkResults.check ? checkResults.royalsInCheck : false; if (trackChecks) boardsim.state.local.checks = checkResults.checks ?? []; } movepiece.makeAllMovesInGame(gamefile, moves, validateMoves); // Do not overwrite pre-existing server conclusion, if present. if (basegame.gameConclusion === undefined) gamefileutility.doGameOverChecks(gamefile); return gamefile; } /** * Initiates both the base game and board of the FullGame at the same time. * Used on just the client. * @param validateMoves - During game construction, throws an error if any move played is illegal. */ function initFullGame( metadata: MetaData, dateTimestamp: number, variantCode: VariantCode | null, additional: Additional = {}, validateMoves?: true, ): FullGame { const basegame = initGame( metadata, dateTimestamp, variantCode, additional.gameConclusion, additional.clockValues, additional.variantOptions, ); const boardsim = initBoard( basegame.gameRules, variantCode, dateTimestamp, additional.variantOptions, additional.editor, additional.worldBorderDist, ); return loadGameWithBoard(basegame, boardsim, additional.moves, validateMoves); } export type { Game, Board, FullGame, Snapshot, ClockDependant, Additional }; export default { initGame, initBoard, initFullGame, }; ================================================ FILE: src/shared/chess/logic/icn/icncommentutils.ts ================================================ // src/shared/chess/logic/icn/icncommentutils.ts /** * This scripts creates and parses embeded command sequences * that go into the comments of moves in Infinite Chess Notation. * * An example of a clock embeded sequence is '[%clk 0:01:57.3]' * * More info on embeded command sequences: * https://www.enpassant.dk/chess/palview/enhancedpgn.htm */ // Types ---------------------------------------------------------------------------- /** All valid command sequences. */ const validCommands = ['clk'] as const; type Command = (typeof validCommands)[number]; /** * Represents a generic command ready to be embedded, * containing the command name and its formatted value string. */ export interface CommandObject { /** The name of the command (e.g., 'clk', 'timestamp'). */ command: Command; // Use the Command union type /** The string value associated with the command. */ value: string; } /** Defines the structure returned when extracting commands from a comment string. */ interface ExtractedCommentData { /** * The remaining comment text after all command sequences have been removed. * Leading/trailing whitespace is trimmed, and multiple spaces resulting * from command removal are collapsed into single spaces. */ comment: string; /** * A record where keys are the command names (e.g., "clk", "timestamp") * and values are the corresponding argument strings associated with those commands. */ commands: CommandObject[]; } // General Command Functions -------------------------------------------------------------------- /** * Combines a comment string and a list of command objects into a single * string suitable for a PGN comment field (without outer curly braces "{}"). * @param [comment] Optional. The human-readable comment string. Can be empty or contain only whitespace. (e.g. "Sacrifice!!!") * @param cmdObjs An array of CommandObject instances. Can be empty. * @returns A combined string with formatted commands followed by the comment (e.g. "[%clk 0:01:57.3] Sacrifice!!!"). */ function combineCommentAndCommands(cmdObjs: CommandObject[], comment?: string): string { /** All parts going into the comment, including command sequences and the human-readable comment. */ const parts: string[] = []; parts.push(...cmdObjs.map(formatCommandSequence)); if (comment && comment.trim().length > 0) parts.push(comment.trim()); return parts.join(' '); } /** * Takes a command object (containing a command name and its value) * and constructs the standard embedded command sequence string. * * Example: { command: 'clk', value: '1:23:45.6' } => "[%clk 1:23:45.6]" * Example: { command: 'timestamp', value: '1678886400' } => "[%timestamp 1678886400]" */ function formatCommandSequence(cmdObj: CommandObject): string { return `[%${cmdObj.command} ${cmdObj.value}]`; } /** * Parses a comment string (expected without the outer curly braces `{}`) * to extract embedded command sequences (like [%clk ...] or [%timestamp ...]) * and the remaining human-readable comment text. * * Command sequences may appear anywhere within the string. * * @param commentString The comment content string. * @returns An object containing the extracted commands and the cleaned comment text. */ function extractCommandsFromComment(commentString: string): ExtractedCommentData { const commands: CommandObject[] = []; const commandRegex = /\[%(\w+) ([^\]]+)\]/g; // The 'g' flag makes it find all occurrences globally. // First, extract all commands and store them. // We use matchAll for a more robust way to get all matches and capture groups. const matches = commentString.matchAll(commandRegex); for (const match of matches) { const commandName = match[1]! as Command; // e.g., "clk" const commandValue = match[2]!; // e.g., "0:09:56.7" // Only parse valid commands, simply ignore and discard all others if (validCommands.includes(commandName)) commands.push({ command: commandName, value: commandValue }); } // Second, remove all command sequences from the original string to get the raw comment. // Replace each found command sequence with an empty string. let rawComment = commentString.replace(commandRegex, ''); // Third, clean up the resulting comment string: // Replace multiple consecutive spaces (which might occur where commands were removed) with a single space. rawComment = rawComment.trim().replace(/\s{2,}/g, ' '); return { comment: rawComment, commands, }; } // Parsing 'clk' Command Sequences -------------------------------------------------------------------- /** * Takes a time in milliseconds and creates a CommandObject containing * the 'clk' command name and the time formatted as H:MM:SS.D. * The input milliseconds are rounded UP to the nearest 100ms boundary * before conversion. */ function createClkCommandObject(timeMillis: number): CommandObject { let formattedValue: string; if (typeof timeMillis !== 'number') throw Error( `Invalid typeof for timeMillis when constructing clk comment embeded command sequence: expected number, got ${typeof timeMillis}`, ); if (isNaN(timeMillis)) throw Error(`timeMillis is NaN when constructing clk comment embeded command sequence!`); // Handle edge case: if time is 0 or less, return 0 time object. if (timeMillis <= 0) { formattedValue = '0:00:00.0'; } else { // Round the total milliseconds UP to the nearest 100ms boundary. const roundedUpMillis = Math.ceil(timeMillis / 100) * 100; // Calculate H:MM:SS.D based on the rounded-up value. const totalSecondsRounded = Math.floor(roundedUpMillis / 1000); const hours = Math.floor(totalSecondsRounded / 3600); const minutes = Math.floor((totalSecondsRounded % 3600) / 60); const seconds = totalSecondsRounded % 60; // Calculate tenths based on the rounded-up milliseconds. const tenths = (roundedUpMillis % 1000) / 100; // Convert minutes and seconds to strings and pad with leading zeros if needed. const paddedMinutes = minutes.toString().padStart(2, '0'); const paddedSeconds = seconds.toString().padStart(2, '0'); // Create the formatted time value string formattedValue = `${hours}:${paddedMinutes}:${paddedSeconds}.${tenths}`; } // Return the command object conforming to CommandObject return { command: 'clk', // The specific command name for this function value: formattedValue, }; } /** * Takes a clock time string value (extracted from a %clk command) and returns * the number of milliseconds represented by that time. * @param clkValueString The time string in H:MM:SS.D format (e.g., "1:23:45.6"). * @returns The total time in milliseconds. */ function getMillisFromClkTimeValue(clkValueString: string): number { // Regular expression to match the format and capture the time components. const regex = /^(\d+):(\d{2}):(\d{2})\.(\d)$/; const match = clkValueString.match(regex); if (!match) throw new Error( `Clock time value string is not in the required H:MM:SS.D format! (${clkValueString})`, ); // Extract the captured groups. match[0] is the full string. // Groups are 1-indexed. const hoursStr = match[1]; const minutesStr = match[2]; const secondsStr = match[3]; const tenthsStr = match[4]; // Convert the captured string parts to numbers. const hours = Number(hoursStr); const minutes = Number(minutesStr); const seconds = Number(secondsStr); const tenths = Number(tenthsStr); // Calculate the total time in milliseconds. // prettier-ignore const totalMillis = (hours * 3600 * 1000) + // Hours to milliseconds (minutes * 60 * 1000) + // Minutes to milliseconds (seconds * 1000) + // Seconds to milliseconds (tenths * 100); // Tenths of a second to milliseconds return totalMillis; } // Exports ---------------------------------------------------------------------------- export default { combineCommentAndCommands, extractCommandsFromComment, createClkCommandObject, getMillisFromClkTimeValue, }; ================================================ FILE: src/shared/chess/logic/icn/icnconverter.ts ================================================ // src/shared/chess/logic/icn/icnconverter.ts /** * Universal Infinite Chess Notation [Converter] and Interface * by Naviary and Andreas Tsevas * https://github.com/tsevasa/infinite-chess-notation * * This script converts games from a JSON notation to a * compact ICN (Infinite Chess Noation) and back, * still human-readable, but taking less space to describe positions. */ import type { BaseRay } from '../../../util/math/geometry.js'; import type { MetaData } from '../../../types.js'; import type { GameRules } from '../../util/gamerules.js'; import type { UnboundedRectangle } from '../../../util/math/bounds.js'; import type { GameruleWinCondition } from '../../util/winconutil.js'; import type { EnPassant, GlobalGameState } from '../state.js'; import jsutil from '../../../util/jsutil.js'; import bimath from '../../../util/math/bimath.js'; import typeutil from '../../util/typeutil.js'; import winconutil from '../../util/winconutil.js'; import coordutil, { Coords, CoordsKey } from '../../util/coordutil.js'; import icncommentutils, { CommandObject } from './icncommentutils.js'; import { rawTypes as r, ext as e, players as p, RawType, Player, PlayerGroup, } from '../../util/typeutil.js'; // Types ------------------------------------------------------------------------------ /** Represents the game format coming IN to the converter. */ interface LongFormatIn extends LongFormatBase { metadata: MetaData; moves?: MovePreprint[]; } /** Represents the game format coming OUT of the converter. */ interface LongFormatOut extends LongFormatBase { metadata: MetaData; moves?: MoveParsed[]; } /** Shared properties between in & out game formats. */ interface LongFormatBase { /** * IN => Required if you want the position specified in the ICN. Otherwise, Variant, UTCDate, and UTCTime metadata are required. * OUT => Specified if the ICN contains the position. Otherwise, Variant metadata is required in the ICN. */ position?: Map; gameRules: GameRules; fullMove: number; /** Same rules as for {@link LongFormatBase['position']}, but for the specialRights. */ state_global: Partial; /** Overrides the variant's preset annotations, if specified. */ presetAnnotes?: PresetAnnotes; } /** The named capture groups of a shortform move. */ type NamedCaptureMoveGroups = { startCoordsKey: CoordsKey; endCoordsKey: CoordsKey; /** The piece abbreviation of the promoted piece, if present. */ promotionAbbr?: string; /** * An un-parsed comment on a move. This may contain embedded command sequences. * However it won't include the opening "{" or closing "}" braces. */ comment?: string; }; /** Input to the ICN serializer. Includes optional information for prettifying the move list. */ interface MovePreprint extends MoveParsed { /** The type of piece moved */ type?: number; flags?: { /** Whether the move delivered check. */ check: boolean; /** Whether the move delivered mate (or the killing move). */ mate: boolean; /** Whether the move caused a capture */ capture: boolean; }; } /** Output of the ICN parser. Includes information extractable from a shortform move. */ interface MoveParsed extends MoveCoords { token: string; /** * Any human-readable comment made on the move, specified in the ICN. * FUTURE: This should go back into the ICN when copying the game. */ comment?: string; /** How much time the player had left after they made their move, in millis. */ clockStamp?: number; } /** The bare minimum information needed to make a move. */ interface MoveCoords { startCoords: Coords; endCoords: Coords; /** Present if the move was a special-move promotion. This is the integer type of the promoted piece. */ promotion?: number; } /** * Permanent preset annotations. Can't be erased. * Helpful for emphasizing important lines/squares in showcasings. */ type PresetAnnotes = { /** In compacted string form: '23,94|23,76' */ squares?: Coords[]; /** In compacted string form: '23,94>-1,0|23,76>-1,0' */ rays?: BaseRay[]; }; // Dictionaries ----------------------------------------------------------------------- /** * 1-2 letter codes for each player number. * This is used for specifying the turn order in ICN. */ const player_codes = { [p.NEUTRAL]: 'n', // I dont think we need this, good to have in case [p.WHITE]: 'w', [p.BLACK]: 'b', // Colored players [p.RED]: 'r', [p.BLUE]: 'bu', [p.YELLOW]: 'y', [p.GREEN]: 'g', } as const; const player_codes_inverted = jsutil.invertObj(player_codes); type PlayerCode = (typeof player_codes)[keyof typeof player_codes]; /** 1-2 letter codes for the standard white, black, and neutral pieces. */ // prettier-ignore const piece_codes = { [r.KING + e.W]: 'K', [r.KING + e.B]: 'k', [r.PAWN + e.W]: 'P', [r.PAWN + e.B]: 'p', [r.KNIGHT + e.W]: 'N', [r.KNIGHT + e.B]: 'n', [r.BISHOP + e.W]: 'B', [r.BISHOP + e.B]: 'b', [r.ROOK + e.W]: 'R', [r.ROOK + e.B]: 'r', [r.QUEEN + e.W]: 'Q', [r.QUEEN + e.B]: 'q', [r.AMAZON + e.W]: 'AM', [r.AMAZON + e.B]: 'am', [r.HAWK + e.W]: 'HA', [r.HAWK + e.B]: 'ha', [r.CHANCELLOR + e.W]: 'CH', [r.CHANCELLOR + e.B]: 'ch', [r.ARCHBISHOP + e.W]: 'AR', [r.ARCHBISHOP + e.B]: 'ar', [r.GUARD + e.W]: 'GU', [r.GUARD + e.B]: 'gu', [r.CAMEL + e.W]: 'CA', [r.CAMEL + e.B]: 'ca', [r.GIRAFFE + e.W]: 'GI', [r.GIRAFFE + e.B]: 'gi', [r.ZEBRA + e.W]: 'ZE', [r.ZEBRA + e.B]: 'ze', [r.CENTAUR + e.W]: 'CE', [r.CENTAUR + e.B]: 'ce', [r.ROYALQUEEN + e.W]: 'RQ', [r.ROYALQUEEN + e.B]: 'rq', [r.ROYALCENTAUR + e.W]: 'RC', [r.ROYALCENTAUR + e.B]: 'rc', [r.KNIGHTRIDER + e.W]: 'NR', [r.KNIGHTRIDER + e.B]: 'nr', [r.HUYGEN + e.W]: 'HU', [r.HUYGEN + e.B]: 'hu', [r.ROSE + e.W]: 'RO', [r.ROSE + e.B]: 'ro', // Neutrals [r.OBSTACLE + e.N]: 'ob', [r.VOID + e.N]: 'vo', }; const piece_codes_inverted = jsutil.invertObj(piece_codes); /** The codes for raw, color-less piece types. */ const piece_codes_raw = { [r.KING]: 'k', [r.PAWN]: 'p', [r.KNIGHT]: 'n', [r.BISHOP]: 'b', [r.ROOK]: 'r', [r.QUEEN]: 'q', [r.AMAZON]: 'am', [r.HAWK]: 'ha', [r.CHANCELLOR]: 'ch', [r.ARCHBISHOP]: 'ar', [r.GUARD]: 'gu', [r.CAMEL]: 'ca', [r.GIRAFFE]: 'gi', [r.ZEBRA]: 'ze', [r.CENTAUR]: 'ce', [r.ROYALQUEEN]: 'rq', [r.ROYALCENTAUR]: 'rc', [r.KNIGHTRIDER]: 'nr', [r.HUYGEN]: 'hu', [r.ROSE]: 'ro', // Neutrals [r.OBSTACLE]: 'ob', [r.VOID]: 'vo', }; const piece_codes_raw_inverted = jsutil.invertObj(piece_codes_raw); // Variables ------------------------------------------------------------------ /** The desired ordering metadata should be placed in the ICN */ const metadata_ordering: (keyof MetaData)[] = [ 'Event', 'Site', 'Variant', 'Round', 'UTCDate', 'UTCTime', 'TimeControl', 'White', 'Black', 'WhiteID', 'BlackID', 'WhiteElo', 'BlackElo', 'WhiteRatingDiff', 'BlackRatingDiff', 'Result', 'Termination', ]; // Defaults when pasting an ICN ---------------------------------------------------------- /** * The default promotions allowed, if the ICN does not specify. * If, when converting a game into ICN, the promotionsAllowed * gamerule matches this, then we won't specify custom promotions in the ICN. */ const default_promotions: RawType[] = [r.QUEEN, r.ROOK, r.BISHOP, r.KNIGHT]; /** Tests if the provided array of legal promotions is the default set of promotions. */ function isPromotionListDefaultPromotions(promotionList: RawType[]): boolean { if (promotionList.length !== default_promotions.length) return false; return default_promotions.every((promotion) => promotionList.includes(promotion)); } /** The default win condition for each player, if none specified in the ICN. */ const default_win_condition = 'checkmate' as const; /** The default turn order, if none specified in the ICN. */ const defaultTurnOrder = [p.WHITE, p.BLACK]; /** The default full move, if none specified in the ICN. */ const defaultFullMove = 1; ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // REGULAR EXPRESSIONS // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** * Simulates possessive behavior for a regex pattern string `str` (e.g., \d+) * using the lookahead/named backreference technique `(?:(?=(?str))\k)`. * Can essentially transform any (...?), (...+), or (...*) regex into a possessive version (...?+), (...?+), or (...*+). * * Using this prevents catastrophic backtracking in regexes, as once a possessive group is matched, * those characters can never be released to see if the string can be matched in a different way. * @param {string} str - Regex pattern string to make possessive. * @returns {string} Pattern string with possessive simulation. */ const possessive = (() => { let counter = 0; // The actual function that gets assigned to possessive() return function (str: string): string { const uniqueGroupName = `_g${counter++}`; // Generate unique name internally return String.raw`(?:(?=(?<${uniqueGroupName}>${str}))\k<${uniqueGroupName}>)`; }; })(); const countingNumberSource = String.raw`[1-9]\d*`; // 1+ Positive. Disallows leading 0's const wholeNumberSource = String.raw`(?:0|[1-9]\d*)`; // 0+ Positive. Disallows leading 0's unless it's 0 const integerSource = String.raw`(?:0|-?[1-9]\d*)`; // Prevents "-0", or numbers with leading 0's like "000005" const unboundedIntegerSource = String.raw`(?:_|${integerSource})`; // Allows _ as a placeholder for infinity const coordsKeyRegexSource = `${integerSource},${integerSource}`; // '-1,2' const piece_code_regex_source = '[a-zA-Z]{1,2}'; const raw_piece_code_regex_source = '[a-z]{1,2}'; /** * Returns a regex for matching a piece abbreviation like '3Q' or 'nr'. '3Q' => Player-3 queen (red) * Optionally captures the piece abbreviation, and the player * number if present, using custom capture group names. * Disallows negatives, or leading 0's * * This prevents duplicate capture group names when a bigger regex contains * multiple smaller pieceAbbrev regexes, as we can make them different. * @param capturing - Whether to capture the player number and piece abbreviation. */ function getPieceAbbrevRegexSource(capturing: boolean): string { const player = capturing ? '' : ':'; const abbrev = capturing ? '' : ':'; const result = `(?${player}${wholeNumberSource})?(?${abbrev}${piece_code_regex_source})`; // console.log("Generated PieceAbbrev Regex Source:", result); return result; } /** * A regex for matching a single piece entry in a shortform position in ICN. * For example, 'P1,2+' => Pawn at 1,2 with special right. * It optionally captures the piece abbreviation, coords key, and special right into named groups. */ function getPieceEntryRegexSource(capturing: boolean): string { const pieceAbbr = capturing ? '' : ':'; const coordsKey = capturing ? '' : ':'; const specialRight = capturing ? '' : ':'; return String.raw`(?${pieceAbbr}${getPieceAbbrevRegexSource(false)})(?${coordsKey}${coordsKeyRegexSource})(?${specialRight}\+)?`; // 'P1,2+' => Pawn at 1,2 with special right } /** Returns a regex source for matching the promotion segment in a move, optionally capturing */ function getPromotionRegexSource(capturing: boolean): string { const promotionAbbr = capturing ? '' : ':'; return `(?:=(?${promotionAbbr}${getPieceAbbrevRegexSource(false)}))?`; // '=Q' => Promotion to queen } /** * A regex for matching a move in the MOST COMPACT form: '1,7>2,8=Q' * The start coords, end coords, and promotion abbrev are all captured into named groups. */ const moveRegexCompact = new RegExp( `^(?${coordsKeyRegexSource})>(?${coordsKeyRegexSource})${getPromotionRegexSource(true)}$`, ); /** * A regex for dynamically matching all forms of a move in ICN. * The move may optionally include a piece abbreviation, spaces between segments, * a separator of ">" or "x", check/mate flags "+" or "#", symbols !?, ?!, !!, and a comment. * "P1,7 x 2,8 =Q + !! {Promotion!!!}" * * It optionally captures the start coords, end coords, promotion abbrev, and the comment, all into named groups. */ function getMoveRegexSource(capturing: boolean): string { const startCoordsKey = capturing ? '' : ':'; const endCoordsKey = capturing ? '' : ':'; const comment = capturing ? '' : ':'; const result = possessive(`(?:${getPieceAbbrevRegexSource(false)})?`) + // Optional starting piece abbreviation "P" DOESN'T NEED TO BE CAPTURED, this avoids a crash cause of duplicate capture group names `(?${startCoordsKey}${coordsKeyRegexSource})` + // Starting coordinates possessive(` ?`) + // Optional space `[>x]` + // Separator possessive(` ?`) + // Optional space `(?${endCoordsKey}${coordsKeyRegexSource})` + // Ending coordinates possessive(` ?`) + // Optional space possessive(getPromotionRegexSource(capturing)) + // Optional promotion ("=" REQUIRED) possessive(` ?`) + // Optional space possessive(`[+#]?`) + // Optional check/checkmate possessive(` ?`) + // Optional space possessive(`(?:[!?]{1,2})?`) + // Optional symbols: !?, ?!, !! possessive(' ?') + // Optional space possessive(String.raw`(?:\{(?${comment}[^}]+)\})?`); // Optional comment (not-greedy). Comments should NOT contain a closing brace "}". // console.log("Generated Move Regex Source:", result); return result; } // console.log("MoveRegexSource:", getMoveRegexSource(false)); /** * Construct the regexes for matching sections of the ICN. * * [Variant "Classical"] w 3,4 0/100 1 (8;Q,R,B,N|1;q,r,b,n) checkmate Rays:14,-140>-1,-1 P1,2+|P2,2+|P3,2+|P4,2+|P5,2+ */ /** * Matches following whitespace, or end of string. * Adding this to many of the section regexes prevents them from * confusing other sections with similar starts. */ const whiteSpaceOrEnd = String.raw`(?:\s+|$)`; // Matches whitespace or end of string const whiteSpaceOrEndRegex = new RegExp(whiteSpaceOrEnd, 'y'); /** Regex source that matches and captures a single metadata entry. */ const singleMetadataSource = String.raw`\[([a-zA-Z]+)\s+"([^"]{1,200})"\]`; // Max metadata value length of 200 chars for safety. This prevents, if we forget a closing ", the regex consuming the entirity of the ICN const metadataRegex = new RegExp( String.raw`${singleMetadataSource}(?:\s+${singleMetadataSource})*${whiteSpaceOrEnd}`, 'y', ); // 'y' flag for sticky matching (only matches at the regex's lastIndex property, not after) const turnOrderRegex = new RegExp( String.raw`(?${raw_piece_code_regex_source}(?::${raw_piece_code_regex_source})*)${whiteSpaceOrEnd}`, 'y', ); const enpassantRegex = new RegExp( String.raw`(?${coordsKeyRegexSource})${whiteSpaceOrEnd}`, 'y', ); const moveRuleRegex = new RegExp( String.raw`(?${wholeNumberSource}/${countingNumberSource})${whiteSpaceOrEnd}`, 'y', ); const fullMoveRegex = new RegExp( String.raw`(?${countingNumberSource})${whiteSpaceOrEnd}`, 'y', ); const promotionRanksSource = `${integerSource}(?:,${integerSource})*`; // '8,16,24,32' const promotionsAllowedSource = `${piece_code_regex_source}(?:,${piece_code_regex_source})*`; // 'q,r,b,n' const singlePlayerPromotionSource = `(?:${promotionRanksSource}(?:;${promotionsAllowedSource})?)?`; // '8,16,24,32;q,r,b,n' | '' /** Captures the promotion ranks and promotions allowed section in the ICN. */ const promotionsRegex = new RegExp( String.raw`\((?${singlePlayerPromotionSource}(?:\|${singlePlayerPromotionSource})*)\)${whiteSpaceOrEnd}`, 'y', ); /** * Matches the world border segment in ICN: 'left,right,bottom,top' * Example: '-7,16,-7,16' * `_` can be used to represent infinity. */ const worldBorderRegex = new RegExp( String.raw`(?${unboundedIntegerSource},${unboundedIntegerSource},${unboundedIntegerSource},${unboundedIntegerSource})${whiteSpaceOrEnd}`, 'y', ); const singleWinConSource = `(?:${winconutil.GAMERULE_WIN_CONDITIONS.join('|')})`; // 'royalcapture' const singlePlayerWinConSource = `${singleWinConSource}(?:,${singleWinConSource})*`; // 'royalcapture,koth' /** Captures the win conditions section in the ICN. */ const winConditionRegex = new RegExp( String.raw`\(?(?${singlePlayerWinConSource}(?:\|${singlePlayerWinConSource})*)\)?${whiteSpaceOrEnd}`, 'y', ); /** * Matches the preset squares segment in ICN * 'Squares:x,y|x,y' */ const presetSquaresRegex = new RegExp( String.raw`Squares:(?${coordsKeyRegexSource}(?:\|${coordsKeyRegexSource})*)${whiteSpaceOrEnd}`, 'y', ); // 'Squares:x,y|x,y' /** Matches a single preset ray, optionally capturing its properties. */ const singleRaySource = `${coordsKeyRegexSource}>${coordsKeyRegexSource}`; // 'x,y>dx,dy' /** * Matches the preset rays segment in ICN * 'Rays:x,y>dx,dy|x,y>dx,dy' */ const presetRaysRegex = new RegExp( String.raw`Rays:(?${singleRaySource}(\|${singleRaySource})*)${whiteSpaceOrEnd}`, 'y', ); // 'Rays:x,y>dx,dy|x,y>dx,dy' // SKIP THE POSITION (It can be too big to capture all at once) /** * Matches any possible delimiter between moves in the moves section of an ICN. * This could be a pipe "|", or the move number "14." */ const movesDelimiter = String.raw`(?:\s?${countingNumberSource}\. | ?\| ?)`; // " 14. " or " | " /** Matches an entire moves list in an ICN, no matter its styling. */ const movesRegexSource = possessive(String.raw`(?:${countingNumberSource}\. )?`) + // The first move number, if present getMoveRegexSource(false) + possessive(`(?:${movesDelimiter}${getMoveRegexSource(false)})*`); // console.log("MovesRegexSource:", movesRegexSource); /** Captures the moes list */ const movesRegex = new RegExp(String.raw`(?${movesRegexSource})${whiteSpaceOrEnd}`, 'y'); //\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ // END OF REGULAR EXPRESSIONS //\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ // Getting & Parsing Abbreviations -------------------------------------------------------------------------------- /** * Gets the 1-2 letter abbreviation of the given piece type. * White pieces are capitalized, black pieces are lowercase. * If a piece is neither white nor black, its player number * will be placed before its abbreviation, overriding the color. * * [43] pawn(white) => 'P' * [52] queen(black) => 'q' * [68] king(red) => '3k' */ function getAbbrFromType(type: number): string { let short = piece_codes[type]; if (!short) { const [r, p] = typeutil.splitType(type); short = String(p) + piece_codes_raw[r]; } return short; } /** * Gets the integer piece type from a 1-2 letter piece abbreviation. * Capitolized abbrev's are white, lowercase are black, or neutral. * It may contain a proceeding number, overriding the player color. * * 'P' => [43] pawn(white) * 'q' => [52] queen(black) * '3k' => [68] king(red) */ function getTypeFromAbbr(pieceAbbr: string): number { const results = new RegExp(`^${getPieceAbbrevRegexSource(true)}$`).exec(pieceAbbr); if (results === null) throw Error(`Piece abbreviation is in invalid form: (${pieceAbbr})`); const playerStr = results.groups!['player']; const abbrev = results.groups!['abbrev']!; let typeStr: string | undefined; if (playerStr === undefined) { // No player number override is present typeStr = piece_codes_inverted[abbrev]; if (typeStr === undefined) throw Error(`Unknown piece abbreviation: (${pieceAbbr})`); return Number(typeStr); } else { // Player number override present '3Q' const rawTypeStr = piece_codes_raw_inverted[abbrev.toLowerCase()]; if (rawTypeStr === undefined) throw Error(`Unknown raw piece abbreviation: (${pieceAbbr})`); return typeutil.buildType(Number(rawTypeStr) as RawType, Number(playerStr) as Player); } } // Main Functions Converting Games To and From ICN ----------------------------------------------------------------- /** * Converts a game in JSON format to Infinite Chess Notation. * @param longformat - The game in JSON format. Required properties below. * @param longformat.metadata - The metadata of the game. Variant, UTCDate, and UTCTime are required if options.skipPosition = true * @param [longformat.position] The position of the game, where the values is the integer piece type at that coordsKey. Required if options.skipPosition = false * @param longformat.gameRules - The required gameRules to create the ICN * @param longformat.fullMove - The fullMove property of the gamefile (usually 1) * @param longformat.state_global - The game's global state. This contains the following properties which change over the duration of a game: `specialRights`, `enpassant`, `moveRuleState`. * @param [longformat.moves] - If provided, they will be placed into the ICN * @param options - Various styling options for the resulting ICN, mostly affecting the moves section. Descriptions are below. * * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q' * IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!! * * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +' * * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}' * * move_numbers => Include move numbers, prettifying the notation. * * make_new_lines => Include line breaks in the ICN, between metadata, and between move numbers. */ function LongToShort_Format( longformat: LongFormatIn, options: { skipPosition?: boolean; compact: boolean; spaces: boolean; comments: boolean; make_new_lines: boolean; move_numbers: boolean; }, ): string { // console.log("Converting longformat to shortform ICN:", jsutil.deepCopyObject(longformat)); /** Will contain the Metadata, Positon, and Move sections. */ const segments: string[] = []; // =================================== Section 1: Metadata =================================== const metadataSegments: string[] = []; // Appended in the correct order given by metadata_key_ordering const metadataCopy = jsutil.deepCopyObject(longformat.metadata); for (const metadata_name of metadata_ordering) { if (metadataCopy[metadata_name] === undefined) { delete metadataCopy[metadata_name]; // Delete it (sometimes its DECLARED as undefined). Prevents it from increasing the key count continue; // Skip to the next metadata } metadataSegments.push(`[${metadata_name} "${metadataCopy[metadata_name]}"]`); delete metadataCopy[metadata_name]; } // Are there any remaining we missed? if (Object.keys(metadataCopy).length > 0) throw Error( `metadata_ordering is missing metadata keys (${Object.keys(metadataCopy).join(', ')})`, ); if (metadataSegments.length > 0) { const metadataDelimiter = options.make_new_lines ? '\n' : ' '; segments.push(metadataSegments.join(metadataDelimiter)); } // =================================== Section 2: Position =================================== /** Each of these are separated by a space. */ const positionSegments: string[] = []; /** * The ordering goes: * * Turn order * Enpassant * Move rule * Full move counter * Promotion lines * World border * Win conditions * Preset Square Highlights * Preset Ray Highlights * Position * Moves * * As an example: * * w 0/100 1 (8;Q,R,B,N|1;q,r,b,n) checkmate {"slideLimit": 100, "cannotPassTurn": true} P1,2+|P2,2+|P3,2+|P4,2+|P5,2+ */ // Turn order const turnOrderArray: PlayerCode[] = longformat.gameRules.turnOrder.map((player) => { if (!(player in player_codes)) throw new Error(`No player code found for player (${player})!`); return player_codes[player]; }); let turn_order = turnOrderArray.join(':'); // 'w:b' if (turn_order === 'w:b') turn_order = 'w'; // Short for 'w:b' else if (turn_order === 'b:w') turn_order = 'b'; // Short for 'b:w' positionSegments.push(turn_order); // En passant if (longformat.state_global.enpassant) { // Only add it SO LONG AS THE distance to the pawn is 1 square!! Which may not be true if it's a 4D game. const yDistance = bimath.abs( longformat.state_global.enpassant.square[1] - longformat.state_global.enpassant.pawn[1], ); if (yDistance === 1n) positionSegments.push( coordutil.getKeyFromCoords(longformat.state_global.enpassant.square), ); // '1,3' else console.warn( 'Enpassant distance is more than 1 square, not specifying it in the ICN. Enpassant:', longformat.state_global.enpassant, ); } // 50 Move Rule if ( longformat.gameRules.moveRule !== undefined || longformat.state_global.moveRuleState !== undefined ) { // Make sure both moveRule and moveRuleState are present if (longformat.state_global.moveRuleState === undefined) throw Error( 'moveRuleState must be present when convering a game with moveRule to shortform!', ); if (longformat.gameRules.moveRule === undefined) throw Error( 'moveRule must be present when convering a game with moveRuleState to shortform!', ); positionSegments.push( `${longformat.state_global.moveRuleState}/${longformat.gameRules.moveRule}`, ); // '0/100' } // Full move counter positionSegments.push(String(longformat.fullMove)); // Promotion lines if (longformat.gameRules.promotionRanks || longformat.gameRules.promotionsAllowed) { // Make sure both promotionRanks and promotionsAllowed are present if (!longformat.gameRules.promotionRanks) throw Error( 'promotionRanks must be present when converting a game with promotionsAllowed to shortform!', ); if (!longformat.gameRules.promotionsAllowed) throw Error( 'promotionsAllowed must be present when converting a game with promotionRanks to shortform!', ); const promotionRanksCopy = jsutil.deepCopyObject(longformat.gameRules.promotionRanks); const promotionsAllowedCopy = jsutil.deepCopyObject(longformat.gameRules.promotionsAllowed); /** A sorted list (ascending) of all unique player numbers in the game. */ const uniquePlayers = Array.from(new Set(longformat.gameRules.turnOrder)).sort( (a, b) => a - b, ); const playerSegments: string[] = []; // ['8,17','1,10'] for (const player of uniquePlayers) { const playerSegment: string[] = []; // ['8,17','n,r,b,q'] const ranks = promotionRanksCopy[player] ?? []; if (ranks.length === 0) { // They have no promotions, but still add them. For example it may look like '(8|)' playerSegments.push(''); continue; } const ranksString = ranks.join(','); playerSegment.push(ranksString); const promotions: RawType[] = promotionsAllowedCopy[player] ?? []; if (promotions.length === 0) throw Error( `Player was given promotion ranks, but no promotions allowed! (${player}: ${ranksString})`, ); if (!isPromotionListDefaultPromotions(promotions)) { const promotionsAbbrevs = promotions.map((type) => piece_codes_raw[type]).join(','); // 'N,R,B,Q' playerSegment.push(promotionsAbbrevs); } playerSegments.push(playerSegment.join(';')); delete promotionRanksCopy[player]; // Remove the player from the object delete promotionsAllowedCopy[player]; // Remove the player from the object } positionSegments.push('(' + playerSegments.join('|') + ')'); // '(8,17|1,10)' // Check if there are any remaining players not accounted for if (Object.keys(promotionRanksCopy).length > 0) throw Error( 'Not all players with promotion ranks had a turn in the turn order! ' + Object.keys(promotionRanksCopy).join(', '), ); if (Object.keys(promotionsAllowedCopy).length > 0) throw Error( 'Not all players with promotions allowed had a turn in the turn order! ' + Object.keys(promotionsAllowedCopy).join(', '), ); } // World Border if (longformat.gameRules.worldBorder) { const { left, right, bottom, top } = longformat.gameRules.worldBorder; positionSegments.push(`${left ?? '_'},${right ?? '_'},${bottom ?? '_'},${top ?? '_'}`); } // Win conditions const playerWinConSegments: string[] = []; // ['checkmate','checkmate|allpiecescaptured'] // Sort by ascending player number const sortedPlayers = ( Object.keys(longformat.gameRules.winConditions).map(Number) as Player[] ).sort((a, b) => a - b); for (const player of sortedPlayers) { playerWinConSegments.push(longformat.gameRules.winConditions[player]!.join(',')); // 'checkmate,allpiecescaptured' } const allPlayersMatchWinConditions = playerWinConSegments.every( (segment) => segment === playerWinConSegments[0], ); if (allPlayersMatchWinConditions) { if (playerWinConSegments[0]! !== default_win_condition) positionSegments.push(playerWinConSegments[0]!); // Don't include parenthesis => 'royalcapture' | 'checkmate,koth' // Else all players have checkmate, no need to specify! } else { // One or more players have differing win conditions positionSegments.push('(' + playerWinConSegments.join('|') + ')'); // Include parenthesis => '(checkmate|checkmate,allpiecescaptured)' } // Preset squares if (longformat.presetAnnotes?.squares) { positionSegments.push( 'Squares:' + longformat.presetAnnotes.squares.map(coordutil.getKeyFromCoords).join('|'), ); } // Preset rays if (longformat.presetAnnotes?.rays) { positionSegments.push( 'Rays:' + longformat.presetAnnotes.rays .map((pr) => { return ( coordutil.getKeyFromCoords(pr.start) + '>' + coordutil.getKeyFromCoords(pr.vector) ); }) .join('|'), ); } // Position - P1,2+|P2,2+|P3,2+|P4,2+|P5,2+ if (!options.skipPosition) { if (longformat.position === undefined) throw Error('longformat.position must be specified when skipPosition = false'); if (longformat.state_global.specialRights === undefined) throw Error('longformat.specialRights must be specified when skipPosition = false'); // Position can be empty in the editor. This avoids a trailing space in the ICN if (longformat.position.size > 0) positionSegments.push( getShortFormPosition(longformat.position, longformat.state_global.specialRights), ); } else if ( !longformat.metadata.Variant || !longformat.metadata.UTCDate || !longformat.metadata.UTCTime ) throw Error( "longformat.metadata's Variant, UTCDate, and UTCTime must be specified when skipPosition = true", ); segments.push(positionSegments.join(' ')); // 'w 0/100 1 (8,17|1,10) (checkmate|checkmate,allpiecescaptured) P1,2+|P2,2+|P3,2+|P4,2+|P5,2+' // =================================== Section 3: Moves =================================== if (longformat.moves) { const move_options = { compact: options.compact, spaces: options.spaces, comments: options.comments, move_numbers: options.move_numbers, // Required if move_numbers = true: make_new_lines: options.make_new_lines, turnOrder: longformat.gameRules.turnOrder, fullmove: longformat.fullMove, }; segments.push(getShortFormMovesFromMoves(longformat.moves, move_options)); } // ======================================================================================== // Combine them all, with an extra line break if make_new_lines = true const sectionDelimiter = options.make_new_lines ? '\n\n' : ' '; return segments.join(sectionDelimiter); // 'w 0/100 1 (8,17|1,10) (checkmate|checkmate,allpiecescaptured) {"slideLimit": 100, "cannotPassTurn": true} P1,2+|P2,2+|P3,2+|P4,2+|P5,2+' } /** * Converts a string in Infinite Chess Notation to game in JSON format. * * Throws an error if the ICN is invalid. */ function ShortToLong_Format(icn: string): LongFormatOut { // console.log("====== Parsing ICN ======"); const metadata: Record = {}; // Required let turnOrder: Player[]; // Required let enpassant: EnPassant | undefined; let moveRule: number | undefined; let moveRuleState: number | undefined; let fullMove: number; // Required let promotionRanks: PlayerGroup | undefined; let promotionsAllowed: PlayerGroup | undefined; let winConditions: PlayerGroup = {}; // Required let worldBorder: UnboundedRectangle | undefined; let presetSquares: Coords[] | undefined; let presetRays: BaseRay[] | undefined; let position: Map | undefined; let specialRights: Set | undefined; let moves: MoveParsed[] | undefined; /** The current index we are observing in the entire ICN string. Start at 0 and work up. */ let lastIndex = 0; /** * Find the first non-whitespace character in the ICN, * which should be the start of the first section. */ const whitespaceRegex = /\s+/y; // Sticky so it only matches at lastIndex whitespaceRegex.lastIndex = lastIndex; // Not needed? But safe if (whitespaceRegex.exec(icn)) lastIndex = whitespaceRegex.lastIndex; // Adjust the lastIndex to the first non-whitespace character if (lastIndex === icn.length) throw Error('ICN is empty.'); // console.log("First non-whitespace character:", icn[lastIndex], "at index", lastIndex); // ==================================== BEGIN =================================== // Metadata // Test if the metadata lies at our current index being observed metadataRegex.lastIndex = lastIndex; const metadataResults = metadataRegex.exec(icn); if (metadataResults) { const blockEnd = metadataRegex.lastIndex; // First character index after the metadata block const singleMetadataRegex = new RegExp(singleMetadataSource, 'g'); singleMetadataRegex.lastIndex = lastIndex; // Since the moveRegex has the global flag, exec() will return the next match each time. // NO STRING SPLITTING REQUIRED let match: RegExpExecArray | null; while ( singleMetadataRegex.lastIndex < blockEnd && (match = singleMetadataRegex.exec(icn)) !== null ) { const key = match[1]!; const value = match[2]!; metadata[key] = value; } // console.log("Parsed metadata:", jsutil.deepCopyObject(metadata)); lastIndex = blockEnd; // Update the ICN index being observed } // Turn order // Test if the turn order lies at our current index being observed turnOrderRegex.lastIndex = lastIndex; const turnOrderResults = turnOrderRegex.exec(icn); if (turnOrderResults) { let turnOrderString = turnOrderResults.groups!['turnOrder']!; // 'w:b' // console.log(`Turn Order: "${turnOrderString}"`); // Substitues if (turnOrderString === 'w') turnOrderString = 'w:b'; // 'w' is short for 'w:b' else if (turnOrderString === 'b') turnOrderString = 'b:w'; // 'b' is short for 'b:w' const turnOrderArray = turnOrderString.split(':'); // ['w','b'] turnOrder = [ ...turnOrderArray.map((p_code) => { if (!(p_code in player_codes_inverted)) throw Error( `Unknown player code (${p_code}) when parsing turn order of ICN! Turn order (${turnOrderResults.groups!['turnOrder']})`, ); return Number(player_codes_inverted[p_code]); }), ] as Player[]; // [1,2] lastIndex = turnOrderRegex.lastIndex; // Update the ICN index being observed } else { // Set default turn order turnOrder = jsutil.deepCopyObject(defaultTurnOrder); } /** A sorted list (ascending) of all unique player numbers in the game. */ const uniquePlayers = Array.from(new Set(turnOrder)).sort((a, b) => a - b); // Enpassant // Test if the enpassant square lies at our current index being observed enpassantRegex.lastIndex = lastIndex; const enpassantResults = enpassantRegex.exec(icn); if (enpassantResults) { const enpassantString = enpassantResults.groups!['enpassant']! as CoordsKey; const coords = coordutil.getCoordsFromKey(enpassantString); const lastTurn = turnOrder[turnOrder.length - 1]; // prettier-ignore const yParity = lastTurn === p.WHITE ? 1n : lastTurn === p.BLACK ? -1n : (() => { throw new Error(`Invalid last turn (${lastTurn}) when parsing enpassant in ICN!`); })(); enpassant = { square: coords, pawn: [coords[0], coords[1] + yParity] }; lastIndex = enpassantRegex.lastIndex; // Update the ICN index being observed } // Move rule // Test if the move rule lies at our current index being observed moveRuleRegex.lastIndex = lastIndex; const moveRuleResults = moveRuleRegex.exec(icn); if (moveRuleResults) { const moveRuleGroup = moveRuleResults.groups!['moveRule']!; [moveRuleState, moveRule] = moveRuleGroup.split('/').map(Number); if (moveRuleState! > moveRule!) throw Error(`Invalid move rule "${moveRuleGroup}" when parsing ICN!`); lastIndex = moveRuleRegex.lastIndex; // Update the ICN index being observed } // Full move // Test if the full move counter lies at our current index being observed fullMoveRegex.lastIndex = lastIndex; const fullMoveResults = fullMoveRegex.exec(icn); if (fullMoveResults) { fullMove = Number(fullMoveResults.groups!['fullMove']!); lastIndex = fullMoveRegex.lastIndex; // Update the ICN index being observed } else { // Set default full move fullMove = defaultFullMove; } // Promotions ranks + allowed // Test if the promotions information lies at our current index being observed promotionsRegex.lastIndex = lastIndex; const promotionsResults = promotionsRegex.exec(icn); if (promotionsResults) { // console.log("Results of promotions regex:", promotionsResults); const promotionsString = promotionsResults.groups!['promotions']!; promotionRanks = {}; promotionsAllowed = {}; const promotions = promotionsString.split('|'); // ['8,16,24,32;q,r,b,n','1,9,17,25;q,r,b,n'] // Make sure the number of promotions matches the number of players if (promotions.length !== uniquePlayers.length) throw new Error( `Number of promotions (${promotions.length}) does not match number of unique players (${uniquePlayers.length})! Received promotions: "${promotionsString}"`, ); for (const player of uniquePlayers) { const playerPromotions = promotions.shift()!; // '8,16,24,32;q,r,b,n' promotionRanks[player] = []; // Initialize empty if (playerPromotions === '') continue; // Player has no promotions. Maybe promotions were "(8|)" const [ranks, allowed] = playerPromotions.split(';'); // The allowed section is optional promotionRanks[player] = ranks!.split(',').map(BigInt); // prettier-ignore promotionsAllowed[player] = allowed ? [...new Set(allowed.split(',').map(raw => { const rawPieceCode = piece_codes_raw_inverted[raw.toLowerCase()]; if (rawPieceCode === undefined) throw new Error(`Unknown raw piece code (${raw}) when parsing promotions allowed!`); return Number(rawPieceCode) as RawType; }))] : jsutil.deepCopyObject(default_promotions); } lastIndex = promotionsRegex.lastIndex; // Update the ICN index being observed } // World Border // Test if the world border lies at our current index being observed worldBorderRegex.lastIndex = lastIndex; const borderResult = worldBorderRegex.exec(icn); if (borderResult) { const [left, right, bottom, top] = borderResult .groups!['worldBorder']!.split(',') .map((value) => (value === '_' ? null : BigInt(value))) as [ bigint | null, bigint | null, bigint | null, bigint | null, ]; worldBorder = { left, right, bottom, top }; lastIndex = worldBorderRegex.lastIndex; // Update the ICN index being observed } // Win conditions // Test if the win conditions lie at our current index being observed winConditionRegex.lastIndex = lastIndex; const winConditionResults = winConditionRegex.exec(icn); if (winConditionResults) { const winConditionsString = winConditionResults.groups!['winConditions']!; const winConStrings = winConditionsString.split('|'); // ['checkmate','checkmate,allpiecescaptured'] winConditions = {}; // If winConStrings.length is 1, all players have the same win conditions if (winConStrings.length === 1 && winConStrings[0] !== undefined) { // The regex guarantees that the win conditions are valid const winConArray = winConStrings[0].split(',') as GameruleWinCondition[]; // ['checkmate','allpiecescaptured'] for (const player of turnOrder) { winConditions[player] = [...winConArray]; } } else { // Each player has their own win conditions // Make sure the number of win conditions matches the number of unique players if (winConStrings.length !== uniquePlayers.length) throw new Error( `Number of win conditions (${winConStrings.length}) does not match number of players (${uniquePlayers.length})!`, ); for (const player of uniquePlayers) { const winConString = winConStrings.shift()!; // The regex guarantees that the win conditions are valid winConditions[player] = winConString.split(',') as GameruleWinCondition[]; // ['checkmate','allpiecescaptured'] } } lastIndex = winConditionRegex.lastIndex; // Update the ICN index being observed } else { // Set default win conditions for (const player of turnOrder) { winConditions[player] = [default_win_condition]; } } // Preset Squares // Test if the preset squares lie at our current index being observed presetSquaresRegex.lastIndex = lastIndex; const squaresResult = presetSquaresRegex.exec(icn); if (squaresResult) { presetSquares = parsePresetSquares(squaresResult.groups!['squarePresets']!); lastIndex = presetSquaresRegex.lastIndex; // Update the ICN index being observed } // Preset Rays // Test if the preset rays lie at our current index being observed presetRaysRegex.lastIndex = lastIndex; const raysResult = presetRaysRegex.exec(icn); if (raysResult) { presetRays = parsePresetRays(raysResult.groups!['rayPresets']!); lastIndex = presetRaysRegex.lastIndex; // Update the ICN index being observed } /** * Moves * * MUST BE TESTED BEFORE THE POSITION, as the position may * wrongfully think the moves section is the start of the position, * since the start of a move can look like a piece entry. */ testNextSectionForMoves(); /** * Position * * SPECIAL HANDLING FOR THE POSITION (It can be too long to regex match all at once) * MUST BE TESTED AFTER THE MOVES, as this may wrongfully interpret the * start of the moves section as the start of the position, if the position isn't present. */ if (!moves) { // This next section GUARANTEED to not be the moves section // Test if this next section is the position section const pieceEntryRegex = new RegExp(getPieceEntryRegexSource(true), 'y'); const delimiter = /\|/y; // The delimiter between piece entries // Set the lastIndex to the current index being observed in the ICN pieceEntryRegex.lastIndex = lastIndex; // Check for the present of the first piece entry let match: RegExpExecArray | null = pieceEntryRegex.exec(icn); if (match) { // The POSITION is present! // Initialize position = new Map(); specialRights = new Set(); processPieceEntry(match); // Repeatedly check for the next piece entry. // EFFICIENT. Works for arbitrarily large positions! while (true) { // Check if the next character is a delimiter delimiter.lastIndex = pieceEntryRegex.lastIndex; // Set the lastIndex to the current index being observed if (delimiter.exec(icn)) { // Delimiter found pieceEntryRegex.lastIndex = delimiter.lastIndex; // Set the lastIndex to the current index being observed match = pieceEntryRegex.exec(icn); // Get the next match if (match) processPieceEntry(match); else throw Error( `Position section is malformed! No valid piece entry follows a "|".`, ); } else { break; // No delimiter found. End of position. Exit the loop. } } // console.log("Parsed position:", position); // Make sure there's whitespace or end of string immediately following whiteSpaceOrEndRegex.lastIndex = pieceEntryRegex.lastIndex; if (!whiteSpaceOrEndRegex.exec(icn)) throw Error( 'Position section needs to be followed by whitespace or end of string!', ); lastIndex = whiteSpaceOrEndRegex.lastIndex; // Update the ICN index being observed } /** Adds the matched piece entry to the position and specialRights. */ function processPieceEntry(match: RegExpExecArray): void { // named groups are: pieceAbbr, coordsKey, specialRight const pieceAbbr = match.groups!['pieceAbbr']!; const coordsKey = match.groups!['coordsKey']! as CoordsKey; const hasSpecialRight = match.groups!['specialRight'] === '+'; const pieceType = getTypeFromAbbr(pieceAbbr); position!.set(coordsKey, pieceType); if (hasSpecialRight) specialRights!.add(coordsKey); } } // Now we can test if the moves section came *after* the positon section. if (!moves) testNextSectionForMoves(); function testNextSectionForMoves(): void { // Test if the beginning of the string matches the moves regex movesRegex.lastIndex = lastIndex; const movesResults = movesRegex.exec(icn); if (movesResults) { const movesString = movesResults.groups!['moves']!; moves = parseShortFormMoves(movesString); lastIndex = movesRegex.lastIndex; // Update the ICN index being observed } } // =================================== END =================================== // Make sure there's no unmatched characters remaining if (lastIndex < icn.length) { const remainingICN = icn.slice(lastIndex); throw Error(`Unexpected characters remaining in the ICN after parsing! "${remainingICN}"`); } // Construct the return object... const gameRules: GameRules = { turnOrder, winConditions, }; if (promotionRanks) gameRules.promotionRanks = promotionRanks; if (promotionsAllowed) gameRules.promotionsAllowed = promotionsAllowed; if (moveRule !== undefined) gameRules.moveRule = moveRule; if (worldBorder) gameRules.worldBorder = worldBorder; const state_global: Partial = {}; if (enpassant) state_global.enpassant = enpassant; if (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState; if (specialRights) state_global.specialRights = specialRights; const longFormatOut: LongFormatOut = { metadata: metadata as unknown as MetaData, gameRules, fullMove, state_global, }; if (position) longFormatOut.position = position; if (moves) longFormatOut.moves = moves; if (presetSquares || presetRays) { longFormatOut.presetAnnotes = {}; if (presetSquares) longFormatOut.presetAnnotes.squares = presetSquares; if (presetRays) longFormatOut.presetAnnotes.rays = presetRays; } // console.log("Finished parcing ICN!"); // console.log("Parsed longformat:", jsutil.deepCopyObject(longFormatOut)); return longFormatOut; } // Compacting & Parsing Single Moves ------------------------------------------------------------------------------- /** * Converts a MoveCoords into the most minimal string form: '1,7>2,8=Q' * * THE `=` IS REQUIRED because in future multiplayer games we will * have promotion to colored pieces, so we need to be able to distinguish * the player number from the end-Y coordinate! "1,7>2,8=3Q" => Red queen * * {@link getShortFormMoveFromMove} is also capable of this, but less efficient. */ function getCompactMoveFromDraft(moveCoords: MoveCoords): string { const startCoordsKey = coordutil.getKeyFromCoords(moveCoords.startCoords); const endCoordsKey = coordutil.getKeyFromCoords(moveCoords.endCoords); const promotionAbbr = moveCoords.promotion !== undefined ? getAbbrFromType(moveCoords.promotion) : undefined; return getCompactMoveFromParts(startCoordsKey, endCoordsKey, promotionAbbr); } function getCompactMoveFromParts( startCoordsKey: string, endCoordsKey: string, promotionAbbr?: string, ): string { const promotedPieceStr = promotionAbbr ? '=' + promotionAbbr : ''; return startCoordsKey + '>' + endCoordsKey + promotedPieceStr; // 'a,b>c,d=X' } /** * Converts a move into shortform notation, with various styling options available. * * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q'. * IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!! * spaces => Spaces between segments of a move => 'P1,7 x 2,8 =Q +' * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7] Capture, promotion, and a check!}' */ function getShortFormMoveFromMove( move: MovePreprint, options: { compact: boolean; spaces: boolean; comments: boolean }, ): string { // console.log("Options for getShortFormMoveFromMove:", options); if (options.compact && !options.spaces && !options.comments) console.warn( 'getCompactMoveFromDraft() is more efficient to get the most-compact form of a move.', ); if (!options.compact) { if (move.type === undefined) throw Error(`move.type must be present when compact = false! (${move.token})`); if (move.flags === undefined) throw Error(`move.flags must be present when compact = false! (${move.token})`); } // TESTING. Randomly give the move either a comment or a clk value. // if (Math.random() < 0.3) move.comment = "Comment example"; // if (Math.random() < 0.3) move.clockStamp = Math.random() * 100000; /** Each "segment" of the entire move will be separated by a space, if spaces is true */ const segments: string[] = []; // 1st segment: piece abbreviation + start coords const startCoordsKey = coordutil.getKeyFromCoords(move.startCoords); if (options.compact) segments.push(startCoordsKey); // '1,2' else { const pieceAbbr = getAbbrFromType(move.type!); segments.push(pieceAbbr + startCoordsKey); // 'P1,2' } // 2nd segment: If it was a capture, use 'x' instead of '>' if (options.compact) segments.push('>'); else segments.push(move.flags!.capture ? 'x' : '>'); // 3rd segment: end coords segments.push(coordutil.getKeyFromCoords(move.endCoords)); // 4th segment: Specify the promoted piece, if present if (move.promotion !== undefined) { const promotedPieceAbbr = getAbbrFromType(move.promotion); segments.push('=' + promotedPieceAbbr); // =Q "=" REQUIRED } // 5th segment: Append the check/mate flags '#' or '+' if (!options.compact && (move.flags!.mate || move.flags!.check)) segments.push(move.flags!.mate ? '#' : '+'); // 6th segment: Comment, if present, with the clk embedded command sequence // For example: {[%clk 0:09:56.7] White captures en passant} if (options.comments && (move.comment || move.clockStamp !== undefined)) { /** * Everything in a comment that has to be separated by a space. * This should include all embeded command sequences, like [%clk 0:09:56.7] */ const cmdObjs: CommandObject[] = []; // Include the clk embeded command sequence, if the player's clockStamp is present on the move. if (move.clockStamp !== undefined) cmdObjs.push(icncommentutils.createClkCommandObject(move.clockStamp)); // '[%clk 0:09:56.7]' const fullComment = icncommentutils.combineCommentAndCommands(cmdObjs, move.comment); // '[%clk 0:09:56.7] White captures en passant' if (fullComment) segments.push('{' + fullComment + '}'); // '{[%clk 0:09:56.7] White captures en passant}' } // Return the shortform move, adding a space between all segments, if spaces is true const segmentDelimiter = options.spaces ? ' ' : ''; return segments.join(segmentDelimiter); // 'P1,7 x 2,8 =Q + {[%clk 0:09:56.7] White captures en passant}' | 'P1,7x2,8=Q+{[%clk 0:09:56.7] White captures en passant}' | '1,7>2,8Q{[%clk 0:09:56.7]}' | '1,7>2,8Q' } /** * Parses a compact token move '1,7>2,8=Q' to a readable MoveParsed. * `comment` and `clockStamp` will NOT be present. */ function parseTokenMove(tokenMove: string): MoveParsed { const match = moveRegexCompact.exec(tokenMove); if (match === null) throw Error('Invalid compact move: ' + tokenMove); return getParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups); } // /** Parses a shortform move in any dynamic format to a readable json. */ // function parseMoveFromShortFormMove(shortFormMove: string): MoveParsed { // const moveRegex = new RegExp(`^${getMoveRegexSource(true)}$`); // const match = moveRegex.exec(shortFormMove); // if (match === null) throw Error('Invalid shortform move: ' + shortFormMove); // return getParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups); // } /** * Takes the result.groups of a regex match and parses them into a move. * * Throws an error if the coordinates would become Infinity when cast to * a javascript number, or if the promoted piece abbreviation is invalid. */ function getParsedMoveFromNamedCapturedMoveGroups( capturedGroups: NamedCaptureMoveGroups, ): MoveParsed { const startCoordsKey = capturedGroups!.startCoordsKey; const endCoordsKey = capturedGroups!.endCoordsKey; const promotionAbbr = capturedGroups!.promotionAbbr; const comment = capturedGroups!.comment; const startCoords = coordutil.getCoordsFromKey(startCoordsKey); const endCoords = coordutil.getCoordsFromKey(endCoordsKey); const parsedMove: MoveParsed = { startCoords, endCoords, token: getCompactMoveFromParts(startCoordsKey, endCoordsKey, promotionAbbr), }; if (promotionAbbr) parsedMove.promotion = getTypeFromAbbr(promotionAbbr); if (comment) { // Parse the human readable comment from the embeded command sequences const parsedComment = icncommentutils.extractCommandsFromComment(comment); parsedMove.comment = parsedComment.comment; parsedComment.commands.forEach((cmdObj) => { if (cmdObj.command === 'clk') parsedMove.clockStamp = icncommentutils.getMillisFromClkTimeValue(cmdObj.value); }); } return parsedMove; } // Compacting & Parsing Move Lists -------------------------------------------------------------------------------- /** * Converts a gamefile's moves list into shortform, ready to place into the ICN. * Various styling options are available: * * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8=Q' * IF FALSE THEN THE MOVES must have their `type` and `flags` properties!!! * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +' * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}' * move_numbers => Include move numbers, prettifying the notation. This makes turnOrder, fullmove, and make_new_lines required. * make_new_lines => Include new lines between move numbers (only when move_numbers = true) */ function getShortFormMovesFromMoves( moves: MovePreprint[], options: { compact: boolean; spaces: boolean; comments: boolean } & ( | { move_numbers: false } | { move_numbers: true; turnOrder: Player[]; fullmove: number; make_new_lines: boolean } ), ): string { // console.log("Getting shortform moves with options:", options); // Converts a gamefile's moves list to the most minimal and compact string notation `1,2>3,4|5,6>7,8=N` if (options.compact && !options.spaces && !options.comments && !options.move_numbers) return moves.map((move) => move.token).join('|'); // Most efficient, as the MoveFull already has the compact form. if (!options.move_numbers) { const shortforms = moves.map((move) => getShortFormMoveFromMove(move, options)); const moveDelimiter = options.spaces ? ' | ' : '|'; return shortforms.join(moveDelimiter); } // Include move_numbers with the notation return getShortFormMovesFromMoves_MoveNumbers(moves, options); // Beautiful form with move numbers, new lines, and comments! } /** * Converts a gamefile's moves list to a NUMBERED shortform notation. * Various styling options are available: * * compact => Exclude piece abbreviations, 'x', '+' or '#' markers => '1,7>2,8Q' * spaces => Spaces between segments of a move. => 'P1,7 x 2,8 =Q +' * comments => Include move comments and clk embeded command sequences => 'P1,7x2,8=Q+{[%clk 0:09:56.7]}' * make_new_lines => Include new lines between move numbers */ function getShortFormMovesFromMoves_MoveNumbers( moves: MovePreprint[], options: { turnOrder: Player[]; fullmove: number; compact: boolean; spaces: boolean; comments: boolean; make_new_lines: boolean; }, ): string { /** * Example preview: (compact = false, spaces = true, comments = true, fullmove = 1) * * 1. P4,2 > 4,4 | p4,7 > 4,6 * 2. P4,4 > 4,5 | p3,7 > 3,5 * 3. P4,5 x 3,6 {White captures en passant} | b6,8 > 3,11 * 4. P3,6 x 2,7 | b3,11 > -4,4 ? * 5. P2,7 x 1,8 =Q | b-4,4 > 2,-2 + * 6. K5,1 > 4,2 | n7,8 > 6,6 * 7. Q1,8 x 2,8 | k5,8 > 7,8 {Castling} * 8. Q2,8 x 1,7 | q4,8 > 0,4 * 9. Q1,7 > 7,13 + | k7,8 > 8,8 * 10. Q7,13 x 7,7 + {Queen sacrifice} | k8,8 x 7,7 !! * 11. P8,2 > 8,4 ?! | q0,4 > 4,4 # {Bad game from both players} */ /** If true, we can read move.token */ const mostCompactForm = options.compact && !options.spaces && !options.comments; const moveLines: string[] = []; let currentLine: string = ''; moves.forEach((move, i) => { const turnIndex = i % options.turnOrder.length; // If turn index is 0, start out with the move number if (turnIndex === 0) currentLine += `${Math.floor(i / options.turnOrder.length) + options.fullmove}. `; // Else add the move delimiter else currentLine += ' | '; // Add the shortform move to the current line currentLine += mostCompactForm ? move.token : getShortFormMoveFromMove(move, options); // If turn index is the last player, push the current line and start a new one. if (turnIndex === options.turnOrder.length - 1) { moveLines.push(currentLine); currentLine = ''; } }); // If the last line is not empty, push it to the lines. if (currentLine !== '') moveLines.push(currentLine); const linesDelimiter = options.make_new_lines ? '\n' : ' '; return moveLines.join(linesDelimiter); } /** Parses the shortform moves of an ICN into a JSON readable format. */ function parseShortFormMoves(shortformMoves: string): MoveParsed[] { // console.log("Parsing shortform moves:", shortformMoves); const moves: MoveParsed[] = []; const moveRegex = new RegExp(getMoveRegexSource(true), 'g'); // Since the moveRegex has the global flag, exec() will return the next match each time. // NO STRING SPLITTING REQUIRED let match: RegExpExecArray | null; while ((match = moveRegex.exec(shortformMoves)) !== null) { moves.push( getParsedMoveFromNamedCapturedMoveGroups(match.groups as NamedCaptureMoveGroups), ); } // console.log("Parsed moves:", moves); return moves; } // Converting Positions ------------------------------------------------------------------------------------------ /** * Accepts a gamefile's starting position and specialRights properties, returns the position in compressed notation (.e.g., "P5,6+|k15,-56|Q5000,1") * @param position - A piece iterator giving us each piece's coordsKey and pieceType. * Using an iterable (which a Map also is considered a valid input) allows * optimization elsewhere in the code, allowing us to avoid creating massive intermediate maps. * @param specialRights - The special rights of each piece in the gamefile, a set of CoordsKeys, where the piece at that coordinate can perform their special move (pawn double push, castling rights..) * @returns The position of the game in compressed form, where each piece with a + has its special move ability (.e.g., "P5,6+|k15,-56|Q5000,1") */ function getShortFormPosition( position: Iterable<[CoordsKey, number]>, specialRights: Set, ): string { const pieces: string[] = []; // ['P1,2+','P2,2+', ...] for (const [coordsKey, type] of position) { const pieceAbbr = getAbbrFromType(type); const specialRightsString = specialRights.has(coordsKey) ? '+' : ''; pieces.push(pieceAbbr + coordsKey + specialRightsString); } // Using join avoids overhead of repeatedly creating and copying large intermediate strings. return pieces.join('|'); } /** * Generates the specialRights property of a gamefile, given the provided position and gamerules. * Only gives pieces that can castle their right if they are on the same rank, and color, as the king, and at least 3 squares away * * This can be manually used to compress the starting position of variants of InfiniteChess.org to shrink the size of the code * @param position - The starting position of the gamefile, in the form 'x,y':'pawnsW' * @param pawnDoublePush - Whether pawns are allowed to double push * @param castleWith - If castling is allowed, this is what piece the king can castle with (e.g., "rooks"), otherwise leave it undefined * @returns The specialRights gamefile property, a set where entries are coordsKeys 'x,y', where the piece at that location has their special move ability (pawn double push, castling rights..) */ function generateSpecialRights( position: Map, pawnDoublePush: boolean, castleWith?: RawType, ): Set { // Make sure castleWith is with a valid piece to castle with if (castleWith !== undefined && castleWith !== r.ROOK && castleWith !== r.GUARD) throw Error(`Cannot allow castling with ${typeutil.debugType(castleWith)}!.`); const specialRights = new Set(); if (pawnDoublePush === false && castleWith === undefined) return specialRights; // Early exit /** Running list of kings discovered, 'x,y': player */ const kingsFound: Record = {}; /** Running list of pieces found that are able to castle (e.g. rooks), 'x,y': Player */ const castleWithsFound: Record = {}; for (const [key, thisPiece] of position.entries()) { const [rawType, player] = typeutil.splitType(thisPiece); if (pawnDoublePush && rawType === r.PAWN) { specialRights.add(key); } else if (castleWith && typeutil.jumpingRoyals.includes(rawType)) { specialRights.add(key); kingsFound[key] = player; } else if (castleWith && rawType === castleWith) { castleWithsFound[key] = player; } } // Only give the pieces that can castle their special move ability // if they are the same row and color as a king! if (Object.keys(kingsFound).length === 0) return specialRights; // Nothing can castle, return now. outerFor: for (const coord in castleWithsFound) { // 'x,y': player const coords = coordutil.getCoordsFromKey(coord as CoordsKey); // [x,y] for (const kingCoord in kingsFound) { // 'x,y': player const kingCoords = coordutil.getCoordsFromKey(kingCoord as CoordsKey); // [x,y] if (coords[1] !== kingCoords[1]) continue; // Not the same y level if (castleWithsFound[coord as CoordsKey] !== kingsFound[kingCoord as CoordsKey]) continue; // Their players don't match const xDist = bimath.abs(coords[0] - kingCoords[0]); if (xDist < 3) continue; // Not at least 3 squares away specialRights.add(coord as CoordsKey); // Same row and color as the king! This piece can castle. // We already know this piece can castle, we don't // need to see if it's on the same rank as any other king continue outerFor; } } return specialRights; } /** * Takes the position in compressed short form and returns the position and specialRights properties of the gamefile * @param shortposition - The compressed position of the gamefile (e.g., "K5,4+|P1,2|r500,25389") */ function generatePositionFromShortForm(shortposition: string): { position: Map; specialRights: Set; } { // console.log("Parsing shortposition:", shortposition); const position = new Map(); const specialRights = new Set(); const pieceRegex = new RegExp(getPieceEntryRegexSource(true), 'g'); // named groups are: pieceAbbr, coordsKey, specialRight // Since the moveRegex has the global flag, exec() will return the next match each time. // NO STRING SPLITTING REQUIRED let match: RegExpExecArray | null; while ((match = pieceRegex.exec(shortposition)) !== null) { const pieceAbbr = match.groups!['pieceAbbr']!; const coordsKey = match.groups!['coordsKey']! as CoordsKey; const hasSpecialRight = match.groups!['specialRight'] === '+'; const pieceType = getTypeFromAbbr(pieceAbbr); position.set(coordsKey, pieceType); if (hasSpecialRight) specialRights.add(coordsKey); } // console.log("Parsed position:", position); return { position, specialRights }; } // Other -------------------------------------------------------------------------------------------------- /** * Parses the preset squares from a compacted string form. * '23,94|23,76' */ function parsePresetSquares(presetSquares: string): Coords[] { const coordsKeys = presetSquares.split('|') as CoordsKey[]; const squares: Coords[] = coordsKeys.map(coordutil.getCoordsFromKey); // console.log("Parsed squares:", squares); return squares; } /** * Parses the preset rays from a compacted string form. * '23,94>-1,0|23,76>-1,0' */ function parsePresetRays(presetRays: string): BaseRay[] { const stringRays: string[] = presetRays.split('|'); // ['75,14>-1,0', '26,29>-1,-1'] const rays: BaseRay[] = stringRays.map((sr) => { const [startCoordsKey, vec2Key] = sr.split('>'); const start = coordutil.getCoordsFromKey(startCoordsKey as CoordsKey); const vector = coordutil.getCoordsFromKey(vec2Key as CoordsKey); return { start, vector }; }); // console.log("Parsed rays:", rays); return rays; } // Exports -------------------------------------------------------------------------------------------------------- export default { LongToShort_Format, ShortToLong_Format, getTypeFromAbbr, getCompactMoveFromDraft, parseTokenMove, getShortFormPosition, generateSpecialRights, generatePositionFromShortForm, getShortFormMovesFromMoves, parseShortFormMoves, parsePresetSquares, parsePresetRays, // Regex sources & objects wholeNumberSource, integerSource, promotionRanksSource, promotionsAllowedSource, default_promotions, default_win_condition, piece_codes_inverted, piece_codes_raw, }; export type { LongFormatIn, LongFormatOut, MovePreprint, MoveParsed, MoveCoords, PresetAnnotes }; ================================================ FILE: src/shared/chess/logic/initvariant.ts ================================================ // src/shared/chess/logic/initvariant.ts /** * This script prepares our variant when a game is constructed */ import type { Snapshot } from './gamefile.js'; import type { GameRules } from '../util/gamerules.js'; import type { CoordsKey } from '../util/coordutil.js'; import type { VariantCode } from '../variants/variantdictionary.js'; import type { PieceMoveset } from './movesets.js'; import type { RawTypeGroup } from '../util/typeutil.js'; import type { GlobalGameState } from './state.js'; import type { SpecialMoveFunction } from './specialmove.js'; import variant from '../variants/variant.js'; /** * Variant options that can be used to load a custom game, * whether local or online, instead of one of the default variants. */ interface VariantOptions { /** * The full move number of the turn at the provided position. Default: 1. * Can be higher if you copy just the positional information in a game with some moves played already. */ fullMove: number; gameRules: GameRules; /** * The starting position object, containing the pieces organized by key. * The key of the object is the coordinates of the piece as a string, * and the value is the type of piece on that coordinate (e.g. [22] pawn (neutral)) */ position: Map; /** The 3 global game states */ state_global: GlobalGameState; } /** * Returns the game rules for the variant. * If variant options are provided, their embedded gameRules are used directly. * @param variantCode - The variant code, or null for custom/pasted positions. * @param timestamp - The game's start timestamp in ms since epoch. * @param [options] - Variant options that override the default variant gamerules. */ function getVariantGamerules( variantCode: VariantCode | null, timestamp: number, options?: VariantOptions, ): GameRules { // Ignores the variant code, and just uses the specified gameRules if (options) return options.gameRules; // Default (built-in variant, not pasted) if (variantCode === null) return variant.getBareMinimumGameRules(); return variant.getGameRulesOfVariant(variantCode, timestamp); } /** * Returns the piece movesets and special moves for the variant. * @param variantCode - The variant code, or null for custom/pasted positions. * @param timestamp - The game's start timestamp in ms since epoch. * @param [slideLimit] - Overrides the slideLimit gamerule of the variant, if specified. */ function getPieceMovesets( variantCode: VariantCode | null, timestamp: number, slideLimit?: bigint, ): { pieceMovesets: RawTypeGroup<() => PieceMoveset>; specialMoves: RawTypeGroup; } { const pieceMovesets = variant.getMovesetsOfVariant(variantCode, timestamp, slideLimit); const specialMoves = variant.getSpecialMovesOfVariant(variantCode, timestamp); return { pieceMovesets, specialMoves, }; } /** * Fills in any holes in the provided variant options with the variant defaults. * @param variantCode - The variant code, or null for custom/pasted positions. * @param timestamp - The game's start timestamp in ms since epoch. * @param [variantOptions] - The variant options. If position is not specified, the variant code must be provided. */ function getVariantVariantOptions( gamerules: GameRules, variantCode: VariantCode | null, timestamp: number, variantOptions?: VariantOptions, ): { position: Snapshot['position']; state_global: Snapshot['state_global']; fullMove: number; } { let position: Snapshot['position']; let fullMove: Snapshot['fullMove']; // The 3 global game states let specialRights: Snapshot['state_global']['specialRights']; let enpassant: Snapshot['state_global']['enpassant']; let moveRuleState: Snapshot['state_global']['moveRuleState']; // Even IF options are provided. If the pasted game doesn't contain position information // then we still have to grab it from the variant! if (variantOptions) { position = variantOptions.position; fullMove = variantOptions.fullMove; specialRights = variantOptions.state_global.specialRights; enpassant = variantOptions.state_global.enpassant; if ( variantOptions.gameRules.moveRule !== undefined && variantOptions.state_global.moveRuleState === undefined ) throw Error('If moveRule is specified, moveRuleState must also be specified.'); moveRuleState = variantOptions.state_global.moveRuleState; } else if (variantCode !== null) { ({ position, specialRights } = variant.getStartingPositionOfVariant( variantCode, timestamp, )); fullMove = 1; // Every variant has the exact same fullMove value. if (gamerules.moveRule !== undefined) moveRuleState = 0; // Every variant has the exact same initial moveRuleState value. } else throw Error('Cannot get starting position without a variant code or variant options.'); // console.log("Variant options:", variantOptions); const state_global: Snapshot['state_global'] = { specialRights }; if (enpassant) state_global.enpassant = enpassant; if (moveRuleState !== undefined) state_global.moveRuleState = moveRuleState; return { position, state_global, fullMove, }; } export type { VariantOptions }; export default { getVariantGamerules, getPieceMovesets, getVariantVariantOptions, }; ================================================ FILE: src/shared/chess/logic/insufficientmaterial.ts ================================================ // src/shared/chess/logic/insufficientmaterial.ts /** * This script detects draws by insufficient material. */ import type { Board } from './gamefile.js'; import type { Coords } from '../util/coordutil.js'; import type { GameRules } from '../util/gamerules.js'; import type { GameConclusion } from '../util/winconutil.js'; import bimath from '../../util/math/bimath.js'; import jsutil from '../../util/jsutil.js'; import moveutil from '../util/moveutil.js'; import boardutil from '../util/boardutil.js'; import gamerules from '../util/gamerules.js'; import coordutil from '../util/coordutil.js'; import typeutil, { Player } from '../util/typeutil.js'; import { rawTypes as r, ext as e, players as p, TypeGroup } from '../util/typeutil.js'; // Types ----------------------------------------------------------------------- /** * Represents a piece's count, using a tuple for bishops to count them on light and dark squares separately. * The tuple should be SORTED in descending order! Otherwise, some insuffmat checks won't work. * i.e. whatever light/dark square has the most bishops should be the first entry of the tuple. */ type PieceCount = number | [number, number]; /** Defines an object mapping piece types to their counts, representing a specific collection of pieces on the board. */ type Scenario = TypeGroup; // Constants ------------------------------------------------------------------- /** * If the world border exists and is closer than this number in any direction, * then take the world border under consideration when doing insuffmat checks. * * Chosen to be as small as possible yet realistically never actually be reached in practice. */ const boundForWorldBorderConsideration = 1_000_000n; /** * List of scenarios that are a draw by insufficient material (checkmate and helpmate impossible). * In each of these, black is the one being asked whether they're checkmateable. * * Entries for bishops are given by tuples ordered in descending order, because * of parity, so that bishops on different colored squares are treated separately. */ const INSUFFMAT_SCENARIOS: readonly Scenario[] = [ // Both sides have one king ...withPieces({ [r.KING + e.W]: 1, [r.KING + e.B]: 1 }, [ { [r.QUEEN + e.W]: 1, [r.QUEEN + e.B]: 1 }, { [r.QUEEN + e.W]: 1, [r.ROOK + e.B]: 1 }, { [r.QUEEN + e.W]: 1, [r.BISHOP + e.B]: [1, 0], [r.KNIGHT + e.B]: 1 }, { [r.QUEEN + e.W]: 1, [r.BISHOP + e.B]: [1, 1] }, { [r.QUEEN + e.W]: 1, [r.KNIGHT + e.B]: 2 }, { [r.QUEEN + e.W]: 1, [r.PAWN + e.B]: 1 }, { [r.ROOK + e.W]: 1, [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.B]: 1 }, { [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 1, [r.KNIGHT + e.B]: 1 }, { [r.ROOK + e.W]: 1, [r.ROOK + e.B]: 1 }, { [r.ROOK + e.W]: 1, [r.BISHOP + e.B]: [1, 1] }, { [r.ROOK + e.W]: 1, [r.BISHOP + e.B]: [1, 0], [r.KNIGHT + e.B]: 1 }, { [r.ROOK + e.W]: 1, [r.KNIGHT + e.B]: 2 }, { [r.ROOK + e.W]: 1, [r.PAWN + e.B]: 1 }, { [r.BISHOP + e.W]: [Infinity, 1] }, { [r.BISHOP + e.W]: [Infinity, 0], [r.KNIGHT + e.W]: 1 }, { [r.BISHOP + e.W]: [Infinity, 0], [r.PAWN + e.B]: 1 }, { [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.W]: 1 }, { [r.BISHOP + e.W]: [1, 1], [r.BISHOP + e.B]: [1, 0] }, { [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.B]: 1 }, { [r.BISHOP + e.W]: [1, 1], [r.PAWN + e.B]: 1 }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 2 }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.BISHOP + e.B]: [1, 0] }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.BISHOP + e.B]: [0, 1] }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.KNIGHT + e.B]: 1 }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1, [r.PAWN + e.B]: 1 }, { [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.B]: 2 }, { [r.KNIGHT + e.W]: 3 }, // 1K3N-1k { [r.KNIGHT + e.W]: 2, [r.KNIGHT + e.B]: 1 }, // 1K2N-1k1n { [r.KNIGHT + e.W]: 2, [r.PAWN + e.B]: 1 }, { [r.PAWN + e.W]: 3, [r.PAWN + e.B]: 1 }, // Fairy scenarios { [r.CHANCELLOR + e.W]: 1 }, { [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [1, 0] }, { [r.ARCHBISHOP + e.W]: 1, [r.KNIGHT + e.W]: 1 }, { [r.KNIGHTRIDER + e.W]: 2 }, { [r.HAWK + e.W]: 2 }, { [r.HAWK + e.W]: 1, [r.BISHOP + e.W]: [1, 0] }, { [r.HUYGEN + e.W]: 2, [r.HUYGEN + e.B]: 1 }, // 1K2HU-1k1hu { [r.GUARD + e.W]: 1 }, ]), // Only one side has a king (black, the side being checkmated) ...withPieces({ [r.KING + e.B]: 1 }, [ { [r.QUEEN + e.W]: 1, [r.ROOK + e.W]: 1 }, { [r.QUEEN + e.W]: 1, [r.KNIGHT + e.W]: 1 }, { [r.QUEEN + e.W]: 1, [r.BISHOP + e.W]: [1, 0] }, { [r.QUEEN + e.W]: 1, [r.PAWN + e.W]: 1 }, { [r.ROOK + e.W]: 2, [r.BISHOP + e.W]: [1, 0] }, { [r.ROOK + e.W]: 2, [r.KNIGHT + e.W]: 1 }, { [r.ROOK + e.W]: 2, [r.PAWN + e.W]: 1 }, { [r.ROOK + e.W]: 1, [r.BISHOP + e.W]: [1, 0], [r.KNIGHT + e.W]: 1 }, { [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 2 }, { [r.ROOK + e.W]: 1, [r.KNIGHT + e.W]: 1, [r.PAWN + e.W]: 1 }, { [r.BISHOP + e.W]: [Infinity, 0], [r.KNIGHT + e.W]: 2 }, { [r.BISHOP + e.W]: [2, 2] }, { [r.BISHOP + e.W]: [2, 1], [r.KNIGHT + e.W]: 1 }, { [r.BISHOP + e.W]: [1, 1], [r.KNIGHT + e.W]: 2 }, { [r.KNIGHT + e.W]: 4 }, { [r.PAWN + e.W]: 6 }, // Fairy scenarios { [r.AMAZON + e.W]: 1 }, { [r.CHANCELLOR + e.W]: 1, [r.ROOK + e.W]: 1 }, { [r.CHANCELLOR + e.W]: 1, [r.KNIGHT + e.W]: 1 }, { [r.ARCHBISHOP + e.W]: 2 }, { [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [2, 0] }, { [r.ARCHBISHOP + e.W]: 1, [r.BISHOP + e.W]: [1, 1] }, { [r.ARCHBISHOP + e.W]: 1, [r.KNIGHT + e.W]: 2 }, { [r.KNIGHTRIDER + e.W]: 3 }, { [r.HUYGEN + e.W]: 4 }, ]), // Only royals -> Can never check each other let alone checkmate each other { [r.KING + e.W]: Infinity, [r.ROYALCENTAUR + e.W]: Infinity, [r.KING + e.B]: Infinity, [r.ROYALCENTAUR + e.B]: Infinity, }, // For practice checkmate 2AM-1rc { [r.AMAZON + e.W]: 1, [r.ROYALCENTAUR + e.B]: 1 }, ]; /** * Same as {@link INSUFFMAT_SCENARIOS} but for games with a world border nearby. * These are less strict, as you require less pieces to be able to checkmate * when receiving help from the world border. */ const INSUFFMAT_SCENARIOS_FINITE: readonly Scenario[] = [ // Both sides have one king ...withPieces({ [r.KING + e.W]: 1, [r.KING + e.B]: 1 }, [ { [r.BISHOP + e.W]: [Infinity, 0], [r.BISHOP + e.B]: [Infinity, 0] }, { [r.KNIGHT + e.W]: 1 }, ]), // Only royals -> Can never check each other let alone checkmate each other (same as infinite case) { [r.KING + e.W]: Infinity, [r.ROYALCENTAUR + e.W]: Infinity, [r.KING + e.B]: Infinity, [r.ROYALCENTAUR + e.B]: Infinity, }, ]; // Validate at run time that no scenario is a subset of another { for (const scenarios of [INSUFFMAT_SCENARIOS, INSUFFMAT_SCENARIOS_FINITE]) { for (let i = 0; i < scenarios.length; i++) { for (let j = 0; j < scenarios.length; j++) { if (i === j) continue; if ( isSubsumedBy(scenarios[i]!, scenarios[j]!) || isSubsumedBy(invertScenario(scenarios[i]!), scenarios[j]!) ) { throw new Error( `Redundant insuffmat scenario:\n${makeScenReadable(scenarios[i]!)} IS A SUBSET OF:\n${makeScenReadable(scenarios[j]!)}.`, ); } } } } } function makeScenReadable(scen: Scenario): string { const transformed = Object.fromEntries( Object.entries(scen).map(([key, val]) => [typeutil.debugType(Number(key)), val]), ); return JSON.stringify(transformed); } // Helpers ---------------------------------------------------------------------- /** * Merges a set of additional pieces into every scenario in the list. * Used to factor out pieces that are implicitly shared across a group of scenarios. * @param addedPieces - the pieces to add to every scenario in the list * @param scenarios - the list of scenarios to add the pieces to */ function withPieces(addedPieces: Scenario, scenarios: readonly Scenario[]): Scenario[] { return scenarios.map((s) => ({ ...addedPieces, ...s })); } /** * Checks if scenario a is subsumed by scenario b, i.e. every piece type * in a is present in b with a count at least as large. If true, a is * redundant and an insufficient material scenario. */ function isSubsumedBy(a: Scenario, b: Scenario): boolean { for (const key in a) { if (!(key in b) || hasMorePieces(a[key]!, b[key]!)) return false; } return true; } /** * Checks if a is larger than b, either as a number, or if it has some larger entry as a tuple * @param a - number or tuple of two numbers * @param b - number or tuple of two numbers */ function hasMorePieces(a: PieceCount, b: PieceCount): boolean { if (typeof a === 'number' && typeof b === 'number') { return a > b; } else if (a instanceof Array && b instanceof Array) { const bArray = b as [number, number]; return a[0] > bArray[0] || a[1] > bArray[1]; } else { throw new Error(`[Insuffmat] Invalid piece count comparison between ${a} and ${b}`); } } /** * Detects if the provided piecelist scenario is a draw by insufficient material * @param scenario - scenario of piececounts in the game, e.g. {'kingsB': 1, 'kingsW': 1, 'queensW': 3} * @param boardIsFinite - Whether the world border is close enough to assist with checkmate. * @returns *true*, if the scenario is a draw by insufficient material, otherwise *false* */ function isScenarioInsuffMat(scenario: Scenario, boardIsFinite: boolean): boolean { const scenarios = boardIsFinite ? INSUFFMAT_SCENARIOS_FINITE : INSUFFMAT_SCENARIOS; return scenarios.some((drawScenario) => isSubsumedBy(scenario, drawScenario)); } /** * Returns the parity of the square coordinates. * 0 = Dark square. 1 = Light square. */ function getCoordsParity(coords: Coords): 0 | 1 { return Number(bimath.abs(coords[0] + coords[1]) % 2n) as 0 | 1; } function sumTupleCount(tuple: [number, number]): number { return tuple[0] + tuple[1]; } function orderTupleDescending(tuple: [number, number]): [number, number] { if (tuple[0] < tuple[1]) return [tuple[1], tuple[0]]; else return tuple; } /** * Normalizes bishop parity tuples in a scenario in place. * * White's bishop tuple is sorted into descending order. * Black's bishop tuple uses the **same** swap direction as white's, * so the relative parity between sides is preserved. * * When white has no bishops, or white has equal counts on both square colors * (parity is irrelevant for white in that case), black's tuple is sorted independently. */ function normalizeBishopParities(scen: Scenario): void { const wb = scen[r.BISHOP + e.W] as [number, number] | undefined; const bb = scen[r.BISHOP + e.B] as [number, number] | undefined; let didSwapWhite = false; if (wb !== undefined) { if (wb[0] < wb[1]) { scen[r.BISHOP + e.W] = [wb[1], wb[0]]; didSwapWhite = true; } } if (bb !== undefined) { if (didSwapWhite) { // Apply the same swap as white to preserve relative parity between sides. scen[r.BISHOP + e.B] = [bb[1], bb[0]]; } else if (wb === undefined || wb[0] === wb[1]) { // No white bishops, or white has equal counts on both colors // (parity is irrelevant for white) — sort black independently. scen[r.BISHOP + e.B] = orderTupleDescending(bb); } // else: white is already descending with unequal counts — black stays // as-is to preserve the relative parity relationship. } } // Main Logic --------------------------------------------------------------- /** Whether the position supports insufficient material checks. */ function doesPositionSupportInsuffmat(gameRules: GameRules, boardsim: Board): boolean { // Is the win condition is checkmate for both players? if ( !gamerules.doesColorHaveWinCondition(gameRules, p.WHITE, 'checkmate') || !gamerules.doesColorHaveWinCondition(gameRules, p.BLACK, 'checkmate') ) return false; if ( gamerules.getWinConditionCountOfColor(gameRules, p.WHITE) !== 1 || gamerules.getWinConditionCountOfColor(gameRules, p.BLACK) !== 1 ) return false; // Was the last move a capture or promotion const lastMove = moveutil.getLastMove(boardsim.moves); if (lastMove && !(lastMove.flags.capture || lastMove.promotion !== undefined)) return false; // Is there less than 11 non-obstacle or gargoyle pieces? if ( boardutil.getPieceCountOfGame(boardsim.pieces, { ignoreRawTypes: new Set([r.OBSTACLE]), ignoreColors: new Set([p.NEUTRAL]), }) + boardutil.getPieceCountOfType(boardsim.pieces, r.VOID + e.N) >= 11 ) return false; return true; } /** * Builds the current piece scenario that is on the board. * @param boardsim * @param exclude - Optional function, run for each piece, that returns * whether that piece should be excluded from the scenario. */ function buildBoardScenario(boardsim: Board, exclude?: (coords: Coords) => boolean): Scenario { // Create scenario object listing amount of all non-obstacle pieces in the game const scenario: Scenario = {}; // bishops are treated specially and separated by parity const bishopsW_count: [number, number] = [0, 0]; const bishopsB_count: [number, number] = [0, 0]; for (const idx of boardsim.pieces.coords.values()) { const piece = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx)!; const [rawType, player] = typeutil.splitType(piece.type); if (rawType === r.OBSTACLE) continue; if (exclude && exclude(piece.coords)) continue; // Exlude this piece as specified by the custom exclude() function else if (rawType === r.BISHOP) { const parity: 0 | 1 = getCoordsParity(piece.coords); if (player === p.WHITE) bishopsW_count[parity] += 1; else if (player === p.BLACK) bishopsB_count[parity] += 1; } else if (piece.type in scenario) { const currentCount = scenario[piece.type]; if (typeof currentCount === 'number') scenario[piece.type] = currentCount + 1; else console.error('[Insuffmat] currentCount is not a number'); } else scenario[piece.type] = 1; } // add bishop tuples to scenario, as [dark_count, light_count] (NOT yet sorted). if (sumTupleCount(bishopsW_count) !== 0) scenario[r.BISHOP + e.W] = bishopsW_count; if (sumTupleCount(bishopsB_count) !== 0) scenario[r.BISHOP + e.B] = bishopsB_count; return scenario; } /** * Inverts the player of each scenario piece and returns a new scenario. * Non-mutating. */ function invertScenario(scenario: Scenario): Scenario { // Create scenario object with inverted players const invertedScenario: Scenario = {}; for (const pieceTypeStr in scenario) { const pieceInverted = typeutil.invertType(Number(pieceTypeStr)); invertedScenario[pieceInverted] = scenario[pieceTypeStr]!; } // Re-normalize bishop parities after inversion: what was black's tuple // (preserved relative to white) is now white's, and may be in ascending order. normalizeBishopParities(invertedScenario); return invertedScenario; } /** * Detects if the game is drawn by insufficient material, * returning the game conclusion if so. */ export function detectInsufficientMaterial( gameRules: GameRules, boardsim: Board, ): GameConclusion | undefined { if (!doesPositionSupportInsuffmat(gameRules, boardsim)) return undefined; const boardScenariosToCheck = buildBoardScenarios(gameRules, boardsim); if (boardScenariosToCheck === false) return undefined; // Too many promotable pawns, skip insuffmat check entirely to avoid exponential blowup. // console.log('Checking insuffmat scenarios:', boardScenariosToCheck.map(makeScenReadable)); const invertedBoardScenariosToCheck = boardScenariosToCheck.map((scen) => invertScenario(scen)); // Is the world border close enough to assist checkmate? // prettier-ignore const boardIsFinite = gameRules.worldBorder === undefined ? false : (gameRules.worldBorder.bottom !== null && -gameRules.worldBorder.bottom <= boundForWorldBorderConsideration) || (gameRules.worldBorder.left !== null && -gameRules.worldBorder.left <= boundForWorldBorderConsideration) || (gameRules.worldBorder.right !== null && gameRules.worldBorder.right <= boundForWorldBorderConsideration) || (gameRules.worldBorder.top !== null && gameRules.worldBorder.top <= boundForWorldBorderConsideration); // It is draw by insuffmat if EVERY board scenario pair is insuffmat. // A pair is insuffmat if itself OR its invert is insuffmat. for (let i = 0; i < boardScenariosToCheck.length; i++) { const scenario = boardScenariosToCheck[i]!; const invertedScenario = invertedBoardScenariosToCheck[i]!; if ( !isScenarioInsuffMat(scenario, boardIsFinite) && !isScenarioInsuffMat(invertedScenario, boardIsFinite) ) { // console.log('Scenario is not insuffmat:', makeScenReadable(scenario)); return undefined; // At least one scenario pair is not insuffmat } } // Every scenario pair tested has been insuffmat return { victor: null, condition: 'insuffmat' }; } /** * Builds all board scenarios to check for insufficient material, accounting for * all possible promotion outcomes of up to 2 promotable pawns. * Returns false if there are 3+ promotable pawns (skip insuffmat check entirely). */ function buildBoardScenarios(gameRules: GameRules, boardsim: Board): Scenario[] | false { // Collect all promotable pawns (across all players) into a flat list const promotablePawns: Array<{ coords: Coords; player: Player; pawnType: number }> = []; for (const idx of boardsim.pieces.coords.values()) { const piece = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx)!; const [rawType, player] = typeutil.splitType(piece.type); if (rawType !== r.PAWN) continue; // Not a pawn if (player === p.NEUTRAL) continue; // Player neutral can't even move pieces let alone promote pawns if ((gameRules.promotionsAllowed?.[player]?.length ?? 0) === 0) continue; // None of them are promotable (this player can't promote to anything) if ((gameRules.promotionRanks?.[player]?.length ?? 0) === 0) continue; // Player has no promotion ranks to promote at // ASSUME the pawn is behind a promotion rank. // Worst case if it isn't: insuffmat isn't triggered when it could be. promotablePawns.push({ coords: piece.coords, player, pawnType: piece.type }); } // Due to exponential computation (S^P where S is the number of promotion states and P is the // number of promotable pawns), skip the insuffmat check entirely if there are 3+ promotable pawns. if (promotablePawns.length > 2) return false; // Build a pawnless base scenario with all promotable pawns excluded. const pawnlessScenario = buildBoardScenario(boardsim, (coords) => promotablePawns.some((pawn) => coordutil.areCoordsEqual(coords, pawn.coords)), ); /** * One possible piece a promotable pawn could become (including staying as a pawn). * Bishops use `bishopParity` since the promotion square color can't * be predicted, so each color is a separate outcome to check. */ type PawnOutcome = { pieceType: number; bishopParity?: 0 | 1 }; /** Returns every possible outcome for a pawn: staying unpromoted, or each promotion piece. */ function getPawnOutcomes(pawn: { player: Player; pawnType: number }): PawnOutcome[] { const outcomes: PawnOutcome[] = [{ pieceType: pawn.pawnType }]; // stays as pawn for (const promotionRawType of gameRules.promotionsAllowed![pawn.player]!) { const pieceType = typeutil.buildType(promotionRawType, pawn.player); if (promotionRawType === r.BISHOP) { outcomes.push({ pieceType, bishopParity: 0 }); outcomes.push({ pieceType, bishopParity: 1 }); } else { outcomes.push({ pieceType }); } } return outcomes; } /** Helper to apply the given pawn outcome to the given scenario, returning a new scenario. Non-mutating. */ function applyOutcomeToScenario(base: Scenario, outcome: PawnOutcome): Scenario { const scen = jsutil.deepCopyObject(base); if (outcome.bishopParity !== undefined) { if (scen[outcome.pieceType] === undefined) scen[outcome.pieceType] = [0, 0]; (scen[outcome.pieceType] as [number, number])[outcome.bishopParity] += 1; // Do NOT sort here - parity relationships must be preserved across pawn iterations. } else { scen[outcome.pieceType] = ((scen[outcome.pieceType] as number | undefined) ?? 0) + 1; } return scen; } // For each pawn, expand the scenario list by all of its possible outcomes (Cartesian product). // For 0 promotable pawns this simply returns [pawnlessScenario] (the base board scenario). let scenarios: Scenario[] = [pawnlessScenario]; for (const pawn of promotablePawns) { const outcomes = getPawnOutcomes(pawn); scenarios = scenarios.flatMap((base) => outcomes.map((outcome) => applyOutcomeToScenario(base, outcome)), ); } // Finally, normalize bishop parities, keeping sides relationships intact. for (const scen of scenarios) normalizeBishopParities(scen); return scenarios; } ================================================ FILE: src/shared/chess/logic/legalmoves.ts ================================================ // src/shared/chess/logic/legalmoves.ts /** * This script calculates legal moves */ import type { Piece } from '../util/boardutil.js'; import type { VariantCode } from '../variants/variantdictionary.js'; import type { PieceMoveset } from './movesets.js'; import type { Vec2, Vec2Key } from '../../util/math/vectors.js'; import type { OrganizedPieces } from './organizedpieces.js'; import type { Board, FullGame } from './gamefile.js'; import type { CoordsKey, Coords } from '../util/coordutil.js'; import type { CoordsTagged, MoveTagged } from './movepiece.js'; import type { RawType, Player, RawTypeGroup } from '../util/typeutil.js'; import type { IgnoreFunction, BlockingFunction } from './movesets.js'; import bimath from '../../util/math/bimath.js'; import variant from '../variants/variant.js'; import movesets from './movesets.js'; import boardutil from '../util/boardutil.js'; import coordutil from '../util/coordutil.js'; import specialdetect from './specialdetect.js'; import checkresolver from './checkresolver.js'; import organizedpieces from './organizedpieces.js'; import bounds, { UnboundedRectangle } from '../../util/math/bounds.js'; import typeutil, { players as p, rawTypes as r } from '../util/typeutil.js'; // Types --------------------------------------------------------------------------- /** * The step-count limits of a sliding direction. * * NULL === INFINITY in that direction. * * [-2,null] => Can slide 2 steps in the negative direction, or infinitely in the positive. * * For knightriders, one [2,1] hop is considered 1 step. * The range does NOT have to intersect the piece owning the slide (for example [8n, 12n], * which could be the case for colinear blocks), but limits[0] <= limits[1] is true ALWAYS. */ type SlideLimits = [bigint | null, bigint | null]; /** An object containing all the legal moves of a piece. */ interface LegalMoves { /** A list of the legal jumping move coordinates: `[[1,2], [2,1]]` */ individual: CoordsTagged[]; /** A dict containing length-2 arrays with the legal left and right slide limits: `{[1,0]:[-5, Infinity]}` */ sliding: Record; /** If provided, all sliding moves will brute-force test for check to see if their actually legal to move to. Use when our piece moves colinearly to a piece pinning it, or if our piece is a royal queen. */ brute?: boolean; /** The ignore function of the piece, to skip over moves. */ ignoreFunc: IgnoreFunction; /** Whether the generated moves are for a colinear mover (huygen). */ colinear: boolean; } /** * A dictionary of vector distances from an origin square containing * a list of raw piece types, typically that can capture from that distance. */ type Vicinity = Record; // Constants ----------------------------------------------------------------------- /** * When testing a `brute` flagged slide to see if at least one square on it is legal, * this is the maximum number of squares we will simulate, before safety exiting * and assuming there is at least one legal move. */ const MAX_BRUTE_SIMULATIONS = 200n; // Functions ----------------------------------------------------------------------- /** * Calculates the area around you in which jumping pieces can land on you from that distance. * This is used for efficient calculating if a king move would put you in check. * Must be called after the piece movesets are initialized. * In the format: `{ '1,2': ['knights', 'chancellors'], '1,0': ['guards', 'king']... }` * DOES NOT include pawn moves. * @param pieceMovesets - MUST BE TRIMMED beforehand to not include movesets of types not present in the game!!!!! * @returns The vicinity object */ function genVicinity(pieceMovesets: RawTypeGroup<() => PieceMoveset>): Vicinity { const vicinity: Record = {}; // For every type in the game... for (const [rawTypeString, movesetFunc] of Object.entries(pieceMovesets)) { const rawType = Number(rawTypeString) as RawType; const individualMoves = movesetFunc().individual ?? []; individualMoves.forEach((coords) => { const coordsKey = coordutil.getKeyFromCoords(coords); if (!(coordsKey in vicinity)) vicinity[coordsKey] = []; // Make sure it's initialized vicinity[coordsKey]!.push(rawType); // Make sure the key contains the piece type that can capture from that distance }); } return vicinity; } /** * Calculates the area around you in which special pieces HAVE A CHANCE to capture you from that distance. * This is used for efficient calculating if a move would put you in check by a special piece. * If a special piece is found at any of these distances, their legal moves are calculated * to see if they would check you or not. * This saves us from having to iterate through every single * special piece in the game to see if they would check you. * @param variantCode - The variant code, or null for custom/pasted positions. * @param timestamp - The game's start timestamp in ms since epoch. * @param existingRawTypes * @returns The specialVicinity object, in the format: `{ '1,1': ['pawns'], '1,2': ['roses'], ... }` */ function genSpecialVicinity( variantCode: VariantCode | null, timestamp: number, existingRawTypes: RawType[], ): Vicinity { const specialVicinityByPiece = variant.getSpecialVicinityOfVariant(variantCode, timestamp); const vicinity = {} as Vicinity; // Object keys are strings, so we need to cast the type to a number for (const [rawTypeString, pieceVicinity] of Object.entries(specialVicinityByPiece)) { const rawType = Number(rawTypeString) as RawType; if (!existingRawTypes.includes(rawType)) continue; // This piece isn't present in our game pieceVicinity.forEach((coords) => { const coordsKey = coordutil.getKeyFromCoords(coords as Coords); // typescript doesn't realize vicinity[coordsKey] is gauranteed to be defined // after this statement if we use (coordsKey in vicinity) for some reason if (!vicinity[coordsKey]) vicinity[coordsKey] = []; // Make sure it's initialized vicinity[coordsKey].push(rawType); }); } return vicinity; } /** * Gets the moveset of the type of piece specified. */ function getPieceMoveset(boardsim: Board, pieceType: number): PieceMoveset { const [rawType, player] = typeutil.splitType(pieceType); // Split the type into raw and color if (player === p.NEUTRAL) return { colinear: false }; // Neutral pieces CANNOT MOVE! const movesetFunc = boardsim.pieceMovesets[rawType]; if (!movesetFunc) return { colinear: false }; // Safety net. return movesetFunc(); // Calling these parameters as a function returns their moveset. } /** * Return the piece move that's blocking function if it is specified, or the default otherwise. */ function getBlockingFuncFromPieceMoveset(pieceMoveset: PieceMoveset): BlockingFunction { return pieceMoveset.blocking || movesets.defaultBlockingFunction; } /** * Return the piece move ignore function if it is specified, or the default otherwise. */ function getIgnoreFuncFromPieceMoveset(pieceMoveset: PieceMoveset): IgnoreFunction { return pieceMoveset.ignore || movesets.defaultIgnoreFunction; } /** * Creates an empty LegalMoves object for a piece. * Should only be used outside of {@link calculateAll} when check doesn't matter or when you don't want special or calculated moves. * @param moveset the moveset belonging to the piece of the legalmoves * @returns the legal moves object */ function getEmptyLegalMoves(moveset: PieceMoveset): LegalMoves { return { individual: [], sliding: {}, ignoreFunc: getIgnoreFuncFromPieceMoveset(moveset), colinear: moveset.colinear, }; } /** * Adds all POSSIBLE individual/sliding moves from the moveset provided. * Best used for calculating premoves. */ function appendPotentialMoves(piece: Piece, moveset: PieceMoveset, legalmoves: LegalMoves): void { // Possible jumping/individual moves if (moveset.individual) { const movesetIndividual = shiftIndividualMovesetByCoords(moveset.individual, piece.coords); legalmoves.individual = legalmoves.individual.concat(movesetIndividual); } // Possible sliding moves if (moveset.sliding) { legalmoves.sliding = { ...moveset.sliding, }; } } /** * Shifts/translates the individual/jumping portion * of a moveset by the coordinates of a piece. * @param indivMoveset - The list of individual/jumping moves this moveset has: `[[1,2],[2,1]]` */ function shiftIndividualMovesetByCoords(indivMoveset: readonly Coords[], coords: Coords): Coords[] { return indivMoveset.map((indivMove) => { return [indivMove[0] + coords[0], indivMove[1] + coords[1]]; }); } /** * Adds any of the pieces movesets applicable special moves * @param gamefile * @param piece * @param moveset * @param legalmoves * @param premove - Default: false. SET TO TRUE when you need to calculate premoves, which allow all possible moves! */ function appendSpecialMoves( gamefile: FullGame, piece: Piece, moveset: PieceMoveset, legalmoves: LegalMoves, premove: boolean, ): void { const color = typeutil.getColorFromType(piece.type); if (moveset.special) legalmoves.individual.push(...moveset.special(gamefile, piece.coords, color, premove)); } /** * Removes moves that either land on a friendly or void, * and adjusts slide limits based on the provided moveset's blocking function * and what pieces are in the way. * * Call BEFORE appending special moves. */ function removeObstructedMoves( boardsim: Board, worldBorder: UnboundedRectangle | undefined, piece: Piece, moveset: PieceMoveset, legalmoves: LegalMoves, premove: boolean, ): void { const color = typeutil.getColorFromType(piece.type); // Remove obstructed jumping/individual moves removeInvalidIndividualMoves(boardsim, worldBorder, legalmoves.individual, color, premove); // Block sliding moves according to obstructions if (moveset.sliding) removeObstructedSlidingMoves( boardsim, worldBorder, piece, moveset, legalmoves.sliding, color, premove, ); } /** * Accepts array of moves, returns new array with illegal moves removed due to pieces occupying. * MUTATES original array. */ function removeInvalidIndividualMoves( boardsim: Board, worldBorder: UnboundedRectangle | undefined, individualMoves: Coords[], color: Player, premove: boolean, ): Coords[] { for (let i = individualMoves.length - 1; i >= 0; i--) { const thisMove = individualMoves[i]!; const moveValidity = testSquareValidity( boardsim, worldBorder, thisMove, color, premove, false, ); if (moveValidity === 2) individualMoves.splice(i, 1); // Not legal to land on } return individualMoves; } /** * @param premove - If true, then only voids and world borders block movement. */ function removeObstructedSlidingMoves( boardsim: Board, worldBorder: UnboundedRectangle | undefined, piece: Piece, moveset: PieceMoveset, slidingMoves: Record, color: Player, premove: boolean, ): void { const blockingFunc = getBlockingFuncFromPieceMoveset(moveset); for (const [linekey, limits] of Object.entries(slidingMoves)) { const lines = boardsim.pieces.lines.get(linekey as Vec2Key); if (lines === undefined) continue; const line = coordutil.getCoordsFromKey(linekey as Vec2Key); const key = organizedpieces.getKeyFromLine(line, piece.coords); const piecesLine = lines.get(key); if (piecesLine === undefined) continue; // No pieces on this line, so no obstructions. Needed so dragarrows feature doesn't crash on empty lines. slidingMoves[linekey as Vec2Key] = slide_CalcLegalLimit( worldBorder, blockingFunc, boardsim.pieces, piecesLine, line, limits, piece.coords, color, premove, ); } } /** * Tests whether the provided coordinates can POSSIBLY be landed on * (bar legality check), and whether they should block further movement. * * 0 => Allowed, and doesn't block further movement (empty square, or premove) * 1 => Allowed, but BLOCKS further movement (enemy piece) * 2 => Blocked, and BLOCKS further movement (friendly piece or void or outside border) * * @param premove - Exempts the `capturing` requirement from being fulfilled, and allows capturing friendlies. * @param capturing - Whether the move is required to be a capture (pawn diagonal move). Default: false. Setting this to false DOES NOT require the move to be non-capturing. */ function testSquareValidity( boardsim: Board, worldBorder: UnboundedRectangle | undefined, coords: Coords, friendlyColor: Player, premove: boolean, capturing: boolean, ): 0 | 1 | 2 { // Test whether the given square lies out of bounds of the position. if (worldBorder !== undefined && !bounds.boxContainsSquare(worldBorder, coords)) return 2; const typeOnSquare = boardutil.getTypeFromCoords(boardsim.pieces, coords); if (typeOnSquare === undefined) { if (premove) return 0; // No piece, premove means capture could end up happening => legal move if (capturing) return 2; // Not a capture, yet capture is required => not legal return 0; // No piece, in bounds => legal move } return testCaptureValidity(friendlyColor, typeOnSquare, premove); } /** * Tests whether the provided piece type can POSSIBLY be captured * (bar legality check), and whether they should block further movement. * * 0 => Allowed, and doesn't block further movement (premove) * 1 => Allowed, but BLOCKS further movement (enemy piece) * 2 => Blocked, and BLOCKS further movement (friendly piece or void) * * @param premove - Allows capturing friendlies. */ function testCaptureValidity( friendlyColor: Player, typeOnSquare: number, premove: boolean, ): 0 | 1 | 2 { const rawType = typeutil.getRawType(typeOnSquare); if (rawType === r.VOID) return 2; // Void, NEVER legal if (premove) return 0; // There is a non-void piece, but we're premoving => legal move const colorOfPiece = typeutil.getColorFromType(typeOnSquare); if (friendlyColor === colorOfPiece) return 2; // Friendly piece, not legal return 1; // Enemy piece, legal move, but blocks further movement } /** * Calculates and generates all legal moves of a piece in the provided gamefile. * @param gamefile * @param piece * @returns The legal moves of that piece */ function calculateAll(gamefile: FullGame, piece: Piece): LegalMoves { const moveset = getPieceMoveset(gamefile.boardsim, piece.type); const moves = getEmptyLegalMoves(moveset); appendPotentialMoves(piece, moveset, moves); removeObstructedMoves( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piece, moveset, moves, false, ); appendSpecialMoves(gamefile, piece, moveset, moves, false); checkresolver.removeCheckInvalidMoves(gamefile, piece, moves); return moves; } /** * Calculates all possible premoves of a piece in the provided gamefile. * * Jumps can't be obstructed. * * Slides can't be blocked. * * No check pruning is made. */ function calculateAllPremoves(gamefile: FullGame, piece: Piece): LegalMoves { const moveset = getPieceMoveset(gamefile.boardsim, piece.type); const moves = getEmptyLegalMoves(moveset); appendPotentialMoves(piece, moveset, moves); removeObstructedMoves( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piece, moveset, moves, true, ); // true to only remove void and world border obstructions appendSpecialMoves(gamefile, piece, moveset, moves, true); // true to add all possible moves // SKIP removing check invalids! return moves; } /** * Takes in specified organized list, direction of the slide, the current moveset... * Shortens the moveset by pieces that block it's path. * @param blockingFunc - The function that will check if each piece on the same line needs to block the piece * @param o * @param line - The list of pieces on this line * @param step - The direction of the line: `[dx,dy]` * @param slideMoveset - How far this piece can slide in this direction: `[left,right]`. If the line is vertical, this is `[bottom,top]` * @param coords - The coordinates of the piece with the specified slideMoveset. * @param color - The color of friendlies */ function slide_CalcLegalLimit( worldBorder: UnboundedRectangle | undefined, blockingFunc: BlockingFunction, o: OrganizedPieces, line: number[], step: Vec2, slideMoveset: SlideLimits, coords: Coords, color: Player, premove: boolean, ): SlideLimits { // The default slide is [null, null] (Infinity in both directions), // change that if there are any pieces blocking our path! // The first index is always negative if it's not null (Infinity) // For most we'll be comparing the x values, only exception is the vertical lines. const axis = step[0] === 0n ? 1 : 0; const limit = [...slideMoveset] as SlideLimits; // Makes a copy // First of all, if we're using a world border, immediately shorten our slide limit to not exceed it. enforceWorldBorderOnSlideLimit(worldBorder, limit, coords, step); // Mutating // else console.error("No world border set, skipping world border slide limit check."); // Iterate through all pieces on same line for (const idx of line) { const thisPiece = boardutil.getPieceFromIdx(o, idx)!; // { type, coords } /** * 0 => Piece doesn't block * 1 => Blocked ON the square (enemy piece) * 2 => Blocked 1 before the square (friendly piece or void) */ const blockResult = blockingFunc(color, thisPiece, coords, premove); if (blockResult !== 0 && blockResult !== 1 && blockResult !== 2) throw new Error( `slide_CalcLegalLimit() not built to handle block result of "${blockResult}"!`, ); if (blockResult === 0) continue; // Not blocked. // It blocks movement... // Is the piece to the left of us or right of us? const thisPieceSteps = (thisPiece.coords[axis] - coords[axis]) / step[axis]; // Can be negative if (thisPieceSteps < 0) { // To our left // What would our new left slide limit be? If it's an opponent, it's legal to capture it. const newLeftSlideLimit = blockResult === 2 ? thisPieceSteps + 1n : thisPieceSteps; // If the piece x is closer to us than our current left slide limit, update it if (limit[0] === null || newLeftSlideLimit > limit[0]) limit[0] = newLeftSlideLimit; } else if (thisPieceSteps > 0) { // To our right // What would our new right slide limit be? If it's an opponent, it's legal to capture it. const newRightSlideLimit = blockResult === 2 ? thisPieceSteps - 1n : thisPieceSteps; // If the piece x is closer to us than our current left slide limit, update it if (limit[1] === null || newRightSlideLimit < limit[1]) limit[1] = newRightSlideLimit; } // else this is us, don't do anything. } return limit; } /** Modifies the provided slide limit in a single step direction (positive & negative) to not exceed the world border. */ function enforceWorldBorderOnSlideLimit( worldBorder: UnboundedRectangle | undefined, limit: SlideLimits, coords: Coords, step: Vec2, ): void { if (!worldBorder) return; // No world border, skip if (!bounds.boxContainsSquare(worldBorder, coords)) { // This can legitimately happen when using the drag arrows feature // to drag an arrow's piece outside of the world border. // console.warn('Piece outside world border.'); // Doesn't crash game, but does yield strange legal move results. } // Helper to apply logic for a single border const checkBound = (border: bigint | null, axis: 0 | 1, isMaxBound: boolean): void => { const axisStep = step[axis]; if (border === null || axisStep === 0n) return; // Takes advantage that bigints truncate towards zero when dividing. // The result is how many steps it would take to reach the border, but not exceed it. const stepsToIntersect = (border - coords[axis]) / axisStep; const movingTowards = isMaxBound ? axisStep > 0 : axisStep < 0; if (movingTowards) { if (limit[1] === null || stepsToIntersect < limit[1]) limit[1] = stepsToIntersect; } else { if (limit[0] === null || stepsToIntersect > limit[0]) limit[0] = stepsToIntersect; } }; // X Axis checkBound(worldBorder.left, 0, false); // Min bound checkBound(worldBorder.right, 0, true); // Max bound // Y Axis checkBound(worldBorder.bottom, 1, false); // Min bound checkBound(worldBorder.top, 1, true); // Max bound // console.log('New limit for step ', step, 'after blocked by world border:', limit); } /** * Calculates how far a given piece can legally slide (ignoring ignore functions, and ignoring check respection) * on the given line of a specific slope. * @param boardsim * @param piece * @param slide * @param slideKey - The key `C|X` of the specific organized line we need to find out how far this piece can slide on * @param organizedLine - The organized line of the above key that our piece is on */ function calcPiecesLegalSlideLimitOnSpecificLine( boardsim: Board, worldBorder: UnboundedRectangle | undefined, piece: Piece, slide: Vec2, slideKey: Vec2Key, organizedLine: number[], ): SlideLimits | undefined { const thisPieceMoveset = getPieceMoveset(boardsim, piece.type); // Default piece moveset if (!thisPieceMoveset.sliding) return; // This piece can't slide at all if (!thisPieceMoveset.sliding[slideKey]) return; // This piece can't slide ALONG the provided line // This piece CAN slide along the provided line. // Calculate how far it can slide... const blockingFunc = getBlockingFuncFromPieceMoveset(thisPieceMoveset); const friendlyColor = typeutil.getColorFromType(piece.type); return slide_CalcLegalLimit( worldBorder, blockingFunc, boardsim.pieces, organizedLine, slide, thisPieceMoveset.sliding[slideKey], piece.coords, friendlyColor, false, ); } /** * Checks if the provided move start and end coords is one of the * legal moves in the provided legalMoves object. * * **This will modify** the provided endCoords to attach any special move tags. * @param gamefile * @param legalMoves - The legalmoves object with the properties `individual`, `horizontal`, `vertical`, `diagonalUp`, `diagonalDown`. * @param startCoords - The coordinates of the piece owning the legal moves * @param endCoords - The square to test if the piece can legally move to * @param colorOfFriendly - The player color owning the piece with the legal moves * @returns *true* if the provided legalMoves object contains the provided endCoords. */ function checkIfMoveLegal( gamefile: FullGame, legalMoves: LegalMoves, startCoords: Coords, endCoords: CoordsTagged, colorOfFriendly: Player, ): boolean { // Do one of the individual moves match? const individual = legalMoves.individual; const length = !individual ? 0 : individual.length; for (let i = 0; i < length; i++) { const thisIndividual = individual[i]!; if (!coordutil.areCoordsEqual(endCoords, thisIndividual)) continue; // Subtle way of passing on the TAG of all special moves! specialdetect.transferSpecialTags_FromCoordsToCoords(thisIndividual, endCoords); return true; } if (!doSlideRangesContainSquare(legalMoves, startCoords, endCoords)) return false; if (legalMoves.brute) { // Don't allow the slide if it results in check const moveTagged = { startCoords, endCoords }; if (checkresolver.getSimulatedCheck(gamefile, moveTagged, colorOfFriendly).check) return false; // The move results in check => not legal } return true; // Move is legal } /** * Checks if the provided end coords are reachable via any slide in the provided legal moves. * @param legalMoves * @param startCoords - The coordinates of the piece owning the legal moves * @param endCoords - The square to test if the piece can slide to * @returns *true* if the endCoords lie within the sliding range. */ function doSlideRangesContainSquare( legalMoves: LegalMoves, startCoords: Coords, endCoords: Coords, ): boolean { if (coordutil.areCoordsEqual(startCoords, endCoords)) return false; // Can't slide to the square we're already on for (const [strline, limits] of Object.entries(legalMoves.sliding)) { const line = coordutil.getCoordsFromKey(strline as Vec2Key); // 'dx,dy' const selectedPieceLine = organizedpieces.getKeyFromLine(line, startCoords); const clickedCoordsLine = organizedpieces.getKeyFromLine(line, endCoords); if (selectedPieceLine !== clickedCoordsLine) continue; // Continue if they don't lie on the same line // prettier-ignore if (doesSlidingMovesetContainSquare(limits, line, startCoords, endCoords, legalMoves.ignoreFunc)) return true; } return false; } /** * Tests if the piece's precalculated slideMoveset is able to reach the provided coords. * ASSUMES the coords are on the direction of travel!!! * @param slideMoveset - The distance the piece can move along this line: `[left,right]`. If the line is vertical, this will be `[bottom,top]`. * @param direction - The direction of the line: `[dx,dy]` * @param pieceCoords - The coordinates of the piece with the provided sliding net * @param coords - The coordinates we want to know if they can reach. * @param ignoreFunc - The ignore function. * @returns true if the piece is able to slide to the coordinates */ function doesSlidingMovesetContainSquare( slideMoveset: SlideLimits, direction: Vec2, pieceCoords: Coords, coords: Coords, ignoreFunc: IgnoreFunction, ): boolean { const axis = direction[0] === 0n ? 1 : 0; const coord = coords[axis]; const min: bigint | null = slideMoveset[0] === null ? null : pieceCoords[axis] + direction[axis] * slideMoveset[0]; // No need to negate direction because slideMoveset[0] is always negative const max: bigint | null = slideMoveset[1] === null ? null : pieceCoords[axis] + direction[axis] * slideMoveset[1]; return ( (min === null || coord >= min) && (max === null || coord <= max) && ignoreFunc(pieceCoords, coords) ); } /** * Accepts the calculated legal moves, tests to see if there is at least one. * In the extreme case, when the `brute` flag is present for slides, and the * slide width exceeds {@link MAX_BRUTE_SIMULATIONS}, this may return true as * a safety measure to avoid hangs, even if there may not actually be a legal move. * @param moves * @param gamefile * @param piece - The piece that owns these legal moves */ function hasAtleast1Move(moves: LegalMoves, gamefile: FullGame, piece: Piece): boolean { if (moves.individual.length > 0) return true; for (const [lineKey, limits] of Object.entries(moves.sliding)) { if (slideHasAtLeast1LegalMove(lineKey as Vec2Key, limits)) return true; } return false; /** * Checks whether a given slide range contains at least one legal move. * If the `brute` flag is present, up to {@link MAX_BRUTE_SIMULATIONS} * squares are simulated for legality before assuming there may be a * legal move and returning true for safety to avoid hangs. */ function slideHasAtLeast1LegalMove(lineKey: Vec2Key, slide: SlideLimits): boolean { if (slide[0] === null || slide[1] === null) return true; // Infinite range const rangeWidth = slide[1] - slide[0]; const offsetPositive = slide[0] > 0n; // Both limits positive const offsetNegative = slide[1] < 0n; // Both limits negative // Any non-empty range means there is at least one legal move if (!moves.brute) { // EXCEPTION: Width can be 0 if there is an offset (not centered on the piece) return rangeWidth > 0n || offsetPositive || offsetNegative; } // Brute flag is present... this will require simulating moves for check // If the range width is greater than our cap, just assume there's at least one legal move to avoid hangs. if (rangeWidth > MAX_BRUTE_SIMULATIONS) return true; const step = coordutil.getCoordsFromKey(lineKey); const color = typeutil.getColorFromType(piece.type); /** Simulates a single candidate step. Returns true if it's a legal move, false otherwise. */ function tryStep(s: bigint): boolean { const targetCoords: Coords = [ piece.coords[0] + step[0] * s, piece.coords[1] + step[1] * s, ]; if (!moves.ignoreFunc(piece.coords, targetCoords)) return false; // Not a valid landing (e.g., not prime for Huygens) const moveTagged: MoveTagged = { startCoords: piece.coords, endCoords: targetCoords }; return !checkresolver.getSimulatedCheck(gamefile, moveTagged, color).check; } // Positive side. Skip if range is entirely negative if (!offsetNegative) { for (let s = bimath.max(1n, slide[0]); s <= slide[1]; s++) { if (tryStep(s)) return true; } } // Negative side. Skip if range is entirely positive if (!offsetPositive) { for (let s = bimath.min(-1n, slide[1]); s >= slide[0]; s--) { if (tryStep(s)) return true; } } return false; // No legal blocking move found in the bounded range } } // Exports ---------------------------------------------------------------- export type { LegalMoves, SlideLimits }; export default { genVicinity, genSpecialVicinity, getPieceMoveset, getBlockingFuncFromPieceMoveset, getIgnoreFuncFromPieceMoveset, getEmptyLegalMoves, appendPotentialMoves, removeObstructedMoves, appendSpecialMoves, testSquareValidity, testCaptureValidity, calculateAll, calculateAllPremoves, slide_CalcLegalLimit, calcPiecesLegalSlideLimitOnSpecificLine, checkIfMoveLegal, doSlideRangesContainSquare, doesSlidingMovesetContainSquare, hasAtleast1Move, }; ================================================ FILE: src/shared/chess/logic/movepiece.ts ================================================ // src/shared/chess/logic/movepiece.ts /** * This script handles the logical side of moving pieces, nothing graphical. * * Both ends, client & server, should be able to use this script. */ import type { Piece } from '../util/boardutil.js'; import type { Coords } from '../util/coordutil.js'; import type { Change } from './boardchanges.js'; import type { MoveCoords } from './icn/icnconverter.js'; import type { MovePacket } from '../../types.js'; import type { GameConclusion } from '../util/winconutil.js'; import type { Board, FullGame } from './gamefile.js'; import type { EnPassant, MoveState } from './state.js'; import state from './state.js'; import bimath from '../../util/math/bimath.js'; import typeutil from '../util/typeutil.js'; import moveutil from '../util/moveutil.js'; import coordutil from '../util/coordutil.js'; import boardutil from '../util/boardutil.js'; import legalmoves from './legalmoves.js'; import boardchanges from './boardchanges.js'; import icnconverter from './icn/icnconverter.js'; import wincondition from './wincondition.js'; import specialdetect from './specialdetect.js'; import checkdetection from './checkdetection.js'; import movevalidation from './movevalidation.js'; import organizedpieces from './organizedpieces.js'; import { rawTypes as r } from '../util/typeutil.js'; // Types -------------------------------------------------------------------------------------------------------------------------- /** A special move tag name on {@link CoordsTagged}, both move tags and UI tags. */ interface SpecialTags extends MoveSpecialTags, UISpecialTags {} /** * A special move tag that is retained when transferring from {@link CoordsTagged} to a move. * This describes what actually happened during the move execution. */ interface MoveSpecialTags { /** Special move tag that, when present, making the move will create an enpassant state on the gamefile. */ enpassantCreate: EnPassant; /** * A special move tag for enpassant capture. * * If true, the specialMove function for pawns will read the gamefile's * enpassant property to figure out where the pawn to capture is. * After that, the captured piece is appended to the move's changes list, * so we don't actually need to store more information in here. */ enpassant: true; /** A special move tag for pawn promotion. This is the integer type of the piece promoted to. */ promotion: number; /** A special move tag for castling. */ castle: { /** 1 => King castled right -1 => King castled left */ dir: 1n | -1n; /** The coordinate of the piece the king castled with, usually a rook. */ coord: Coords; }; /** * A special move tag that stores a list of all the waypoints along * the travel path of a piece. Inclusive to start and end. * * Used for Rose piece. */ path: Coords[]; } /** * A special move tag that is UI-only. It is present on {@link CoordsTagged} * to signal something to the UI (e.g. open the promotion picker), and is * consumed and removed BEFORE the move is executed — never transferred to a move. */ interface UISpecialTags { /** * A special move tag that, when the move is attempted to be made should * trigger the promotion UI to open. The special detect functions are in * charge of adding this. selection.ts will delete it and open the promotion UI. */ promoteTrigger: boolean; } /** * A pair of coordinates, WITH attached special move information. * This usually denotes a legal square you can move to that will * activate said special move. */ type CoordsTagged = Coords & Partial; /** A move as stored in the base game. Does not need a lot of details. */ interface MoveRecord extends MoveCoords { /** * How much time the player had left after they made their move, in millis. * * Server is always boss, we cannot set this until after the * server responds back with the updated clock information. */ clockStamp?: number; /** The move in most compact notation: `8,7>8,8=Q` */ token: string; } /** A {@link MoveCoords} move with all special tags attached. */ type MoveTagged = MoveCoords & Partial; /** Information about some change on the chessboard, either by a move or some other property change (e.g. as used in the board editor) */ interface Edit { /** A list of changes the move made to the board, whether it moved a piece, captured a piece, added a piece, etc. */ changes: Array; /** The state of the move is used to know how to modify specific gamefile * properties when forwarding/rewinding this move. */ state: MoveState; } /** * All properties of a move needed to apply/unapply it * to/from the board state, along with other useful flags. */ interface MoveFull extends Edit, MoveTagged, MoveRecord { /** The type of piece moved */ type: number; /** The index this move was generated for. This can act as a safety net * so we don't accidentally make the move on the wrong index of the game. */ generateIndex: number; flags: { /** Whether the move delivered check. */ check: boolean; /** Whether the move delivered mate (or the killing move). */ mate: boolean; /** Whether the move caused a capture */ capture: boolean; }; /** * Any comment made on the move, specified in the ICN. * These will go back into the ICN when copying the game. */ comment?: string; } // Constants ------------------------------------------------------------------------------------------------------- /** * All special move tag names that are retained when transferring from {@link CoordsTagged} * to a move. These describe what actually happened during the move execution. */ const MOVE_SPECIAL_TAGS = [ 'enpassantCreate', 'enpassant', 'promotion', 'castle', 'path', ] satisfies ReadonlyArray; /** * All special move tag names that are UI-only. They are present on {@link CoordsTagged} * to signal something to the UI (e.g. open the promotion picker), and are * consumed and removed BEFORE the move is executed — never transferred to a move. */ const UI_SPECIAL_TAGS = ['promoteTrigger'] satisfies ReadonlyArray; /** All special move tags names on {@link CoordsTagged}, both move tags and UI tags. */ const SPECIAL_TAGS = [...MOVE_SPECIAL_TAGS, ...UI_SPECIAL_TAGS] satisfies ReadonlyArray< keyof SpecialTags >; // Move Generating -------------------------------------------------------------------------------------------------- /** * Generates a full MoveFull from a MoveTagged, then immediately applies it to the gamefile. * @returns The generated MoveFull object */ function generateAndMakeMove(gamefile: FullGame, moveTagged: MoveTagged): MoveFull { const move = generateMove(gamefile, moveTagged); makeMove(gamefile, move); return move; } /** * Generates a full MoveFull object from a MoveTagged, * calculating and appending its board changes to its Changes list, * and queueing its gamefile StateChanges. */ function generateMove(gamefile: FullGame, moveTagged: MoveTagged): MoveFull { const { boardsim } = gamefile; const piece = boardutil.getPieceFromCoords(boardsim.pieces, moveTagged.startCoords); if (!piece) throw Error( `Cannot make move because no piece exists at coords ${coordutil.stringifyCoords(moveTagged.startCoords)}.`, ); // Construct the full MoveFull object // Initialize the state, and change list, as empty for now. const move: MoveFull = { ...moveTagged, type: piece.type, changes: [], generateIndex: boardsim.state.local.moveIndex + 1, state: { local: [], global: [] }, token: icnconverter.getCompactMoveFromDraft(moveTagged), flags: { // These will be set later, but we need a default value check: false, mate: false, capture: false, }, }; /** * Delete the current enpassant state. * If any specialMove function adds a new EnPassant state, * this one's future value will be overwritten */ state.createEnPassantState(move, boardsim.state.global.enpassant, undefined); const rawType = typeutil.getRawType(move.type); let specialMoveMade: boolean = false; // If a special move function exists for this piece type, run it. // The actual function will return whether a special move was actually made or not. // If a special move IS made, we skip the normal move piece method. if (rawType in boardsim.specialMoves) specialMoveMade = boardsim.specialMoves[rawType]!(boardsim, piece, move); if (!specialMoveMade) calcMovesChanges(boardsim, piece, moveTagged, move); // Move piece regularly (no special tag) // Must be set before calling queueIncrementMoveRuleStateChange() move.flags.capture = boardchanges.wasACapture(move); // Delete all special rights that should be revoked from the move. queueSpecialRightDeletionStateChanges(boardsim, move); queueIncrementMoveRuleStateChange(gamefile, move); return move; } /** * Calculates all of a move's board changes, and "queues" them, * adding them to the move's Changes list. * * This should NOT be used if the move is a special move. * @param boardsim - The board * @param piece - The piece that's being moved * @param move - The move that's being made */ function calcMovesChanges(boardsim: Board, piece: Piece, moveCoords: MoveCoords, edit: Edit): void { const capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, moveCoords.endCoords); if (capturedPiece) boardchanges.queueCapture(edit.changes, true, capturedPiece); boardchanges.queueMovePiece(edit.changes, true, piece, moveCoords.endCoords); } /** * Queues gamefile state changes to delete all * special rights that should have been revoked from the move. * This includes the startCoords and endCoords of all move actions. */ function queueSpecialRightDeletionStateChanges(boardsim: Board, edit: Edit): void { edit.changes.forEach((change) => { if (change.action === 'move' || change.action === 'capture' || change.action === 'delete') { // Delete any special rights off the (start coords / captured piece coords / deleted piece coords). cascadeDeleteSpecialRights(boardsim, change.piece.coords, edit); } }); } /** * Helper for, when one square's special rights is deleted, * scanning the entire row for all kings and rooks that can * no longer have a valid castling partner because of it, * and deleting their special rights too. */ function cascadeDeleteSpecialRights(boardsim: Board, coords: Coords, edit: Edit): void { const coordsKey = coordutil.getKeyFromCoords(coords); const hasSpecialRight = boardsim.state.global.specialRights.has(coordsKey); if (!hasSpecialRight) return; // No special right existed on square in first place. // 1. Delete the rights of the specific piece that moved/died state.createSpecialRightsState(edit, coordsKey, hasSpecialRight, false); const piece = boardutil.getPieceFromCoords(boardsim.pieces, coords)!; const [rawType, player] = typeutil.splitType(piece.type); if (rawType === r.PAWN) return; // Pawns cannot castle, them losing their special right doesn't affect others. const isTrigger: boolean = typeutil.jumpingRoyals.includes(rawType); // Royals are the castling triggers const key = organizedpieces.getKeyFromLine([1n, 0n], coords); const row = boardsim.pieces.lines.get('1,0')!.get(key)!; // 2. Iterate through all pieces on this rank. // If they can no longer castle with any valid partner, delete their special right too. for (const idx of row) { const candidate = boardutil.getDefinedPieceFromIdx(boardsim.pieces, idx); const [candRawType, candPlayer] = typeutil.splitType(candidate.type); // Basic Validity Checks if (candPlayer !== player) continue; // Affects friends only if (candRawType === r.PAWN) continue; // Pawns don't have castling rights const candCoordsKey = coordutil.getKeyFromCoords(candidate.coords); if (!boardsim.state.global.specialRights.has(candCoordsKey)) continue; // Already has no rights // Optimization: If the piece being checked is the same "Role" as the piece that triggered this event, // it is unaffected. (e.g. Left Rook moving doesn't stop Right Rook from *potential* castling). const candidateIsTrigger = typeutil.jumpingRoyals.includes(candRawType); // Royals are the castling triggers if (candidateIsTrigger === isTrigger) continue; // 3. Search: Does this candidate have ANY valid partner remaining? const hasValidPartner = hasCastlingPartner( boardsim, candidate, // Additional constraint: The partner cannot be the piece that just moved/died (partner: Piece) => !coordutil.areCoordsEqual(partner.coords, coords), ); // If no partners were found, this piece is now impotent. Revoke its rights. if (!hasValidPartner) state.createSpecialRightsState(edit, candCoordsKey, true, false); } } /** * Determines whether a piece has any valid castling partner on the board. * @param boardsim * @param candidate - A candidate piece for castling. MUST NOT be a pawn. * @param partnerConstraint - An optional function, run for each partner, that must return true for them to be considered valid. */ function hasCastlingPartner( boardsim: Board, candidate: Piece, partnerConstraint?: (partner: Piece) => boolean, ): boolean { const [candRawType, candPlayer] = typeutil.splitType(candidate.type); // Basic Validity Checks if (candRawType === r.PAWN) throw new Error('Cannot test if pawn has valid castling partner.'); // Safety, this could be easy to accidentally pass in. const candidateIsTrigger = typeutil.jumpingRoyals.includes(candRawType); // Royals are the castling triggers const key = organizedpieces.getKeyFromLine([1n, 0n], candidate.coords); const row = boardsim.pieces.lines.get('1,0')!.get(key)!; // Search: Does this candidate have ANY valid castling partner? const hasValidPartner = row.some((partnerIdx) => { const partner = boardutil.getDefinedPieceFromIdx(boardsim.pieces, partnerIdx); const [partnerRawType, partnerPlayer] = typeutil.splitType(partner.type); // Partner Validation if (partnerPlayer !== candPlayer) return false; // Affects friends only if (partnerRawType === r.PAWN) return false; // Pawns don't have castling rights const partnerCoordsKey = coordutil.getKeyFromCoords(partner.coords); if (!boardsim.state.global.specialRights.has(partnerCoordsKey)) return false; // Partner must have rights // A valid partner must be the OPPOSITE role (King needs Rook, Rook needs King) const partnerIsTrigger = typeutil.jumpingRoyals.includes(partnerRawType); if (partnerIsTrigger === candidateIsTrigger) return false; // Distance Check: Must be at least 3 spaces away const dist = bimath.abs(candidate.coords[0] - partner.coords[0]); if (dist < 3n) return false; // Additional optional constraint checks if (partnerConstraint && !partnerConstraint(partner)) return false; return true; // Found a valid partner! }); return hasValidPartner; } /** * Increments the gamefile's moveRuleStatus property, if the move-rule is in use. */ function queueIncrementMoveRuleStateChange({ basegame, boardsim }: FullGame, move: MoveFull): void { if (!basegame.gameRules.moveRule) return; // Not using the move-rule // Reset if it was a capture or pawn movement const newMoveRule = !move.flags.capture && typeutil.getRawType(move.type) !== r.PAWN ? boardsim.state.global.moveRuleState! + 1 : 0; state.createMoveRuleState(move, boardsim.state.global.moveRuleState!, newMoveRule); } // Forwarding ------------------------------------------------------------------------------------------------------- /** * Executes all the logical board changes of a global forward move in the game, no graphical changes. */ function makeMove(gamefile: FullGame, move: MoveFull): void { gamefile.boardsim.moves.push(move); gamefile.basegame.moves.push({ startCoords: move.startCoords, endCoords: move.endCoords, promotion: move.promotion, token: move.token, // Propogate the clockStamp if already set. REQUIRED for server-side move // validated games to persist their clock information over server restarts! clockStamp: move.clockStamp, }); applyMove(gamefile, move, true, { global: true }); // Apply the logical boardsim changes. // This needs to be after the moveIndex is updated updateTurn(gamefile); // Now we can test for check, and modify the state of the gamefile if it is. createCheckState(gamefile, move); if (gamefile.boardsim.state.local.inCheck) move.flags.check = true; // The "mate" property of the move will be added after our game conclusion checks... } /** * Applies a move's board changes to the gamefile, and updates moveIndex. * No graphical changes. * @param gamefile * @param move * @param forward - Whether the move's board changes should be applied forward or backward. * @param [options.global] - If true, we will also apply this move's global state changes to the gamefile */ function applyMove( gamefile: FullGame, move: MoveFull, forward = true, { global = false } = {}, ): void { gamefile.boardsim.state.local.moveIndex += forward ? 1 : -1; // Update the gamefile moveIndex // Stops stupid missing piece errors const indexToApply = gamefile.boardsim.state.local.moveIndex + Number(!forward); if (indexToApply !== move.generateIndex) throw new Error( `Move was expected at index ${move.generateIndex} but applied at ${indexToApply} (forward: ${forward}).`, ); applyEdit(gamefile, move, forward, global); // Apply the board changes } /** * Applies a edits board changes to the gamefile. * If we're applying a board editor's move's edits, then global should be true. * @param gamefile - The gamefile to apply the edit to. * @param edit - The edit to apply, which contains the changes and state of the move. * @param global - If true, we will also apply this move's global state changes to the gamefile. Should be true if the edit is from a board editor move. * @param forward - Whether the move's board changes should be applied forward or backward. */ function applyEdit(gamefile: FullGame, edit: Edit, forward: boolean, global: boolean): void { state.applyMove(gamefile.boardsim.state, edit.state, forward, { globalChange: global }); // Apply the State of the move boardchanges.runChanges(gamefile, edit.changes, boardchanges.changeFuncs, forward); // Logical board changes } /** * Updates the `whosTurn` property of the gamefile, according to the move index we're on. */ function updateTurn(gamefile: FullGame): void { gamefile.basegame.whosTurn = moveutil.getWhosTurnAtMoveIndex( gamefile.basegame, gamefile.boardsim.state.local.moveIndex, ); } /** * Tests if the gamefile is currently in check, * then creates and set's the game state to reflect that. */ function createCheckState(gamefile: FullGame, move: MoveFull): void { const { boardsim, basegame } = gamefile; const whosTurnItWasAtMoveIndex = moveutil.getWhosTurnAtMoveIndex( basegame, boardsim.state.local.moveIndex, ); const oppositeColor = typeutil.invertPlayer(whosTurnItWasAtMoveIndex)!; // Only track checks if we're using checkmate win condition. const trackChecks = basegame.gameRules.winConditions[oppositeColor]!.includes('checkmate'); const checkResults = checkdetection.detectCheck( gamefile, whosTurnItWasAtMoveIndex, trackChecks, ); // { check: boolean, royalsInCheck: Coords[], checks?: CheckInfo[] } const futureInCheck = checkResults.check === false ? false : checkResults.royalsInCheck; // Passing in the gamefile into this method tells state.ts to immediately apply the state change. state.createCheckState(move, boardsim.state.local.inCheck, futureInCheck, boardsim.state); // Passes in the gamefile as an argument state.createChecksState( move, boardsim.state.local.checks, checkResults.checks ?? [], boardsim.state, ); // Erase the check pairs calculated from previous turn and pass in new ones } /** * Accepts a move list in the most comapact form: `['1,2>3,4','10,7>10,8Q']`, * reconstructs each move's properties, INCLUDING special tags, and makes that move * in the game. At each step it has to calculate what legal special * moves are possible, so it can pass on those flags. * * **THROWS AN ERROR** if any move during the process is in an invalid format. * @param gamefile - The gamefile * @param moves - The list of moves to add to the game, each in the most compact format: `['1,2>3,4','10,7>10,8Q']` * @param validateMoves - If true, throws an error if any move is illegal. */ function makeAllMovesInGame( gamefile: FullGame, moves: MovePacket[], validateMoves?: boolean, ): void { if (gamefile.boardsim.moves.length > 0) throw Error('Cannot make all moves in game when there are already moves played.'); for (let i = 0; i < moves.length; i++) { const shortmove = moves[i]!; // If validateMoves flag is true, check if the move is actually legal! if (validateMoves) { const validationResult = movevalidation.isTokenMoveLegal(gamefile, shortmove.token); if (!validationResult.valid) { throw Error( `Move ${i + 1} is illegal: ${shortmove.token}. Reason: ${validationResult.reason}`, ); } } const move: MoveFull = calculateMoveFromPacket(gamefile, shortmove); makeMove(gamefile, move); // Also if validateMoves flag is true, any move that comes AFTER // when the game should have ended already is considered illegal! const isLastIteration = i === moves.length - 1; if (validateMoves && !isLastIteration) { const conclusion = wincondition.getGameConclusion(gamefile); if (conclusion) throw new Error( `Moves cannot come after game ends. Move ${i + 1} should have concluded game by ${JSON.stringify(conclusion)}.`, ); } } } /** * Accepts a move in the most compact short form, and constructs the whole MoveFull object. * This has to calculate the piece's legal special * moves to be able to deduce if the move was a special move. * * **Returns undefined** if there was an error anywhere in the conversion. * * This does NOT perform legality checks, so still do that afterward. */ function calculateMoveFromPacket(gamefile: FullGame, movePacket: MovePacket): MoveFull { if (!moveutil.areWeViewingLatestMove(gamefile.boardsim)) throw Error( "Cannot calculate MoveFull object from shortmove when we're not viewing the most recently played move.", ); // Reconstruct the startCoords, endCoords, and special move properties of the MoveTagged let moveTagged: MoveTagged; try { moveTagged = icnconverter.parseTokenMove(movePacket.token); } catch (error) { console.error(error); throw Error( `Failed to calculate Move from shortmove because it's in an incorrect format: ${movePacket.token}`, ); } // Reconstruct the special move properties by calculating what legal // special moves this piece can make, comparing them to the move's endCoords, // and if there's a match, pass on the special move flag. const piece = boardutil.getPieceFromCoords(gamefile.boardsim.pieces, moveTagged.startCoords); if (!piece) { // No piece on start coordinates, can't calculate Move, because it's illegal throw Error( `Failed to calculate Move from shortmove because there's no piece on the start coords: ${movePacket.token}`, ); } const moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piece.type); const legalSpecialMoves = legalmoves.getEmptyLegalMoves(moveset); legalmoves.appendSpecialMoves(gamefile, piece, moveset, legalSpecialMoves, false); for (const thisCoord of legalSpecialMoves.individual) { if (!coordutil.areCoordsEqual(thisCoord, moveTagged.endCoords)) continue; // Matched coordinates! Transfer any special move tags specialdetect.transferSpecialTags_FromCoordsToMove(thisCoord, moveTagged); break; } const move = generateMove(gamefile, moveTagged); if (movePacket.clockStamp !== undefined) move.clockStamp = movePacket.clockStamp; return move; } // Rewinding ------------------------------------------------------------------------------------------------------- /** * Executes all the logical board changes of a global REWIND move in the game, no graphical changes. */ function rewindMove(gamefile: FullGame): void { // console.error("Rewinding move"); const move = moveutil.getMoveFromIndex( gamefile.boardsim.moves, gamefile.boardsim.state.local.moveIndex, ); applyMove(gamefile, move, false, { global: true }); // Delete the move off the end of our moves list gamefile.boardsim.moves.pop(); gamefile.basegame.moves.pop(); updateTurn(gamefile); } // Dynamic ------------------------------------------------------------------------------------------------------- /** * Iterates to a certain move index, performing a callback function on each move. * The callback should be a move application function, either {@link applyMove}, or movesequence.viewMove(), * depending on if each move should make graphical changes or not. Both methods make logical board changes. * @param {gamefile} gamefile * @param {number} index * @param {CallableFunction} callback - Either {@link applyMove}, or movesequence.viewMove() */ function goToMove(boardsim: Board, index: number, callback: (_move: MoveFull) => void): void { if (index === boardsim.state.local.moveIndex) return; const forwards = index >= boardsim.state.local.moveIndex; const offset = forwards ? 0 : 1; let i = boardsim.state.local.moveIndex; if (boardsim.moves.length <= index + offset || index + offset < 0) throw Error('Target index is outside of the movelist!'); while (i !== index) { i = moveTowards(i, index, 1); const move = boardsim.moves[i + offset]; if (move === undefined) throw Error(`Undefined move in goToMove()! ${i}, ${index}`); callback(move); } } /** * Starts with `s`, steps it by +-`progress` towards `e`, then returns that number. */ function moveTowards(s: number, e: number, progress: number): number { return s + Math.sign(e - s) * Math.min(Math.abs(e - s), progress); } // Move Wrappers ---------------------------------------------------------------------------------------------------- /** * Wraps a function in a simulated move. * The callback may be used to obtain whatever * property of the gamefile we want after the move is made. * The move is automatically rewound when it's done. * @returns Whatever is returned by the callback */ function simulateMoveWrapper(gamefile: FullGame, moveTagged: MoveTagged, callback: () => R): R { generateAndMakeMove(gamefile, moveTagged); // What info can we pull from the game after simulating this move? const info = callback(); rewindMove(gamefile); return info; } /** * Simulates a move to get the gameConclusion * @returns the gameConclusion */ function getSimulatedConclusion( gamefile: FullGame, moveTagged: MoveTagged, ): GameConclusion | undefined { return simulateMoveWrapper(gamefile, moveTagged, () => wincondition.getGameConclusion(gamefile), ); } // --------------------------------------------------------------------------------------------------------------------- export type { MoveFull, Edit, MoveRecord, MoveTagged, SpecialTags, MoveSpecialTags, CoordsTagged }; export default { // Constants MOVE_SPECIAL_TAGS, SPECIAL_TAGS, // Functions generateMove, calcMovesChanges, queueSpecialRightDeletionStateChanges, hasCastlingPartner, makeMove, generateAndMakeMove, updateTurn, goToMove, makeAllMovesInGame, applyMove, applyEdit, rewindMove, simulateMoveWrapper, getSimulatedConclusion, }; ================================================ FILE: src/shared/chess/logic/movesets.ts ================================================ // src/shared/chess/logic/movesets.ts /** * This script contains the default movesets for all pieces except specials (pawns, castling) */ import type { Piece } from '../util/boardutil.js'; import type { Coords } from '../util/coordutil.js'; import type { FullGame } from './gamefile.js'; import type { CoordsTagged } from './movepiece.js'; import type { Vec2, Vec2Key } from '../../util/math/vectors.js'; import type { RawTypeGroup, Player, RawType } from '../util/typeutil.js'; import bimath from '../../util/math/bimath.js'; import vectors from '../../util/math/vectors.js'; import legalmoves from './legalmoves.js'; import specialdetect from './specialdetect.js'; import { primalityTest } from '../../util/isprime.js'; import { rawTypes as r } from '../util/typeutil.js'; /** A Movesets object containing the movesets for every piece type in a game */ type Movesets = RawTypeGroup; /** {@link Movesets} but without the auto-generated colinear properties. */ type RawMovesets = RawTypeGroup; /** {@link PieceMoveset} but without the auto-generated colinear property. */ interface RawPieceMoveset { /** * Jumping moves immediately surrounding the piece where it can move to. * * TODO: Separate moving-moves from capturing-moves. */ individual?: Coords[]; /** * Sliding moves the piece can make. * * `"1,0": [null,null]` => Lets the piece slide horizontally infinitely in both directions. * * The *key* is the step amount of each skip, and the *value* is the skip limit in the -x and +x directions (-y and +y if it's vertical). * * THE X-KEY SHOULD NEVER BE NEGATIVE!!! And if it's 0, then Y should be positive. */ sliding?: SlidingMoves; /** * The initial function that determines how far a piece is legally able to slide * according to what pieces block it. * * This should be provided if we're not using the default. */ blocking?: BlockingFunction; /** * The secondary function that *actually* determines whether each individual * square in a slide is legal to move to. * * This should be provided if we're not using the default. */ ignore?: IgnoreFunction; /** * If present, the function to call for calculating legal special moves. */ special?: SpecialFunction; } /** A moveset for an single piece type in a game */ interface PieceMoveset extends RawPieceMoveset { /** Whether this moveset involves colinear sliding moves. Auto-generated property. */ colinear: boolean; } /** * Sliding moves the piece can make. * * `"1,0": [-5,null]` => Lets the piece slide 5 squares in the negative vector direction, or infinitely in the positive. * * The *key* is the step amount of each skip, and the *value* is the skip limit in the -x and +x directions (-y and +y if it's vertical). * * THE 0-INDEX KEY SHOULD ALWAYS BE NEGATIVE!!! */ type SlidingMoves = { [slideDirection: Vec2Key]: [bigint | null, bigint | null]; }; /** * This runs once for every square you can slide to that's visible on the screen. * It returns true if the square is legal to move to, false otherwise. * * If no ignore function is specified, the default ignore function that every piece * has by default always returns *true*. * * The start and end coords arguments are useful for the Huygen, as it can * calculate the distance traveled, and then test if it's prime. * * The gamefile and detectCheck method may be used for the Royal Queen, * as it can test if the squares are check for positive. */ type IgnoreFunction = (_startCoords: Coords, _endCoords: Coords) => boolean; /** * This runs once for every piece on the same line of the selected piece. * * 0 => Piece doesn't block * 1 => Blocked ON the square (enemy piece) * 2 => Blocked 1 before the square (friendly piece or void) * * The return value of 0 will be useful in the future for allowing pieces * to *phase* through other pieces. * An example of this would be the "witch", which makes all adjacent friendly * pieces "transparent", allowing friendly pieces to phase through them. */ type BlockingFunction = ( _friendlyColor: Player, _blockingPiece: Piece, _coords: Coords, _premove: boolean, ) => 0 | 1 | 2; /** * A function that returns an array of any legal special individual moves for the piece, * each of the coords will have a special property attached to it. castle/promote/enpassant */ type SpecialFunction = ( _gamefile: FullGame, _coords: Coords, _color: Player, _premove: boolean, ) => CoordsTagged[]; // /** The direction a given player color is facing (which way their pawns move). */ // type PlayerFacingDirection = { // /** 1 -> Pawns move vertically. 0 -> Pawns move horizontally. */ // axis: 0 | 1; // parity: 1n | -1n; // }; /** The default blocking function of each piece's sliding moves, if not specified. */ function defaultBlockingFunction( friendlyColor: Player, blockingPiece: Piece, coords: Coords, premove: boolean, ): 0 | 1 | 2 { return legalmoves.testCaptureValidity(friendlyColor, blockingPiece.type, premove); } /** The default ignore function of each piece's sliding moves, if not specified. */ function defaultIgnoreFunction(): boolean { return true; // Square allowed } /** * Generates all orthogonal/diagonal moves on the perimeter of a square with a given radius (king, hawk). */ function generateCompassMoves(distance: bigint): Coords[] { // prettier-ignore return [ [-distance, distance], [0n, distance], [distance, distance], [-distance, 0n], /*[0n,0n],*/ [distance, 0n], [-distance, -distance], [0n, -distance], [distance, -distance] ]; } /** * Generates the 8 moves for an (m,n) leaper piece (knight, camel, zebra, giraffe). * It creates all permutations of (±m, ±n) and (±n, ±m). */ function generateLeaperMoves(m: bigint, n: bigint): Coords[] { // prettier-ignore return [ // Positive second coordinate ("up" on a board) [-n, m], [-m, n], [m, n], [n, m], // Negative second coordinate ("down" on a board) [-n, -m], [-m, -n], [m, -n], [n, -m], ]; } /** * Returns the movesets of all the pieces, modified according to the specified slideLimit gamerule. * * These movesets are called as functions so that they return brand * new copies of each moveset so there's no risk of accidentally modifying the originals. * @param [slideLimit] Optional. The slideLimit gamerule value. * @returns Object containing the movesets of all pieces except pawns. */ function getPieceDefaultMovesets(slideLimit: bigint | null = null): Movesets { if (typeof slideLimit !== 'bigint' && slideLimit !== null) throw new Error('slideLimit gamerule is in an unsupported value.'); // Slide limits of all pieces. Negative the first index. const slideLimits: [bigint | null, bigint | null] = [ slideLimit === null ? null : -slideLimit, slideLimit, ]; // Define common movesets to reduce duplication const kingMoves: Coords[] = generateCompassMoves(1n); const knightMoves = generateLeaperMoves(1n, 2n); const rookMoves: SlidingMoves = { '1,0': slideLimits, '0,1': slideLimits, }; const bishopMoves: SlidingMoves = { '1,1': slideLimits, '1,-1': slideLimits, }; const rawMovesets: RawMovesets = { // Finitely moving [r.PAWN]: { special: specialdetect.pawns, }, [r.KNIGHT]: { individual: knightMoves, }, [r.HAWK]: { individual: [...generateCompassMoves(2n), ...generateCompassMoves(3n)], }, [r.KING]: { individual: kingMoves, special: specialdetect.kings, }, [r.GUARD]: { individual: kingMoves, }, // Infinitely moving [r.ROOK]: { sliding: rookMoves, }, [r.BISHOP]: { sliding: bishopMoves, }, [r.QUEEN]: { sliding: { ...rookMoves, ...bishopMoves, }, }, [r.ROYALQUEEN]: { sliding: { ...rookMoves, ...bishopMoves, }, }, [r.CHANCELLOR]: { individual: knightMoves, sliding: rookMoves, }, [r.ARCHBISHOP]: { individual: knightMoves, sliding: bishopMoves, }, [r.AMAZON]: { individual: knightMoves, sliding: { ...rookMoves, ...bishopMoves, }, }, [r.CAMEL]: { individual: generateLeaperMoves(1n, 3n), }, [r.GIRAFFE]: { individual: generateLeaperMoves(1n, 4n), }, [r.ZEBRA]: { individual: generateLeaperMoves(2n, 3n), }, [r.KNIGHTRIDER]: { sliding: { '1,2': slideLimits, '1,-2': slideLimits, '2,1': slideLimits, '2,-1': slideLimits, }, }, [r.CENTAUR]: { individual: [...kingMoves, ...knightMoves], }, [r.ROYALCENTAUR]: { individual: [...kingMoves, ...knightMoves], special: specialdetect.kings, }, [r.HUYGEN]: { sliding: rookMoves, blocking: ( friendlyColor: Player, blockingPiece: Piece, coords: Coords, premove: boolean, ): 0 | 1 | 2 => { const distance = vectors.chebyshevDistance(coords, blockingPiece.coords); const isPrime = primalityTest(distance); if (!isPrime) return 0; // Doesn't block, not even if it's a void. It hops over it! return legalmoves.testCaptureValidity(friendlyColor, blockingPiece.type, premove); }, ignore: (startCoords: Coords, endCoords: Coords): boolean => { const distance = vectors.chebyshevDistance(startCoords, endCoords); const isPrime = primalityTest(distance); return isPrime; }, }, [r.ROSE]: { special: specialdetect.roses, }, }; return convertRawMovesetsToPieceMovesets(rawMovesets); } /** * Calculates all possible slides that should be possible in the provided game, * based on the provided movesets. * @param pieceMovesets - MUST BE TRIMMED beforehand to not include movesets of types not present in the game!!!!! */ function getPossibleSlides(pieceMovesets: RawTypeGroup<() => PieceMoveset>): Vec2[] { const slides = new Set(['1,0']); // '1,0' is required if castling is enabled. for (const rawtype in pieceMovesets) { const moveset = pieceMovesets[Number(rawtype) as RawType]!(); if (!moveset.sliding) continue; Object.keys(moveset.sliding).forEach((slide) => slides.add(slide as Vec2Key)); } return Array.from(slides, vectors.getVec2FromKey); } /** Converts raw movesets into final piece movesets by auto adding the colinear property. */ function convertRawMovesetsToPieceMovesets(pieceMovesets: RawTypeGroup): Movesets { // Now, auto add in the colinear property to each piece moveset const finalMovesets: Movesets = {}; for (const [rawtype, moveset] of Object.entries(pieceMovesets)) { finalMovesets[Number(rawtype) as RawType] = { ...moveset, colinear: isMovesetColinear(moveset), }; } return finalMovesets; } /** Tests whether the provided moveset involves colinear sliding moves. */ function isMovesetColinear(moveset: RawPieceMoveset): boolean { /** * Colinears are present if an ignore/blocking function override is present (which can simulate non-primitive vectors). * We cannot predict if the piece will not cause colinears. * A custom blocking function may trigger crazy checkmate colinear shenanigans because it can allow opponent pieces to phase through your pieces, so pinning works differently. */ if (moveset.blocking || moveset.ignore) return true; // This type has a custom ignore/blocking function being used (colinears may be present). /** * Colinears are present if any vector is NOT a primitive vector. * This is because if a vector is not primitive, multiple simpler vectors can be combined to make it. * For example, [2,0] can be made by combining [1,0] and [1,0]. * In a real game, you could have two [2,0] sliders, offset by 1 tile, and their lines would be colinear, yet not intersecting. * A vector is considered primitive if the greatest common divisor (GCD) of its components is 1. */ if (moveset.sliding) { const slides: Vec2[] = (Object.keys(moveset.sliding) as Vec2Key[]).map((s) => vectors.getVec2FromKey(s), ); if (slides.some((s) => isVectorColinear(s))) return true; // Colinear } return false; } /** Tests whether the provided slide vector is colinear (not a primitive vector). */ function isVectorColinear(vector: Vec2): boolean { return bimath.GCD(vector[0], vector[1]) !== 1n; } // /** // * Returns the normalized vector direction a given player's pawns travel. // * `axis` = 0 -> pawn moves horizontal. `axis` = 1 -> pawn moves vertical. // * // * @throws If player neutral is passed // */ // function determinePlayerFacingDirection(player: Player): PlayerFacingDirection { // if (player === p.WHITE) return { axis: 1, parity: 1n }; // else if (player === p.BLACK) return { axis: 1, parity: -1n }; // // 4 Player colors // else if (player === p.RED) return { axis: 1, parity: 1n }; // else if (player === p.BLUE) return { axis: 0, parity: 1n }; // else if (player === p.YELLOW) return { axis: 1, parity: -1n }; // else if (player === p.GREEN) return { axis: 0, parity: -1n }; // else throw Error(`Cannot determine player facing direction of player ${player}!`); // } export default { defaultBlockingFunction, defaultIgnoreFunction, getPieceDefaultMovesets, getPossibleSlides, convertRawMovesetsToPieceMovesets, isVectorColinear, // determinePlayerFacingDirection, }; export type { Movesets, RawMovesets, PieceMoveset, BlockingFunction, IgnoreFunction }; ================================================ FILE: src/shared/chess/logic/movevalidation.ts ================================================ // src/shared/chess/logic/movevalidation.ts import type { FullGame } from './gamefile.js'; import type { GameConclusion } from '../util/winconutil.js'; import jsutil from '../../util/jsutil.js'; import winconutil from '../util/winconutil.js'; import legalmoves from './legalmoves.js'; import checkresolver from './checkresolver.js'; import specialdetect from './specialdetect.js'; import boardutil, { Piece } from '../util/boardutil.js'; import icnconverter, { MoveCoords } from './icn/icnconverter.js'; import movepiece, { CoordsTagged, MoveTagged } from './movepiece.js'; import typeutil, { Player, RawType, rawTypes as r } from '../util/typeutil.js'; // Types ----------------------------------------------------------------------- export type MoveValidationResult = | { valid: true; /** The move draft with any special tags attached, derived from its end coords. */ tagged: MoveTagged; } | { valid: false; /** The reason the move is illegal. */ reason: string; }; type ConclusionValidityResult = { valid: true } | { valid: false; reason: string }; // Functions ------------------------------------------------------------------- /** * UTILITY: Runs a specific validation action while the game is temporarily * fast-forwarded to the latest move. Afterwards restoring the game to its original state. * @param gamefile - The gamefile * @param action - The action to run while at the front of the game * @returns The result of the action */ function runActionAtGameFront(gamefile: FullGame, action: () => T): T { const { boardsim } = gamefile; const originalMoveIndex = boardsim.state.local.moveIndex; // Fast Forward to the latest move (graphical updates skipped since we will return afterwards) movepiece.goToMove(boardsim, boardsim.moves.length - 1, (move) => movepiece.applyMove(gamefile, move, true), ); // Run the specific logic (move validation, conclusion check, etc) const result = action(); // Rewind to original state movepiece.goToMove(boardsim, originalMoveIndex, (move) => movepiece.applyMove(gamefile, move, false), ); return result; } /** * Tests if the provided move is legal to play in this game, * including whether the claimed game conclusion is correct. * Also attaches and special move tags to the move coords. * @param gamefile - The gamefile * @param moveCoords - The move. Special move tags will be attached to them if it is legal. * @param claimedGameConclusion - The opponent's claimed game conclusion * @returns An object containing either: * - `valid: true` and the `draft` of the move with any special tags attached. * - `valid: false` and a `reason` string explaining why it is illegal. */ function isOpponentsMoveLegal( gamefile: FullGame, moveCoords: MoveCoords, claimedGameConclusion: GameConclusion | undefined, ): MoveValidationResult { // We run both move and conclusion checks when at the front of the game return runActionAtGameFront(gamefile, () => { // 1. Check Move Legality const moveResult = validateMove(gamefile, moveCoords); if (!moveResult.valid) return moveResult; // 2. Check Conclusion Validity (using the draft with special tags attached) const conclusionResult = validateConclusion( gamefile, moveResult.tagged, claimedGameConclusion, ); if (!conclusionResult.valid) return conclusionResult; // At this stage, both move and conclusion are valid! return moveResult; }); } /** * Tests if the provided compact move string is legal to play. * @param gamefile - The gamefile * @param tokenMove - The move that SHOULD be in compact string format (e.g. "x,y>x,y=Q"), but we can't trust all enginess response contents. * @returns An object containing either: * - `valid: true` and the `draft` of the move with any special tags attached. * - `valid: false` and a `reason` string explaining why it is illegal. */ function isTokenMoveLegal(gamefile: FullGame, tokenMove: unknown): MoveValidationResult { if (typeof tokenMove !== 'string') return { valid: false, reason: 'Not a string.' }; // Convert the move from compact short format "x,y>x,y=N" to JSON format let moveCoords: MoveCoords; try { moveCoords = icnconverter.parseTokenMove(tokenMove); } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); console.error(`Invalid format error when parsing compact move "${tokenMove}": ${msg}`); // Return generic invalid reason return { valid: false, reason: 'Incorrect format.' }; } return runActionAtGameFront(gamefile, () => { return validateMove(gamefile, moveCoords); }); } /** * CORE LOGIC: Checks validity of a move. * REQUIRES you to be viewing the head of the game. * Also attaches and special move tags to the move coords. * @param gamefile - The gamefile * @param moveCoords - The move to validate. Special move tags will be attached to them if it is legal. * @returns An object containing either: * - `valid: true` and the `draft` of the move with any special tags attached. * - `valid: false` and a `reason` string explaining why it is illegal. */ function validateMove(gamefile: FullGame, moveCoords: MoveCoords): MoveValidationResult { const { boardsim, basegame } = gamefile; const piecemoved: Piece | undefined = boardutil.getPieceFromCoords( boardsim.pieces, moveCoords.startCoords, ); // Make sure a piece exists on the start coords if (!piecemoved) return { valid: false, reason: 'No piece at start coords.' }; // Make sure it matches the color of whos turn it is. const colorOfPieceMoved: Player = typeutil.getColorFromType(piecemoved.type); if (colorOfPieceMoved !== basegame.whosTurn) return { valid: false, reason: 'Incorrect color.' }; const rawTypeMoved = typeutil.getRawType(piecemoved.type); promotion: if (moveCoords.promotion !== undefined) { // User IS promoting if (!basegame.gameRules.promotionRanks) return { valid: false, reason: 'Game has no promotion ranks.' }; if (rawTypeMoved !== r.PAWN) return { valid: false, reason: "Can't promote non-pawn." }; const promotionRanks: bigint[] | undefined = basegame.gameRules.promotionRanks[colorOfPieceMoved]; if (!promotionRanks) return { valid: false, reason: 'Color has no promotion ranks.' }; if (!promotionRanks.includes(moveCoords.endCoords[1])) return { valid: false, reason: 'No promotion rank at end coords.' }; const colorPromotedTo: Player = typeutil.getColorFromType(moveCoords.promotion); if (basegame.whosTurn !== colorPromotedTo) return { valid: false, reason: 'Incorrect promotion color.' }; if (!basegame.gameRules.promotionsAllowed) return { valid: false, reason: 'Game has no promotions allowed.' }; const promotionsAllowed: RawType[] | undefined = basegame.gameRules.promotionsAllowed[colorOfPieceMoved]; if (!promotionsAllowed) return { valid: false, reason: 'Color has no promotions allowed.' }; const rawPromotion: RawType = typeutil.getRawType(moveCoords.promotion); if (!promotionsAllowed.includes(rawPromotion)) return { valid: false, reason: 'Illegal promotion type.' }; } else { // User is NOT promoting // Make sure they aren't moving to a promotion rank WITHOUT promoting! That's also illegal. if (!basegame.gameRules.promotionRanks) break promotion; // This game doesn't have promotion. if (rawTypeMoved !== r.PAWN) break promotion; // Not a pawn, not forced to promote. const promotionRanks: bigint[] | undefined = basegame.gameRules.promotionRanks[colorOfPieceMoved]; if (!promotionRanks) break promotion; // This color doesn't have promotion ranks, not forced to promote. if (!promotionRanks.includes(moveCoords.endCoords[1])) break promotion; // Not on a promotion rank, not forced to promote. // If we are here: They moved a pawn to a promotion rank but didn't promote. return { valid: false, reason: 'Did not promote.' }; } // Test if that piece's legal moves contain the destination coords... const endCoordsToAppendTagsTo: CoordsTagged = jsutil.deepCopyObject(moveCoords.endCoords); // This logic is pulled out of legalmoves.calculateAll(), so we can observe // it at each step to find the earliest illegality point of the move submission. const moveset = legalmoves.getPieceMoveset(gamefile.boardsim, piecemoved.type); const legalMoves = legalmoves.getEmptyLegalMoves(moveset); legalmoves.appendPotentialMoves(piecemoved, moveset, legalMoves); legalmoves.removeObstructedMoves( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, piecemoved, moveset, legalMoves, false, ); legalmoves.appendSpecialMoves(gamefile, piecemoved, moveset, legalMoves, false); // Check if even the non-check-respecting move is legal first // This should pass on any special moves tags to endCoordsToAppendSpecialsTo at the same time. if ( !legalmoves.checkIfMoveLegal( gamefile, legalMoves, piecemoved.coords, endCoordsToAppendTagsTo, colorOfPieceMoved, ) ) { return { valid: false, reason: 'Invalid destination coords.' }; } checkresolver.removeCheckInvalidMoves(gamefile, piecemoved, legalMoves); // Now check if the check-respecting move is legal if ( !legalmoves.checkIfMoveLegal( gamefile, legalMoves, piecemoved.coords, endCoordsToAppendTagsTo, colorOfPieceMoved, ) ) { return { valid: false, reason: 'Puts self in check.' }; } // Now transfer the special move tags from the coords to the move draft specialdetect.transferSpecialTags_FromCoordsToMove(endCoordsToAppendTagsTo, moveCoords); // If we reach here, the move is valid! return { valid: true, tagged: moveCoords }; } /** * Determines whether the opponent's claimed conclusion matches what we calculate from the position. * @param gamefile - The gamefile * @param moveTagged - The move draft, WITH special tags attached! * @param claimedGameConclusion - The opponent's claimed game conclusion * @returns An object containing either: * - `valid: true` * - `valid: false` and a `reason` string explaining why it is illegal. */ function validateConclusion( gamefile: FullGame, moveTagged: MoveTagged, claimedGameConclusion: GameConclusion | undefined, ): ConclusionValidityResult { if ( claimedGameConclusion !== undefined && !winconutil.isConclusionMoveTriggered(claimedGameConclusion.condition) ) { // Non-move-triggered (e.g. resignation, time, abort) conclusions are always valid since the server handles those. return { valid: true }; } const moveTaggedCopy = jsutil.deepCopyObject(moveTagged); const simulatedConclusion = movepiece.getSimulatedConclusion(gamefile, moveTaggedCopy); if ( simulatedConclusion?.condition !== claimedGameConclusion?.condition || simulatedConclusion?.victor !== claimedGameConclusion?.victor ) { console.error( `Conclusion mismatch! Simulated: ${JSON.stringify(simulatedConclusion)}, Claimed: ${JSON.stringify(claimedGameConclusion)}`, ); return { valid: false, reason: 'Wrong conclusion.' }; } // If we reach here, the claimed conclusion is valid! return { valid: true }; } export default { isTokenMoveLegal, isOpponentsMoveLegal, validateMove, }; ================================================ FILE: src/shared/chess/logic/organizedpieces.ts ================================================ // src/shared/chess/logic/organizedpieces.ts /** * This script generates and manages the organized pieces of a game. * * The pieces are organized in many different ways to optimize for different accessing methods. * * Ways to access the pieces: * - By index * - By coordinate * - By line */ import type { PieceMoveset } from './movesets.js'; import type { Coords, CoordsKey } from '../util/coordutil.js'; import type { Player, PlayerGroup, RawType, TypeGroup, RawTypeGroup } from '../util/typeutil.js'; import bimath from '../../util/math/bimath.js'; import movesets from './movesets.js'; import coordutil from '../util/coordutil.js'; import vectors, { Vec2, Vec2Key } from '../../util/math/vectors.js'; import typeutil, { ext, players as p, rawTypes, neutralRawTypes } from '../util/typeutil.js'; // Types --------------------------------------------------------------------------- /** * An object that stores the pieces on the board in several different organized ways. * This way we can quickly access the pieces when we are given different information. * * - By index * - By coordinate * - By line * * Also stores variables for all possible slide lines in the game, * and whether there are any hippogonal riders present. */ interface OrganizedPieces { /** The X position of all pieces. Undefined pieces are set to 0. */ XPositions: bigint[]; /** The Y position of all pieces. Undefined pieces are set to 0. */ YPositions: bigint[]; /** * The type of all pieces. Undefined pieces retain the type of the type range they are in. * * Uint8Array range: 0-255. There are 22 total types currently, potentially 4 unique players/players in a game ==> 88 posible types. */ types: Uint8Array; /** Contains start and end indices for where each type of piece begins and ends in the types array. */ typeRanges: TypeRanges; /** * Pieces organized by coordinate * 'x,y' => idx */ coords: Map; /** * Pieces organized by line (rank/file/diagonal) * Map{ 'dx,dy' => Map { 'yint|xafter0' => [idx, idx, idx...] }} * dx is never negative. If dx is 0, dy cannot be negative either. */ lines: Map>; /** All slide directions possible in the game. [1,0] guaranteed for castling to work. */ slides: Vec2[]; /** Whether there are any hippogonal riders in the game (knightriders). */ hippogonalsPresent: boolean; /** * If this flag is present, it means the pieces have been regenerated * to add more undefineds to the type ranges. * movesequence should see this and immediately regenerate the piece models! */ newlyRegenerated?: true; } /** Contains start and end indices for where each type of piece begins and ends in the types array. */ type TypeRanges = Map; /** Contains the start and end indices for where a single piece type begins and ends in the types array. */ interface TypeRange { /** Inclusive */ start: number; /** Exclusive */ end: number; /** Each number in this array is the index of the undefined in the large XYPositions arrays. This array is also sorted. */ undefineds: Array; } /** A unique identifier for a single line of pieces. `C|X` */ type LineKey = `${bigint}|${bigint}`; // Constants --------------------------------------------------------------------------- /** How many extra undefined placeholders each type range should have. * When these are all exhausted, the large piece lists must be regenerated. */ const listExtras = 10; /** EDITOR-MODE-SPECIFIC {@link listExtras} */ const listExtras_Editor = 50; // Main Functions --------------------------------------------------------------------- /** * Takes the source Position for the variant, and constructs the entire * organized pieces object, and returns other information inherited from it. * * Mutates pieceMovesets to remove useless movesets */ function processInitialPosition( position: Map, pieceMovesets: RawTypeGroup<() => PieceMoveset>, turnOrder: Player[], editor: boolean, promotionsAllowed?: PlayerGroup, ): { pieces: OrganizedPieces; /** * All existing types in the game, with their color information. * This may include pieces not in the starting position, * such as those that can be promoted to. */ existingTypes: number[]; /** All raw existing types in the game. */ existingRawTypes: RawType[]; } { // Organize the pieces by type const piecesByType: Map = new Map(); const existingTypesSet = new Set(); if (!(position instanceof Map)) throw Error(`Position is not a map! (${typeof position})`); for (const [coordsKey, type] of position) { if (typeof type !== 'number') throw Error(`Type inside Position is not a number! ${type} ${coordsKey}`); // Bug catcher const coords = coordutil.getCoordsFromKey(coordsKey as CoordsKey); existingTypesSet.add(type); if (!piecesByType.has(type)) piecesByType.set(type, []); piecesByType.get(type)!.push(coords); // Push the coords } // Calculate the possible types const { existingTypes, existingRawTypes } = calcRemainingExistingTypes( existingTypesSet, turnOrder, editor, promotionsAllowed, ); // Determine how many undefineds each type needs const listExtrasByType: TypeGroup = {}; for (const type of existingTypes) { const numOfPieceInStartingPos = piecesByType.get(type)?.length ?? 0; listExtrasByType[type] = getListExtrasOfType( type, numOfPieceInStartingPos, editor, promotionsAllowed, ); } // console.log("List extras by type:"); // console.log(listExtrasByType); /** * Trim the pieceMovesets to only include movesets for types in the game * This is REQUIRED for possible slides to be calculated correctly!! */ typeutil.deleteUnusedFromRawTypeGroup(existingRawTypes, pieceMovesets); // We can get the possible slides now that the movesets are trimmed to only include the types in the game. const slides = movesets.getPossibleSlides(pieceMovesets); // Allocate the space needed for the XPositions, YPositions, and types arrays const totalSlotsNeeded = position.size + Object.values(listExtrasByType).reduce((a, b) => a + b, 0); // console.log("Total piece count: " + pieceCount); // console.log(`Total slots needed: ${totalSlotsNeeded}`); // This way we save on RAM since we don't have to construct normal arrays first and transfer the data after. const XPositions = new Array(totalSlotsNeeded); const YPositions = new Array(totalSlotsNeeded); const types = new Uint8Array(totalSlotsNeeded); // Initialize the organized lines const lines = new Map>(); for (const line of slides) { const strline = vectors.getKeyFromVec2(line); lines.set(strline, new Map()); } // Fill the lists and Construct the type ranges, coords, and lines! const partialPieces = { XPositions, YPositions, coords: new Map(), lines, }; let start = 0; // The next range start let pointer = 0; // The index within the XPositions, YPosition, and types, we are currently setting. const ranges: TypeRanges = new Map(); for (const type of existingTypes) { const pieces = piecesByType.get(type) ?? []; // It will be empty if there are no pieces of this type in the starting position. Those may be acquired via promotion / board editor. // Set the pieces X, Y, and type, and register in space for (let i = 0; i < pieces.length; i++) { XPositions[pointer] = pieces[i]![0]; YPositions[pointer] = pieces[i]![1]; types[pointer] = Number(type); registerPieceInSpace(pointer, partialPieces); pointer++; } // Create the undefineds list const undefineds: number[] = []; for (let i = 0; i < listExtrasByType[type]!; i++) { // The XPositions and YPositions are initialized to 0, so we don't need to set them here. types[pointer] = Number(type); // The undefined is still in the same type range, though, so we do need to set this. undefineds.push(pointer); pointer++; } // Set the range ranges.set(type, { start, end: pointer, undefineds, }); // console.log("Set type range for type " + typeutil.debugType(type) + ":"); // console.log(ranges.get(type)); start = pointer; } // Construct the OrganizedPieces object return { pieces: { XPositions, YPositions, types, typeRanges: ranges, coords: partialPieces.coords, lines: partialPieces.lines, slides, hippogonalsPresent: areHippogonalsPresentInGame(slides), }, existingTypes, existingRawTypes, }; } /** * Resizes the piece arrays and updates type ranges to ensure minimum undefined slots. * Afterward, flags the pieces as newly regenerated. movesequence may * watch for that to know when to regenerate the piece models. */ function regenerateLists( o: OrganizedPieces, editor: boolean, promotionsAllowed?: PlayerGroup, ): void { const additionalUndefinedsNeeded: Map = new Map(); const typeOffsets: Map = new Map(); const modifiedTypes: number[] = []; // A list of all type ranges that changed in size. let totalAdditionalSlots = 0; let currentCumulativeOffset = 0; // 1. Calculate needed slots, offsets, and track modified types // for (const [type, range] of typesAndRanges) { for (const [type, range] of o.typeRanges) { const pieceTypeCount = range.end - range.start - range.undefineds.length; // The type of this piece, excluding undefineds const targetUndefineds = getListExtrasOfType( type, pieceTypeCount, editor, promotionsAllowed, ); const needed = Math.max(0, targetUndefineds - range.undefineds.length); additionalUndefinedsNeeded.set(type, needed); typeOffsets.set(type, currentCumulativeOffset); if (needed > 0) { // Only track if modification occurred modifiedTypes.push(type); totalAdditionalSlots += needed; } currentCumulativeOffset += needed; } // --- Early exit if no changes are needed --- if (totalAdditionalSlots === 0) { console.warn('regenerateLists() called but no additional slots were needed.'); return; // Return (no type ranges modified) } console.log( `Regenerating lists: Adding ${totalAdditionalSlots} more total slots for types: ${modifiedTypes.map(typeutil.debugType).join(', ')}.`, ); // --- Prepare for copy --- const oldSize = o.XPositions.length; const newSize = oldSize + totalAdditionalSlots; // 2. Allocate new, larger arrays const newXPositions = new Array(newSize); const newYPositions = new Array(newSize); const newTypes = new Uint8Array(newSize); // Keep track of original types before overwriting o.types const originalTypes = new Uint8Array(o.types); // 3. Copy data and update TypeRanges for (const [type, range] of o.typeRanges) { const offset = typeOffsets.get(type)!; const addedSlots = additionalUndefinedsNeeded.get(type)!; // Will be 0 if not modified const newStart = range.start + offset; const newEnd = range.end + offset + addedSlots; // console.log(`Copying type ${typeutil.debugType(type)}: ${range.start} -> ${newStart}, ${range.end} -> ${newEnd}`); // Copy existing data block const copyLength = range.end - range.start; for (let i = 0; i < copyLength; i++) { newXPositions[newStart + i] = o.XPositions[range.start + i]!; newYPositions[newStart + i] = o.YPositions[range.start + i]!; } newTypes.set(o.types.subarray(range.start, range.end), newStart); // Update the TypeRange // Update existing undefined indices range.undefineds = range.undefineds.map((oldUndefIndex) => oldUndefIndex + offset); // Add new undefined indices (only if addedSlots > 0) if (addedSlots > 0) { const firstNewUndefIndex = range.end + offset; for (let i = 0; i < addedSlots; i++) { const newIndex = firstNewUndefIndex + i; newTypes[newIndex] = type; // Set type for the new slot range.undefineds.push(newIndex); } } // Update range properties range.start = newStart; range.end = newEnd; } // 4. Update indices in coords map const newCoords = new Map(); for (const [key, oldIdx] of o.coords.entries()) { const type = originalTypes[oldIdx]!; const offset = typeOffsets.get(type)!; newCoords.set(key, oldIdx + offset); } o.coords = newCoords; // 5. Update indices in lines map for (const lineGroup of o.lines.values()) { for (const indicesArray of lineGroup.values()) { for (let i = 0; i < indicesArray.length; i++) { const oldIdx = indicesArray[i]!; const type = originalTypes[oldIdx]!; const offset = typeOffsets.get(type)!; indicesArray[i] = oldIdx + offset; } } } // 6. Replace old arrays with new ones o.XPositions = newXPositions; o.YPositions = newYPositions; o.types = newTypes; o.newlyRegenerated = true; // Mark as newly regenerated. Piece models should be regenerated too. // console.log("Regenerated lists:"); // console.log(o); } /** Generates a position with the coordinates as the key, and the piece type as the value. */ function generatePositionFromPieces({ coords, types }: OrganizedPieces): Map { const position = new Map(); for (const [coordsKey, idx] of coords) { position.set(coordsKey, types[idx]!); } return position; } /** * Generates an iterable of [coordsKey, pieceType] pairs from the given organized pieces. * * More efficient than {@link generatePositionFromPieces}, as this doesn't create an intermediate map. * @param {OrganizedPieces} o - The organized pieces object. Destructure the `coords` and `type` objects so the organized pieces can be garbage cleaned. * @returns The piece iterator, yielding [coordsKey, pieceType] pairs. */ function* getPieceIterable({ coords, types }: OrganizedPieces): Iterable<[CoordsKey, number]> { for (const [coordsKey, idx] of coords) { yield [coordsKey, types[idx]!]; } } // Processing and Removing Pieces in space ------------------------------------------------- /** Adds a piece to o.coords and o.lines so that it can be used for efficient collision detection. */ function registerPieceInSpace( idx: number, o: { /* * Declaring the argument like this instead of using * Partial guarantees these options MUST be present. * And doesn't require us pass in a fully-constructed organized pieces object. */ XPositions: bigint[]; YPositions: bigint[]; coords: Map; lines: Map>; }, ): void { if (idx === undefined) throw Error('Undefined idx is trying'); const x = o.XPositions[idx]; const y = o.YPositions[idx]; const coords = [x, y] as Coords; // console.log("Registering piece in space: " + idx + " coords: " + coords); const key = coordutil.getKeyFromCoords(coords); if (o.coords.has(key)) throw Error( `While organizing a piece, there was already an existing piece there!! ${key} idx ${idx}`, ); o.coords.set(key, idx); const lines = o.lines; for (const [strline, linegroup] of lines) { const lkey = getKeyFromLine(coordutil.getCoordsFromKey(strline), coords); // Is line initialized if (linegroup.get(lkey) === undefined) lines.get(strline)!.set(lkey, []); linegroup.get(lkey)!.push(idx); } } /** Deletes a piece from o.coords and o.lines */ function removePieceFromSpace( idx: number, o: { /* * Declaring the argument like this instead of using * Partial guarantees these options MUST be present. * And doesn't require us pass in a fully-constructed organized pieces object. */ XPositions: bigint[]; YPositions: bigint[]; coords: Map; lines: Map>; }, ): void { const x = o.XPositions![idx]; const y = o.YPositions![idx]; const coords = [x, y] as Coords; // console.log("Removing piece from space: " + idx + " coords: " + coords); const key = coordutil.getKeyFromCoords(coords); if (!o.coords.has(key)) throw Error( `While removing a piece, there was no existing piece there!! ${key} idx ${idx}`, ); o.coords.delete(key); const lines = o.lines; for (const [strline, linegroup] of lines) { const lkey = getKeyFromLine(coordutil.getCoordsFromKey(strline), coords); // Is line initialized if (linegroup.get(lkey) === undefined) continue; removePieceFromLine(linegroup, lkey); } // Takes a line from a property of an organized piece list, deletes the piece at specified coords function removePieceFromLine(lineset: Map, lineKey: LineKey): void { const line = lineset.get(lineKey)!; for (let i = 0; i < line.length; i++) { const thisPieceIdx = line[i]!; if (thisPieceIdx !== idx) continue; line.splice(i, 1); // Delete // If the line length is now 0, remove itself from the lineset if (line.length === 0) lineset.delete(lineKey); break; } } } // Helper Functions ------------------------------------------------------------------------ /** * Takes a Set of all types in the STARTING POSITION and adds to it other * potential pieces that may join the game via promotion or board editor. */ function calcRemainingExistingTypes( positionExistingTypes: Set, turnOrder: Player[], editor: boolean, promotionsAllowed?: PlayerGroup, ): { existingTypes: number[]; existingRawTypes: RawType[]; } { let existingTypes: number[]; let existingRawTypes: RawType[]; if (editor) { // ALL pieces may be added in the board editor, but only of the players mentioned in turnOrder const playersSet: Set = new Set(turnOrder); if (turnOrder.some((player) => player >= 3)) playersSet.add(p.NEUTRAL); // also add gargoyles for neutral player, if more than 2 players are in game const playersArray: Array = [...playersSet]; existingTypes = typeutil.buildAllTypesForPlayers(playersArray, Object.values(rawTypes)); existingTypes = [...new Set([...neutralRawTypes, ...existingTypes])]; // This ensures VOID and OBSTACLE are always added. existingRawTypes = Object.values(rawTypes); } else { if (promotionsAllowed) { // Makes sure pieces that are possible to promote to are accounted for. for (const playerString in promotionsAllowed) { const player = Number(playerString) as Player; const rawPromotions = promotionsAllowed[player]!; for (const rawType of rawPromotions) { positionExistingTypes.add(typeutil.buildType(rawType, player)); } } } /** If Player 3 or greater is present (multiplayer game), then gargoyles may appear when a player dies. * Which means we also must add corresponding neutral for every type in the game! */ if (turnOrder.some((p) => p >= 3)) { for (const type of [...positionExistingTypes]) { // Spread to avoid problems with infinite iteration when adding to it at the same time. // Convert it to neutral, and add it to existingTypes positionExistingTypes.add(typeutil.getRawType(type) + ext.N); } } existingTypes = [...positionExistingTypes]; existingRawTypes = [...new Set(existingTypes.map(typeutil.getRawType))]; } return { existingTypes, existingRawTypes, }; } /** * Returns the target number of undefineds that should be alloted for a given type. * @param numOfPieces - The number of pieces of this type in the position, EXCLUDING undefineds */ function getListExtrasOfType( type: number, numOfPieces: number, editor: boolean, promotionsAllowed?: PlayerGroup, ): number { const undefinedsBehavior = getTypeUndefinedsBehavior(type, editor, promotionsAllowed); // prettier-ignore return undefinedsBehavior === 2 ? Math.max(listExtras_Editor, numOfPieces) // Count of piece can increase RAPIDLY (editor) : undefinedsBehavior === 1 ? listExtras // Count of piece can increase slowly (promotion) : undefinedsBehavior === 0 ? 0 // Count of piece CANNOT increase : (() => { throw Error(`Unsupported undefineds behavior" ${undefinedsBehavior} for type ${typeutil.debugType(type)}!`); })(); } /** * Returns a number signifying the importance of this piece type needing undefineds placeholders in its type list. * * 0 => Pieces of this type can not increase in count in this gamefile * 1 => Can increase in count, but slowly (promotion) * 2 => Can increase in count rapidly (board editor) */ function getTypeUndefinedsBehavior( type: number, editor: boolean, promotionsAllowed?: PlayerGroup, ): 0 | 1 | 2 { if (editor) return 2; // gamefile is in the board editor, EVERY piece needs undefined placeholders, and a lot of them! if (!promotionsAllowed) return 0; // No pieces can promote, definitely not appending undefineds to this piece. const [rawType, player] = typeutil.splitType(type); if (!promotionsAllowed[player]) return 0; // This player color cannot promote (neutral). if (promotionsAllowed[player].includes(rawType)) return 1; // Can be promoted to return 0; // This piece cannot be promoted to anything. } /** * Tests if the provided gamefile has hippogonal lines present in the game. * True if there are knightriders or higher riders. */ function areHippogonalsPresentInGame(slidingPossible: Vec2[]): boolean { for (let i = 0; i < slidingPossible.length; i++) { const thisSlideDir: Vec2 = slidingPossible[i]!; if (bimath.abs(thisSlideDir[0]) > 1n) return true; if (bimath.abs(thisSlideDir[1]) > 1n) return true; } return false; } // Line Key Functions -------------------------------------------------------------- /** * Returns a string that is a unique identifier of a given organized line: `"C|X"`. * Where `C` is the c in the linear standard form of the line: "ax + by = c", * and `X` is the nearest x-value the line intersects on or after the y-axis. * For example, the line with step-size [2,0] that starts on point (0,0) will have an X value of '0', * whereas the line with step-size [2,0] that starts on point (1,0) will have an X value of '1', * because it's step size means it never intersects the y-axis at x = 0, but x = 1 is the nearest it gets to it, after 0. * * If the line is perfectly vertical, the axis will be flipped, so `X` in this * situation would be the nearest **Y**-value the line intersects on or above the x-axis. * @param step - Line step `[dx,dy]` * @param coords `[x,y]` - A point the line intersects * @returns the key `C|X` */ function getKeyFromLine(step: Vec2, coords: Coords): LineKey { const C = vectors.getLineCFromCoordsAndVec(coords, step); const X = getXFromLine(step, coords); return `${C}|${X}`; } /** Splits the `C` value out of the line key */ function getCFromKey(lineKey: LineKey): bigint { return BigInt(lineKey.split('|')[0]!); } /** * Calculates the `X` value of the line's key from the provided step direction and coordinates, * which is the nearest x-value the line intersects on or after the y-axis. * For example, the line with step-size [2,0] that starts on point (0,0) will have an X value of '0', * whereas the line with step-size [2,0] that starts on point (1,0) will have an X value of '1', * because it's step size means it never intersects the y-axis at x = 0, but x = 1 is the nearest it gets to it, after 0. * * If the line is perfectly vertical, the axis will be flipped, so `X` in this * situation would be the nearest **Y**-value the line intersects on or above the x-axis. * @param {Vec2} step - [dx,dy] * @param {Coords} coords - Coordinates that are on the line * @returns {number} The X in the line's key: `C|X` */ function getXFromLine(step: Coords, coords: Coords): bigint { // See these desmos graphs for inspiration for finding what line the coords are on: // https://www.desmos.com/calculator/d0uf1sqipn // https://www.desmos.com/calculator/t9wkt3kbfo const lineIsVertical = step[0] === 0n; const deltaAxis = lineIsVertical ? step[1] : step[0]; const coordAxis = lineIsVertical ? coords[1] : coords[0]; return bimath.posMod(coordAxis, deltaAxis); } // Exports -------------------------------------------------- export default { processInitialPosition, regenerateLists, generatePositionFromPieces, getPieceIterable, registerPieceInSpace, removePieceFromSpace, getTypeUndefinedsBehavior, getKeyFromLine, getCFromKey, getXFromLine, }; export type { OrganizedPieces, TypeRange, LineKey }; ================================================ FILE: src/shared/chess/logic/repetition.ts ================================================ // src/shared/chess/logic/repetition.ts /** * This script contains our algorithm for detecting draw by repetition. * * It is compatible with the enpassant state, as if 2 positions differ only * by the enpassant state, they are considered different. * * It also takes into account special rights. */ import type { MoveFull } from './movepiece.js'; import type { FullGame } from './gamefile.js'; import type { StateChange } from './state.js'; import type { GameConclusion } from '../util/winconutil.js'; import typeutil from '../util/typeutil.js'; import boardchanges from './boardchanges.js'; import { rawTypes as r } from '../util/typeutil.js'; /** Either a surplus/deficit, on an exact coordinate. This may include a piece type, or an enpassant state. */ type Flux = `${string},${string},${number | string}`; // `x,y,43` | `x,y,enpassant` /** * Tests if the provided gamefile has had a repetition draw. * * Complexity O(m) where m is the move count since the last pawn push or capture or special right loss! * @param gamefile - The gamefile * @returns Whether there is a three fold repetition present. */ function detectRepetitionDraw({ basegame, boardsim }: FullGame): GameConclusion | undefined { const moveList = boardsim.moves; const turnOrderLength = basegame.gameRules.turnOrder.length; /** What index of the turn order whos turn it is at the front of the game. * 0 => First player's turn in the turn order. */ const currentPlayerTurn = moveList.length % turnOrderLength; /** When compared to our current position, this is a running set of surpluses of previous positions, preventing them from being equivalent to the current position. */ const surplus = new Set(); /** When compared to our current position, this is a running set of deficits of previous positions, preventing them from being equivalent to the current position. */ const deficit = new Set(); let equalPositionsFound: number = 0; const startIndex: number = moveList.length - 1; let indexOfLastEqualPositionFound: number = startIndex + 1; // We need +1 because the first move we observe is the move that brought us to this move index. outer: for (let index = startIndex; index >= 0; index--) { // WILL BE -1 if we've reached the beginning of the game! const move: MoveFull = moveList[index]!; // Did this move include a one-way action? Pawn push, special right loss.. // If so, no further equal positions, terminate the loop. // 'capture' move changes are handled lower down, they are one-way too. if (typeutil.getRawType(move.type) === r.PAWN) break; // Pawn pushes reset the repetition alg because we know they can't move back to their previous position. if ( move.state.global.some( (stateChange: StateChange) => stateChange.type === 'specialrights' && stateChange.future === false, ) ) break; // specialright was lost, no way its equal to the current position, unless in the future it's possible to add specialrights mid-game. // Iterate through all move changes, adding the fluxes. for (const change of move.changes) { // Did this move change include a one-way action? (capture/deletion) If so, no further equal positions, terminate the loop. if (boardchanges.oneWayActions.includes(change.action)) break outer; // One-way action, can't be undone, no further equal positions. // The remaining actions are two-way, so we need to create fluxes for them.. if (change.action === 'move') { // If this change was undo'd, there would be a DEFICIT on its endCoords addDeficit(`${change.endCoords[0]},${change.endCoords[1]},${change.piece.type}`); // There would also be a SURPLUS on its startCoords addSurplus( `${change.piece.coords[0]},${change.piece.coords[1]},${change.piece.type}`, ); } else if (change.action === 'add') { // If this change was undo'd, there would be a DEFICIT on its coords addDeficit( `${change.piece.coords[0]},${change.piece.coords[1]},${change.piece.type}`, ); } } // Next, iterate through all enpassant state changes and add fluxes for them move.state.global.forEach((state: StateChange) => { if (state.type !== 'enpassant') return false; // Filter out non-enpassant states /** * If we reverse applied this enpassant state, * there would be a DEFICIT on the future value, * and a SURPLUS on the current value. * * The reason we should also specify in the flux the pawn's coords that double-pushed is because * in the 5D variant, you can't tell where the pawn is that double pushed. It could be 1 square away, or 10. * Which means the enpassant square is fundamentally different if the pawn to be captured is a different distance. */ if (state.future !== undefined) addDeficit( `${state.future.square[0]},${state.future.square[1]},${state.future.pawn[0]},${state.future.pawn[1]},enpassant`, ); if (state.current !== undefined) addSurplus( `${state.current.square[0]},${state.current.square[1]},${state.current.pawn[0]},${state.current.pawn[1]},enpassant`, ); return; // typescript needs this to not complain }); function addSurplus(flux: Flux): void { // If there is a DEFICIT with this exact same key, delete that instead! It's been canceled-out. if (deficit.has(flux)) deficit.delete(flux); else surplus.add(flux); } function addDeficit(flux: Flux): void { // If there is a SURPLUS with this exact same key, delete that instead! It's been canceled-out. if (surplus.has(flux)) surplus.delete(flux); else deficit.add(flux); } checkEqualPosition: { // Has a full turn cycle ocurred since the last increment of equalPositionsFound? // If so, we can't count this as an equal position, because it will break it in multiplayer games, // or if we have multiple turns in a row. const indexDiff = indexOfLastEqualPositionFound - index; if (indexDiff < turnOrderLength) break checkEqualPosition; // Hasn't been a full turn cycle yet, don't increment the counter // If both the deficit and surplus objects are EMPTY, this position is equal to our current position! if (surplus.size === 0 && deficit.size === 0) { // Check if it's the same player's turn as the front of the game, that is also a requirement for equality. const thisPlayerTurn = index % turnOrderLength; if (thisPlayerTurn !== currentPlayerTurn) break checkEqualPosition; // Equal! equalPositionsFound++; indexOfLastEqualPositionFound = index; if (equalPositionsFound === 2) break; // Enough to confirm a repetition draw! } } // console.log('Surplus:', surplus); // console.log('Deficit:', deficit); } // Loop is finished. How many equal positions did we find? if (equalPositionsFound === 2) return { victor: null, condition: 'repetition' }; else return undefined; } export { detectRepetitionDraw }; ================================================ FILE: src/shared/chess/logic/specialdetect.ts ================================================ // src/shared/chess/logic/specialdetect.ts /** * This detects if special moves are legal. * Does NOT execute the moves! */ import type { Coords } from '../util/coordutil.js'; import type { Player } from '../util/typeutil.js'; import type { CoordsTagged } from './movepiece.js'; import type { FullGame, Game, Board } from './gamefile.js'; import type { MoveTagged, MoveSpecialTags, SpecialTags } from './movepiece.js'; import bd from '@naviary/bigdecimal'; import math from '../../util/math/math.js'; import jsutil from '../../util/jsutil.js'; import bimath from '../../util/math/bimath.js'; import bounds from '../../util/math/bounds.js'; import vectors from '../../util/math/vectors.js'; import typeutil from '../util/typeutil.js'; import bdcoords from '../util/bdcoords.js'; import boardutil from '../util/boardutil.js'; import coordutil from '../util/coordutil.js'; import gamerules from '../util/gamerules.js'; import movepiece from './movepiece.js'; import legalmoves from './legalmoves.js'; import checkresolver from './checkresolver.js'; import gamefileutility from '../util/gamefileutility.js'; import organizedpieces from './organizedpieces.js'; import { players as p, rawTypes as r } from '../util/typeutil.js'; // Functions ----------------------------------------------------------------------- // EVERY one of these functions needs to include enough information in the special move tag // to be able to undo any of them! /** * Appends legal king special moves to the provided legal individual moves list. (castling) * @param gamefile - The gamefile * @param coords - Coordinates of the king selected * @param color - The color of the king selected * @param premove - Whether we should return all possible moves (premoving) */ function kings( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): CoordsTagged[] { const individualMoves: CoordsTagged[] = []; const { boardsim, basegame } = gamefile; if (!doesPieceHaveSpecialRight(boardsim, coords)) return individualMoves; // King doesn't have castling rights const king = boardutil.getPieceFromCoords(boardsim.pieces, coords)!; const kingX = coords[0]; const kingY = coords[1]; const oppositeColor = typeutil.invertPlayer(color); const key = organizedpieces.getKeyFromLine([1n, 0n], coords); const row = boardsim.pieces.lines.get('1,0')!.get(key)!; // Add legal Castling... let left: bigint | null = null; // Piece directly left of king. (Infinity if none) let right: bigint | null = null; // Piece directly right of king. (Infinity if none) // If premoving, skip obstruction and check checks. if (premove) { // Find the closest CASTLEABLE piece on each side of the king. for (const idx of row) { const pieceCoords = boardutil.getCoordsFromIdx(boardsim.pieces, idx); if (!isPieceCastleable(pieceCoords)) continue; // Piece is not castleable, skip it if (pieceCoords[0] < kingX && (left === null || pieceCoords[0] > left)) left = pieceCoords[0]; else if (pieceCoords[0] > kingX && (right === null || pieceCoords[0] < right)) right = pieceCoords[0]; } // THEN append the castling moves to the individual moves. processSide(left, -1n, premove); // Castling left processSide(right, 1n, premove); // Castling right } else { // Not premoving. Perform obsctruction and check checks as normal. // Find the CLOSEST piece on each side of the king. for (const idx of row) { const pieceCoords = boardutil.getCoordsFromIdx(boardsim.pieces, idx); if (pieceCoords[0] < kingX && (left === null || pieceCoords[0] > left)) left = pieceCoords[0]; else if (pieceCoords[0] > kingX && (right === null || pieceCoords[0] < right)) right = pieceCoords[0]; } // THEN check if the piece is castleable. processSide(left, -1n, premove); // Castling left processSide(right, 1n, premove); // Castling right } /** * Returns whether the piece at the given coordinates is castleable: * * Its distance from the king is at least 3 squares * * It has its special rights * * It is a friendly piece * * It is not a pawn or jumping royal */ function isPieceCastleable(pieceCoords: Coords): boolean { // Distance should be at least 3 squares away. const dist = bimath.abs(kingX - pieceCoords[0]); // Distance from the king to the piece if (dist < 3) return false; // Piece is too close, can't castle with it // Piece should have its special rights if (!doesPieceHaveSpecialRight(boardsim, pieceCoords)) return false; // Piece doesn't have special rights, can't castle with it // Color should be a friendly piece const pieceType: number = boardutil.getTypeFromCoords(boardsim.pieces, pieceCoords)!; const [rawType, pieceColor] = typeutil.splitType(pieceType); if (pieceColor !== color) return false; // Piece should not be a pawn or jumping royal if (rawType === r.PAWN || typeutil.jumpingRoyals.includes(rawType)) return false; return true; } /** * If the given side is legal to castle with, it will append the castling move to the individual moves. * @param pieceX - The X coordinate of the piece that the king is castling with, or -Infinity/Infinity if there is no piece on that side. * @param dir - The direction the king is moving in. 1 for right, -1 for left. * @param premove - PREMOVING: Whether we should ignore checks. */ function processSide(pieceX: bigint | null, dir: 1n | -1n, premove: boolean): void { if (pieceX === null) return; // No piece on this side, can't castle with it const pieceCoord: Coords = [pieceX, kingY]; // The coordinates of the piece that the king is castling with. if (!isPieceCastleable(pieceCoord)) return; // Piece is not castleable, skip it // Check checks: Only need if opponent is using checkmate as a win condition. // Can skip if we're premoving, as we can't predict if we will be in check. if ( gamerules.doesColorHaveWinCondition(basegame.gameRules, oppositeColor, 'checkmate') && !premove ) { // Can't currently be in check if (gamefileutility.isCurrentViewedPositionInCheck(boardsim)) return; // Not legal if in check // The square the king passes through must not be a check. Let's simulate that. const middleSquare: Coords = [kingX + dir, kingY]; // The square the king passes through if (checkresolver.isMoveCheckInvalid(gamefile, king, middleSquare, color)) return; // The square the king passes through is a check // The square the king LANDS ON will be tested later, within checkresolver. } // All checks passed, this side is legal to castle with. Add the move! const specialMove: CoordsTagged = [coords[0] + 2n * dir, coords[1]]; specialMove.castle = { dir, coord: pieceCoord }; // The special move tag, containing: The direction the king is moving in, and the coordinates of the piece that the king is castling with. individualMoves.push(specialMove); } return individualMoves; } /** * Appends legal pawn moves to the provided legal individual moves list. * This also is in charge of adding single-push, double-push, and capturing * pawn moves, even though those don't need a special move tag. * @param gamefile - The gamefile * @param coords - Coordinates of the pawn selected * @param color - The color of the pawn selected * @param premove - Whether we should return all possible moves (premoving) */ function pawns( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): CoordsTagged[] { const { boardsim, basegame } = gamefile; // White and black pawns move and capture in opposite directions. const yOneorNegOne = color === p.WHITE ? 1n : -1n; const individualMoves: CoordsTagged[] = []; // How do we go about calculating a pawn's legal moves? // 1. It can move forward if there is no piece there // Is there a piece in front of it? const singlePushCoord: CoordsTagged = [coords[0], coords[1] + yOneorNegOne]; let moveValidity = legalmoves.testSquareValidity( boardsim, gamefile.basegame.gameRules.worldBorder, singlePushCoord, color, premove, false, ); if (moveValidity === 0) { // Pawns forward-motion validity check must be 0, as they can't capture forward. appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, singlePushCoord, color); // Legal, add the move // Further... Is the double push legal? const doublePushCoord: CoordsTagged = [ singlePushCoord[0], singlePushCoord[1] + yOneorNegOne, ]; moveValidity = legalmoves.testSquareValidity( boardsim, gamefile.basegame.gameRules.worldBorder, doublePushCoord, color, premove, false, ); if (doesPieceHaveSpecialRight(boardsim, coords) && moveValidity === 0) { // Add the double push! // Only create the enpassantCreate tag if it's not a premove. if (!premove) doublePushCoord.enpassantCreate = getEnPassantGamefileProperty( coords, doublePushCoord, ); appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, doublePushCoord, color); } } // 2. It can capture diagonally if there are opponent pieces there const coordsToCapture: CoordsTagged[] = [ [coords[0] - 1n, coords[1] + yOneorNegOne], [coords[0] + 1n, coords[1] + yOneorNegOne], ]; for (const captureCoords of coordsToCapture) { const moveValidity = legalmoves.testSquareValidity( boardsim, gamefile.basegame.gameRules.worldBorder, captureCoords, color, premove, true, ); // true for capture is required if (moveValidity <= 1) appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, captureCoords, color); // Good to add the capture! } // 3. It can capture en passant if a pawn next to it just pushed twice. // Skip if we're premoving, as the capturing moves are added above if (!premove) addPossibleEnPassant(gamefile, individualMoves, coords, color); return individualMoves; } /** * Returns what the gamefile's enpassant property should be after this double pawn push move * @param moveStartCoords - The start coordinates of the move * @param moveEndCoords - The end coordinates of the move * @returns The coordinates en passant is allowed */ function getEnPassantGamefileProperty( moveStartCoords: Coords, moveEndCoords: Coords, ): MoveSpecialTags['enpassantCreate'] { const y = (moveStartCoords[1] + moveEndCoords[1]) / 2n; const enpassantSquare: Coords = [moveStartCoords[0], y]; return { square: enpassantSquare, pawn: coordutil.copyCoords(moveEndCoords) }; // Copy needed to strip endCoords of existing special tags } /** * Appends legal enpassant capture to the selected pawn's provided individual moves. * @param gamefile - The gamefile * @param individualMoves - The running list of legal individual moves * @param coords - The coordinates of the pawn selected, [x,y] * @param color - The color of the pawn selected */ // If it can capture en passant, the move is appended to legalmoves function addPossibleEnPassant( { boardsim, basegame }: FullGame, individualMoves: CoordsTagged[], coords: Coords, color: Player, ): void { if (boardsim.state.global.enpassant === undefined) return; // No enpassant tag on the game, no enpassant possible if (color !== basegame.whosTurn) return; // Not our turn (the only color who can legally capture enpassant is whos turn it is). If it IS our turn, this also guarantees the captured pawn will be an enemy pawn. const enpassantCapturedPawnType = boardutil.getTypeFromCoords( boardsim.pieces, boardsim.state.global.enpassant.pawn, )!; if (typeutil.getColorFromType(enpassantCapturedPawnType) === color) return; // The captured pawn is not an enemy pawn. THIS IS ONLY EVER NEEDED if we can move opponent pieces on our turn, which is the case in EDIT MODE. const xDifference = boardsim.state.global.enpassant.square[0] - coords[0]; if (bimath.abs(xDifference) !== 1n) return; // Not immediately left or right of us // prettier-ignore const yParity = color === p.WHITE ? 1n : color === p.BLACK ? -1n : (() => { throw new Error("Invalid color!"); })(); if (coords[1] + yParity !== boardsim.state.global.enpassant.square[1]) return; // Not one in front of us // It is capturable en passant! /** The square the pawn lands on. */ const enPassantSquare: CoordsTagged = coordutil.copyCoords( boardsim.state.global.enpassant.square, ); // TAG THIS MOVE as an en passant capture!! gamefile looks for this tag // on the individual move to detect en passant captures and know when to perform them. enPassantSquare.enpassant = true; appendPawnMoveAndAttachPromoteTag(basegame, individualMoves, enPassantSquare, color); } /** * Appends the provided move to the running individual moves list, * and adds the `promoteTrigger` special tag to it if it landed on a promotion rank. */ function appendPawnMoveAndAttachPromoteTag( basegame: Game, individualMoves: CoordsTagged[], landCoords: CoordsTagged, color: Player, ): void { if (basegame.gameRules.promotionRanks !== undefined) { const teamPromotionRanks = basegame.gameRules.promotionRanks[color]; if (teamPromotionRanks?.includes(landCoords[1])) landCoords.promoteTrigger = true; } individualMoves.push(landCoords); } /** * Appends legal moves for the rose piece to the provided legal individual moves list. * @param gamefile - The gamefile * @param coords - Coordinates of the rose selected * @param color - The color of the rose selected * @param premove - Whether we should return all possible moves (premoving) * @returns */ function roses( gamefile: FullGame, coords: Coords, color: Player, premove: boolean, ): CoordsTagged[] { // prettier-ignore const movements: Coords[] = [[-2n, -1n], [-1n, -2n], [1n, -2n], [2n, -1n], [2n, 1n], [1n, 2n], [-1n, 2n], [-2n, 1n]]; // Counter-clockwise const directions = [1, -1] as const; // Counter-clockwise and clockwise directions const individualMoves: CoordsTagged[] = []; for (let i = 0; i < movements.length; i++) { for (const direction of directions) { let currentCoord: CoordsTagged = coordutil.copyCoords(coords); let b = i; const path = [coords]; // The running path of travel for the current spiral. Used for animating. for (let c = 0; c < movements.length - 1; c++) { // Iterate 7 times, since we can't land on the square we started const movement = movements[math.posMod(b, movements.length)]!; currentCoord = coordutil.addCoords(currentCoord, movement); path.push(coordutil.copyCoords(currentCoord)); const moveValidity = legalmoves.testSquareValidity( gamefile.boardsim, gamefile.basegame.gameRules.worldBorder, currentCoord, color, premove, false, ); if (moveValidity <= 1) appendCoordToIndividuals(currentCoord, path); // Capture is legal if (moveValidity >= 1) break; // Blocked, break the spiral b += direction; // Update 'b' for the next iteration } } } return individualMoves; /** * Appends a ROSE coordinate to the individual moves list if it's not already present. * If it is present, it chooses the one according to this priority: * 1. Shortest path * 2. Path that curves towards the center of play * 3. Randomly pick one * @param {Coords} newCoord - The coordinate to append [x, y]. */ function appendCoordToIndividuals(newCoord: CoordsTagged, path: Coords[]): void { newCoord.path = jsutil.deepCopyObject(path); for (let i = 0; i < individualMoves.length; i++) { const coord = individualMoves[i]!; if (!coordutil.areCoordsEqual(coord, newCoord)) continue; /* * This coord has already been added to our individual moves!!! * Pick the one with the shortest path. */ if (coord.path!.length < newCoord.path.length) individualMoves[i] = coord; // First path shorter else if (coord.path!.length > newCoord.path.length) individualMoves[i] = newCoord; // Second path shorter else if (coord.path!.length === newCoord.path.length) { // Path are equal length // Pick the one that curves towards the center of play, // as that's more likely to stay within the window during animation. const coordsBD = bdcoords.FromCoords(coords); const coordPathBD = bdcoords.FromCoords(coord.path![1]!); const newCoordPathBD = bdcoords.FromCoords(newCoord.path[1]!); const startingBoxBD = bounds.castBoundingBoxToBigDecimal( gamefile.boardsim.startSnapshot.box, ); const centerOfPlay = bounds.calcCenterOfBoundingBox(startingBoxBD); const vectorToCenter = vectors.calculateVectorFromBDPoints(coordsBD, centerOfPlay); const existingCoordVector = vectors.calculateVectorFromBDPoints( coordsBD, coordPathBD, ); const newCoordVector = vectors.calculateVectorFromBDPoints( coordsBD, newCoordPathBD, ); // Whichever's dot product scores higher is the one that curves more towards the center const existingCoordDotProd = vectors.dotProductBD( existingCoordVector, vectorToCenter, ); const newCoordDotProd = vectors.dotProductBD(newCoordVector, vectorToCenter); const compareResult = bd.compare(existingCoordDotProd, newCoordDotProd); if (compareResult > 0) individualMoves[i] = coord; // Existing move's path curves more towards the center else if (compareResult < 0) individualMoves[i] = newCoord; // New move's path curves more towards the center else { // BOTH point equally point towards the origin. // JUST pick a random one! individualMoves[i] = Math.random() < 0.5 ? coord : newCoord; } } return; } // This coordinate has not been added yet. Let's do it now. individualMoves.push(newCoord); } } /** * Tests if the piece at the given coordinates has it's special move rights. * @param gamefile - The gamefile * @param coords - The coordinates of the piece * @returns *true* if it has it's special move rights. */ function doesPieceHaveSpecialRight(boardsim: Board, coords: Coords): boolean { const key = coordutil.getKeyFromCoords(coords); return boardsim.state.global.specialRights.has(key); } // Returns true if the type is a pawn and the coords it moved to is a promotion line /** * Returns true if a pawn moved onto a promotion line. * @param gamefile * @param type * @param coordsClicked * @returns */ function isPawnPromotion(basegame: Game, type: number, coordsClicked: Coords): boolean { if (typeutil.getRawType(type) !== r.PAWN) return false; if (!basegame.gameRules.promotionRanks) return false; // This game doesn't have promotion. const color = typeutil.getColorFromType(type); const promotionRanks = basegame.gameRules.promotionRanks[color]; return promotionRanks?.includes(coordsClicked[1]) || false; } /** * Transfers the move-retained special tags from the provided coordinates to the move. * UI-only tags (e.g. `promoteTrigger`) are intentionally excluded — they are * consumed and deleted before any call to this function. */ function transferSpecialTags_FromCoordsToMove(coords: CoordsTagged, move: MoveTagged): void { for (const special of movepiece.MOVE_SPECIAL_TAGS) { transferSpecialTag(coords, move, special); } } /** * Transfers all special move tags (move and UI) from one set of coordinates to another. * @param srcCoords - The source coordinates * @param destCoords - The destination coordinates */ function transferSpecialTags_FromCoordsToCoords( srcCoords: CoordsTagged, destCoords: CoordsTagged, ): void { for (const special of movepiece.SPECIAL_TAGS) { transferSpecialTag(srcCoords, destCoords, special); } } /** * Copies a single {@link SpecialTags} key from `src` to `dest`. * * Keeping `Tags = MoveSpecialTags` fixed and `K` as a free parameter gives * TypeScript full correlation between the key and value types on both sides, * so the assignment is verified with full type safety. */ function transferSpecialTag( src: Partial, dest: Partial, key: K, ): void { if (src[key] !== undefined) dest[key] = jsutil.deepCopyObject(src[key]); // SpecialTag[K] → SpecialTag[K] | undefined ✓ } // Exports ----------------------------------------------------------------------- export default { kings, pawns, roses, getEnPassantGamefileProperty, isPawnPromotion, transferSpecialTags_FromCoordsToMove, transferSpecialTags_FromCoordsToCoords, }; ================================================ FILE: src/shared/chess/logic/specialmove.ts ================================================ // src/shared/chess/logic/specialmove.ts /** This script stores the default methods for EXECUTING special moves */ import type { Piece } from '../util/boardutil.js'; import type { Board } from './gamefile.js'; import type { Coords } from '../util/coordutil.js'; import type { RawTypeGroup } from '../util/typeutil.js'; import type { Edit, MoveTagged } from './movepiece.js'; import state from './state.js'; import boardutil from '../util/boardutil.js'; import boardchanges from './boardchanges.js'; import { rawTypes as r } from '../util/typeutil.js'; /** * Function that queues all of the changes a special move makes when executed. */ type SpecialMoveFunction = (_boardsim: Board, _piece: Piece, _move: MoveRunning) => boolean; /** All properties of the Move that special move functions need to access */ interface MoveRunning extends MoveTagged, Edit {} /** * An object storing the squares in the immediate vicinity * a piece has a CHANCE of making a special-move capture from. * * The value is a list of coordinates that it may be possible for that raw piece type to make a special capture from that distance. */ type SpecialVicinity = RawTypeGroup; // This returns the functions for executing special moves, // it does NOT calculate if they're legal. // In the future, parameters can be added if variants have // different special moves for pieces. const defaultSpecialMoves: RawTypeGroup = { [r.KING]: kings, [r.ROYALCENTAUR]: kings, [r.PAWN]: pawns, [r.ROSE]: roses, }; // A custom special move needs to be able to: // * Delete a custom piece // * Move a custom piece // * Add a custom piece // ALL FUNCTIONS NEED TO: // * Make the move // * Append the move // Called when the piece moved is a king. // Tests if the move contains "castle" special move, if so it executes it! // RETURNS FALSE if special move was not executed! function kings(boardsim: Board, piece: Piece, move: MoveRunning): boolean { const specialTag = move.castle; // { dir: -1/1, coord } if (!specialTag) return false; // No special move to execute, return false to signify we didn't move the piece. // Move the king to new square const moveChanges = move.changes; const kingCapturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, move.endCoords); // CASTLING CAN CAPTURE A PIECE IF IT'S A PREMOVE!!! if (kingCapturedPiece) boardchanges.queueCapture(moveChanges, true, kingCapturedPiece); // Capture piece boardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords); // Move the rook to new square const pieceToCastleWith = boardutil.getPieceFromCoords(boardsim.pieces, specialTag.coord)!; const landSquare: Coords = [move.endCoords[0] - specialTag.dir, move.endCoords[1]]; const rookCapturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, landSquare); // CASTLING CAN CAPTURE A PIECE IF IT'S A PREMOVE!!! if (rookCapturedPiece) boardchanges.queueCapture(moveChanges, false, rookCapturedPiece); // Capture piece boardchanges.queueMovePiece(moveChanges, false, pieceToCastleWith, landSquare); // Special move was executed! // (There is no captured piece with castling) return true; } function pawns(boardsim: Board, piece: Piece, move: MoveRunning): boolean { const moveChanges = move.changes; // If it was a double push, then queue adding the new enpassant square to the gamefile! if (move.enpassantCreate !== undefined) state.createEnPassantState(move, boardsim.state.global.enpassant, move.enpassantCreate); const enpassantTag = move.enpassant; // true | undefined const promotionTag = move.promotion; // promote type if (!enpassantTag && !promotionTag) return false; // No special move to execute, return false to signify we didn't move the piece. const captureCoords = enpassantTag ? boardsim.state.global.enpassant!.pawn : move.endCoords; // const captureCoords = enpassantTag ? getEnpassantCaptureCoords(move.endCoords, enpassantTag) : move.endCoords; const capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, captureCoords); // Delete the piece captured if (capturedPiece) boardchanges.queueCapture(moveChanges, true, capturedPiece); boardchanges.queueMovePiece(moveChanges, true, piece, move.endCoords); if (promotionTag) { // Delete original pawn boardchanges.queueDeletePiece(moveChanges, true, { coords: move.endCoords, type: piece.type, index: piece.index, }); boardchanges.queueAddPiece(moveChanges, { coords: move.endCoords, type: promotionTag, index: -1, }); } // Special move was executed! return true; } // The Roses need a custom special move function so that it can pass the `path` special flag to the move changes. function roses(boardsim: Board, piece: Piece, move: MoveRunning): boolean { const capturedPiece = boardutil.getPieceFromCoords(boardsim.pieces, move.endCoords); // Delete the piece captured if (capturedPiece) boardchanges.queueCapture(move.changes, true, capturedPiece); boardchanges.queueMovePiece(move.changes, true, piece, move.endCoords, move.path); // Special move was executed! return true; } /** * Returns the coordinate distances certain piece types have a chance * of special-move capturing on, according to the default specialMove functions. */ function getDefaultSpecialVicinitiesByPiece(): SpecialVicinity { // prettier-ignore return { [r.PAWN]: [[-1n,1n],[1n,1n],[-1n,-1n],[1n,-1n]], // All squares a pawn could potentially capture on. // All squares a rose piece could potentially capture on. [r.ROSE]: [[-2n,-1n],[-3n,-3n],[-2n,-5n],[0n,-6n],[2n,-5n],[3n,-3n],[2n,-1n],[-4n,0n],[-5n,2n],[-4n,4n],[-2n,5n],[0n,4n],[1n,2n],[-1n,-2n],[0n,-4n],[4n,-4n],[5n,-2n],[4n,0n],[2n,1n],[-5n,-2n],[-6n,0n],[-3n,3n],[-1n,2n],[1n,-2n],[6n,0n],[5n,2n],[3n,3n],[-4n,-4n],[-2n,1n],[4n,4n],[2n,5n],[0n,6n]], }; } export default { defaultSpecialMoves, getDefaultSpecialVicinitiesByPiece, }; export type { MoveRunning, SpecialMoveFunction, SpecialVicinity }; ================================================ FILE: src/shared/chess/logic/state.ts ================================================ // src/shared/chess/logic/state.ts /** * This script creates, queues, and applies gamefile states * to the gamefile when a Move is created, and executed. */ import type { Coords } from '../util/coordutil.js'; import type { CoordsKey } from '../util/coordutil.js'; import type { Edit, MoveSpecialTags } from './movepiece.js'; // Types ----------------------------------------------------------------------------------------------- /** The state of a game holds variables that change over the duration of it. */ interface GameState { local: LocalGameState; global: GlobalGameState; } /** State of a specific move your are VIEWING. */ interface LocalGameState { /** Index of the move we're currently viewing in the moves list. -1 means we're looking at the very beginning of the game. */ moveIndex: number; /** If the currently-viewed move is in check, this will be a list of coordinates * of all the royal pieces in check: `[[5,1],[10,1]]`, otherwise *false*. @type {} */ inCheck: Coords[] | false; /** * All active checks against whoever's turn it is, each pairing the checked royal with * its attacker. ONLY USED with the `checkmate` win condition!! * Only used to calculate legal moves, and detect checkmate. * The same royal or attacker may appear in multiple checks, in scenarios such as double checks. */ checks: CheckInfo[]; } /** * State of a game that DOESN'T change depending on what move your VIEWING, * but DO change when new moves are made, or rewound (deleted). * * They represent the state of the game at the FRONT. */ interface GlobalGameState { /** An object containing the information if each individual piece has its special move rights. */ specialRights: Set; /** If enpassant is allowed at the front of the game, this defines the coordinates. */ enpassant?: EnPassant; /** The number of half-moves played since the last capture or pawn push. */ moveRuleState?: number; } // TODO: Move to gamefile type definition (right now it's not in typescript) type inCheck = false | Coords[]; /** * * Local statechanges are unique to the move you're viewing, and are always applied. Those include: * * check, checks * * Global statechanges are a property of the game as a whole, not unique to the move, * and are not applied when VIEWING a move. * However, they are applied only when we make a new move, or rewind a simulated one. Those include: * * enpassant, specialrights, moverulestate */ /** * Contains the statechanges for the turn before and after a move is made * * Local state change examples: (check, checks) * Global state change examples: (enpassant, specialrights, moverule state, running check counter) */ interface MoveState { local: Array; global: Array; } /** * A state change, local or global, that contains enough information to set the gamefile's * property whether the move is being rewound or replayed. */ type StateChange = | { /** The type of state this {@link StateChange} is */ type: 'check'; /* The gamefile's property of this type BEFORE this move was made, used to restore them when the move is rewinded. */ current: inCheck; /* The gamefile's property of this type AFTER this move was made, used to restore them when the move is replayed. */ future: inCheck; } | { type: 'checks'; current: CheckInfo[]; future: CheckInfo[]; } | { type: 'enpassant'; current?: EnPassant; future?: EnPassant; } | { type: 'specialrights'; current: boolean; future: boolean; /** The coordsKey of what square was affected by this specialrights state change. */ coordsKey: CoordsKey; } | { type: 'moverulestate'; current: number; future: number; }; /** A single check being delivered: the checked royal paired with its attacker. */ type CheckInfo = { /** The coordinates of the royal being checked */ royal: Coords; /** The coordinates of the attacking piece */ attacker: Coords; /** Whether the check is delivered via a sliding movement (not individual, NOR special with a `path` attribute) */ slidingCheck: boolean; } & ( | { slidingCheck: true; /** Whether the attacker is moving colinearly. */ colinear: boolean; } | { slidingCheck: false; /** Optionally, if it's an individual (non-slidingCheck), the path this piece takes to check the royal (e.g. Rose piece) */ path?: MoveSpecialTags['path']; } ); interface EnPassant { /** The enpassant square. */ square: Coords; /** * The square the pawn that doubled pushed is on. * * We need this info, because otherwise in the 5D variant, * you can't tell where the pawn is that double pushed. * It could be 1 square away, or 10. */ pawn: Coords; } // Creating Local State Changes -------------------------------------------------------------------- /** Creates a check local StateChange, adding it to the Move and immediately applying it to the gamefile. */ function createCheckState( move: Edit, current: inCheck, future: inCheck, gamestate: GameState, ): void { const newStateChange: StateChange = { type: 'check', current, future }; move.state.local.push(newStateChange); // Check is a local state // Check states are immediately applied to the gamefile applyLocalState(gamestate.local, newStateChange, true); } /** Creates a checks local StateChange, adding it to the Move and immediately applying it to the gamefile. */ function createChecksState( move: Edit, current: CheckInfo[], future: CheckInfo[], gamestate: GameState, ): void { const newStateChange: StateChange = { type: 'checks', current, future }; move.state.local.push(newStateChange); // Checks is a local state // Checks states are immediately applied to the gamefile applyLocalState(gamestate.local, newStateChange, true); } // Creating Global State Changes -------------------------------------------------------------------- /** Creates an enpassant global StateChange, queueing it by adding it to the Move. */ function createEnPassantState(move: Edit, current?: EnPassant, future?: EnPassant): void { if (current === future) return; // If the current and future values are identical, we can skip queueing this state. const newStateChange: StateChange = { type: 'enpassant', current, future }; // Check to make sure there isn't already an enpassant state change, // If so, we need to overwrite that one's future value, instead of queueing a new one. const preExistingEnPassantState = move.state.global.find((state) => state.type === 'enpassant'); if (preExistingEnPassantState !== undefined) preExistingEnPassantState.future = future; else move.state.global.push(newStateChange); // EnPassant is a global state } /** * Creates a specialrights global StateChange, queueing it by adding it to the Move. * IN NORMAL GAMES (outside of board editor), `current` and `future` SHOULD NEVER BE EQUAL, * otherwise it breaks the threefold repetition algorithm!! * We can't just exit early if they are equal, because the board editor needs to be able to create * multiple state changes with equal current and future values for accurate selection tool reflections. */ function createSpecialRightsState( move: Edit, coordsKey: CoordsKey, current: boolean, future: boolean, ): void { const newStateChange: StateChange = { type: 'specialrights', current, future, coordsKey }; move.state.global.push(newStateChange); // Special Rights is a global state } /** Creates a moverule global StateChange, queueing it by adding it to the Move. */ function createMoveRuleState(move: Edit, current: number, future: number): void { if (current === future) return; // If the current and future values are identical, we can skip queueing this state. const newStateChange: StateChange = { type: 'moverulestate', current, future }; move.state.global.push(newStateChange); // Special Rights is a global state } // Applying State Changes ---------------------------------------------------------------------------- /** * Applies all the StateChanges of a Move, in order, to the gamefile, * whether forward or backward, local or global. */ function applyMove( gamestate: GameState, moveState: MoveState, /** Whether we're playing this move forward or backward. */ forward: boolean, /** * Specify `globalChange` as true if you are making a physical move in the game, * or rewinding a simulated move. * All other situations, such as rewinding and forwarding the game, should only * be local, so `globalChange` should be false. */ { globalChange = false } = {}, ): void { applyLocalStateChanges(gamestate.local, moveState.local, forward); if (globalChange) applyGlobalStateChanges(gamestate.global, moveState.global, forward); } function applyLocalStateChanges( gamestate: LocalGameState, changes: Array, forward: boolean, ): void { for (const state of changes) { applyLocalState(gamestate, state, forward); } } function applyGlobalStateChanges( gamestate: GlobalGameState, changes: Array, forward: boolean, ): void { /** The reason we don't include the whole gamefile is so that {@link gamecompressor.GameToPosition} can also use applyMove(). */ for (const state of changes) { applyGlobalState(gamestate, state, forward); } } /** Applies a move's local state change to the gamefile, forward or backward. */ function applyLocalState(gamestate: LocalGameState, state: StateChange, forward: boolean): void { const noNewValue = (forward ? state.future : state.current) === undefined; switch (state.type) { case 'check': gamestate.inCheck = forward ? state.future : state.current; break; case 'checks': if (noNewValue) gamestate.checks = []; else gamestate.checks = forward ? state.future : state.current; break; default: throw new Error(`State ${state.type} is not a local state change.`); } } /** Applies a move's global state change to the gamefile, forward or backward. */ function applyGlobalState(gamestate: GlobalGameState, state: StateChange, forward: boolean): void { const noNewValue = (forward ? state.future : state.current) === undefined; switch (state.type) { case 'specialrights': if (!(forward ? state.future : state.current)) gamestate.specialRights.delete(state.coordsKey); else gamestate.specialRights.add(state.coordsKey); break; case 'enpassant': if (noNewValue) delete gamestate.enpassant; else gamestate.enpassant = forward ? state.future : state.current; break; case 'moverulestate': gamestate.moveRuleState = forward ? state.future : state.current; break; default: throw new Error(`State ${state.type} is not a global state change.`); } } // Exports -------------------------------------------------------------------------- export default { applyMove, applyGlobalStateChanges, createCheckState, createChecksState, createEnPassantState, createSpecialRightsState, createMoveRuleState, }; export type { GameState, GlobalGameState, MoveState, StateChange, CheckInfo, EnPassant }; ================================================ FILE: src/shared/chess/logic/wincondition.ts ================================================ // src/shared/chess/logic/wincondition.ts /** * This script contains the methods for calculating if the * game is over by the win condition used, for all win * conditions except for checkmate, stalemate, and repetition. */ import type { Coords } from '../util/coordutil.js'; import type { GameConclusion } from '../util/winconutil.js'; import type { Board, FullGame } from './gamefile.js'; import moveutil from '../util/moveutil.js'; import boardutil from '../util/boardutil.js'; import boardchanges from './boardchanges.js'; import gamefileutility from '../util/gamefileutility.js'; import typeutil, { RawType } from '../util/typeutil.js'; import { detectRepetitionDraw } from './repetition.js'; import { rawTypes as r, Player } from '../util/typeutil.js'; import { detectInsufficientMaterial } from './insufficientmaterial.js'; import { detectCheckmateOrStalemate, pieceCountToDisableCheckmate, royalCountToDisableCheckmate, } from './checkmate.js'; // The squares in KOTH where if you get your king to you WIN // prettier-ignore const kothCenterSquares: Coords[] = [[4n, 4n], [5n, 4n], [4n, 5n], [5n, 5n]]; /** * Tests if the game is over by the win condition used, and if so, * returns the `gameConclusion` property of the gamefile. * For example, `{ victor: 1, condition: 'checkmate' }`, or `{ victor: 0, condition: 'stalemate' }`. * @param gamefile - The gamefile * @returns The conclusion object, if the game is over. For example, `{ victor: 1, condition: 'checkmate' }`, or `{ victor: 0, condition: 'stalemate' }`. If the game isn't over, this returns *undefined*. */ function getGameConclusion(gamefile: FullGame): GameConclusion | undefined { if (!moveutil.areWeViewingLatestMove(gamefile.boardsim)) throw new Error("Cannot perform game over checks when we're not on the last move."); return ( detectAllpiecescaptured(gamefile) || detectRoyalCapture(gamefile) || detectAllroyalscaptured(gamefile) || detectKoth(gamefile) || detectRepetitionDraw(gamefile) || detectCheckmateOrStalemate(gamefile) || // This needs to be last so that a draw isn't enforced in a true win detectMoveRule(gamefile) || // 50-move-rule detectInsufficientMaterial(gamefile.basegame.gameRules, gamefile.boardsim) || undefined ); // No win condition passed. No game conclusion! } function detectRoyalCapture({ boardsim, basegame }: FullGame): GameConclusion | undefined { if (!gamefileutility.isOpponentUsingWinCondition(basegame, basegame.whosTurn, 'royalcapture')) return undefined; // Not using this gamerule // Was the last move capturing a royal piece? if (wasLastMoveARoyalCapture(boardsim)) { const colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex( basegame, boardsim.moves.length - 1, ); return { victor: colorThatWon, condition: 'royalcapture' }; } return undefined; } function detectAllroyalscaptured({ boardsim, basegame }: FullGame): GameConclusion | undefined { if ( !gamefileutility.isOpponentUsingWinCondition( basegame, basegame.whosTurn, 'allroyalscaptured', ) ) return undefined; // Not using this gamerule if (!wasLastMoveARoyalCapture(boardsim)) return undefined; // Last move wasn't a royal capture. // Are there any royal pieces remaining? // Remember that whosTurn has already been flipped since the last move. const royalCount: Coords[] = boardutil.getRoyalCoordsOfColor( boardsim.pieces, basegame.whosTurn, ); if (royalCount.length === 0) { const colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex( basegame, boardsim.moves.length - 1, ); return { victor: colorThatWon, condition: 'allroyalscaptured' }; } return undefined; } function detectAllpiecescaptured({ boardsim, basegame }: FullGame): GameConclusion | undefined { if ( !gamefileutility.isOpponentUsingWinCondition( basegame, basegame.whosTurn, 'allpiecescaptured', ) ) return undefined; // Not using this gamerule // If the player who's turn it is now has zero pieces left, win! const count: number = boardutil.getPieceCountOfColor(boardsim.pieces, basegame.whosTurn); if (count === 0) { const colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex( basegame, boardsim.moves.length - 1, ); return { victor: colorThatWon, condition: 'allpiecescaptured' }; } return undefined; } function detectKoth({ boardsim, basegame }: FullGame): GameConclusion | undefined { if (!gamefileutility.isOpponentUsingWinCondition(basegame, basegame.whosTurn, 'koth')) return undefined; // Not using this gamerule // Was the last move a king move? const lastMove = moveutil.getLastMove(boardsim.moves); if (!lastMove) return undefined; if (typeutil.getRawType(lastMove.type) !== r.KING) return undefined; let kingInCenter = false; for (const thisCenterSquare of kothCenterSquares) { const typeAtSquare: number | undefined = boardutil.getTypeFromCoords( boardsim.pieces, thisCenterSquare, ); if (typeAtSquare === undefined) continue; if (typeutil.getRawType(typeAtSquare) === r.KING) { kingInCenter = true; break; } } if (kingInCenter) { const colorThatWon: Player = moveutil.getColorThatPlayedMoveIndex( basegame, boardsim.moves.length - 1, ); return { victor: colorThatWon, condition: 'koth' }; } return undefined; } /** * Detects if the game is over by, for example, the 50-move rule. * @param gamefile - The gamefile * @returns `{ victor: 0, condition: 'moverule' }`, if the game is over by the move-rule, otherwise *undefined*. */ function detectMoveRule({ boardsim, basegame }: FullGame): GameConclusion | undefined { if (basegame.gameRules.moveRule === undefined) return undefined; // No move-rule being used if (boardsim.state.global.moveRuleState === basegame.gameRules.moveRule) { return { victor: null, condition: 'moverule' }; } return undefined; } // Returns true if the very last move captured a royal piece. function wasLastMoveARoyalCapture(boardsim: Board): boolean | undefined { const lastMove = moveutil.getLastMove(boardsim.moves); if (!lastMove) return undefined; const capturedTypes = new Set(); boardchanges.getCapturedPieceTypes(lastMove).forEach((type: number) => { capturedTypes.add(typeutil.getRawType(type)); }); if (capturedTypes.size === 0) return undefined; // Last move not a capture // Vscode or the Node.js environment does NOT have set methods! // return !capturedTypes.isDisjointFrom(new Set(typeutil.royals)); // disjoint if they share nothing in common // Check if any captured type is a royal piece. const royalSet = new Set(typeutil.royals); for (const capturedType of capturedTypes) { if (royalSet.has(capturedType)) return true; } return false; } /** * If the game is multiplayer, or if anyone gets multiple turns in a row, then that allows capturing * of the kings no matter the win conditions, by way of one person opening a discovered on turn 1, and * another person capturing the king on turn 2 => CHECKMATE NOT COMPATIBLE! * * Checkmate is also not compatible with games with colinear lines present, because the logic surrounding * making opening discovered attacks illegal is a nightmare. * @param gamefile * @returns true if the gamefile is checkmate compatible */ function isCheckmateCompatibleWithGame({ boardsim, basegame }: FullGame): boolean { if (boardsim.editor) return false; // This prevents legal move calculation respecting check in the editor. if (boardutil.getPieceCountOfGame(boardsim.pieces) > pieceCountToDisableCheckmate) return false; // Too many pieces (checkmate algorithm takes too long) if (boardsim.pieces.slides.length > 16) return false; // If the game has more lines than this, then checkmate creates lag spikes. if (gamefileutility.getPlayerCount(basegame) > 2) return false; // 3+ Players allows for 1 player to open a discovered and a 2nd to capture a king. CHECKMATE NOT COMPATIBLE if (moveutil.doesAnyPlayerGet2TurnsInARow(basegame)) return false; // This also allows the capture of the king. if (boardutil.getRoyalCountOfGame(boardsim.pieces) > royalCountToDisableCheckmate) return false; // Too many royals (check & checkmate algorithm takes too long) return true; // Checkmate compatible! } export default { getGameConclusion, isCheckmateCompatibleWithGame, }; ================================================ FILE: src/shared/chess/util/bdcoords.ts ================================================ // src/shared/chess/util/bdcoords.ts import type { BDCoords, Coords, DoubleCoords } from './coordutil'; import { fromBigInt, fromNumber, isInteger, toBigInt, toNumber } from '@naviary/bigdecimal'; // Constructors -------------------------------------------------------------------- /** Converts BigInt Coords to BDCoords (BigDecimal), capable of decimal arithmetic. */ function FromCoords(coords: Coords, precision?: number): BDCoords { return [fromBigInt(coords[0], precision), fromBigInt(coords[1], precision)]; } /** Converts coordinates of javascript doubles to BDCoords (BigDecimal) */ function FromDoubleCoords(coords: DoubleCoords): BDCoords { return [fromNumber(coords[0]), fromNumber(coords[1])]; } // Comparisons ------------------------------------------------------------------------ /** * Checks if both coordinates in a BDCoords tuple represent perfect integers. * This is useful for determining if a point lies exactly on an integer grid. * @param coords The BDCoords tuple [x, y] to check. * @returns True if both the x and y coordinates are whole numbers. */ function areCoordsIntegers(coords: BDCoords): boolean { return isInteger(coords[0]) && isInteger(coords[1]); } // Conversion ------------------------------------------------------------------------ /** * Converts a pair of bigdecimal coords into normal bigint Coords. * THIS WILL LOSE PRECISION if you aren't already confident that both * coordinates are integers! */ function coordsToBigInt(coords: BDCoords): Coords { // Convert each coordinate to a BigInt using the toBigInt function. return [toBigInt(coords[0]), toBigInt(coords[1])]; } /** * Converts a pair of bigdecimal coords into DoubleCoords. * Only call if you are CONFIDENT all both coordinates won't overflow or underflow! */ function coordsToDoubles(coords: BDCoords): DoubleCoords { // Convert each coordinate to a BigInt using the toBigInt function. return [toNumber(coords[0]), toNumber(coords[1])]; } export default { // Constructors FromCoords, FromDoubleCoords, // Comparisons areCoordsIntegers, // Conversion coordsToBigInt, coordsToDoubles, }; ================================================ FILE: src/shared/chess/util/boardutil.ts ================================================ // src/shared/chess/util/boardutil.ts /** * This script contains utility methods for working with the organized pieces of a game. */ import type { RawType, Player } from './typeutil.js'; import type { Coords, CoordsKey } from './coordutil.js'; import type { OrganizedPieces, TypeRange } from '../logic/organizedpieces.js'; import jsutil from '../../util/jsutil.js'; import vectors from '../../util/math/vectors.js'; import typeutil from './typeutil.js'; import coordutil from './coordutil.js'; import organizedpieces from '../logic/organizedpieces.js'; import bounds, { BoundingBox } from '../../util/math/bounds.js'; // Types ---------------------------------------------------------------------------------------------------- interface Piece { type: number; coords: Coords; /** * Relative to the start of its type range. * To get the absolute idx, use boardutil.getAbsoluteIdx. * * This will be -1 if the piece does not have an index yet. * This will get set to another number when it is added to the board. */ index: number; } // Counting Pieces ---------------------------------------------------------------------------------------------- /** * Counts the number of pieces in the gamefile. Doesn't count undefined placeholders. * @param o - The pieces * @param [options] - Optional settings. * @param [options.ignoreColors] - Whether to ignore certain colors eg p.NEUTRAL. * @param [options.ignoreTypes] - Whether to ignore certain types pieces. * @returns The number of pieces in the gamefile. */ function getPieceCountOfGame( o: OrganizedPieces, { ignoreColors, ignoreRawTypes, }: { ignoreColors?: Set; ignoreRawTypes?: Set } = {}, ): number { // Early exit optimization: If ignoreColors and ignoreRawTypes are not specified, // return the size of o.coords, since that has zero undefineds. if (!ignoreColors && !ignoreRawTypes) return o.coords.size; let count = 0; // Running count list for (const [type, range] of o.typeRanges) { if (ignoreColors && ignoreColors.has(typeutil.getColorFromType(type))) continue; if (ignoreRawTypes && ignoreRawTypes.has(typeutil.getRawType(type))) continue; count += getPieceCountOfTypeRange(range); } return count; } /** * Counts the total number of royal pieces (jumping + sliding) in the game. * @param o - The organized pieces data * @returns The total number of royal pieces on the board */ function getRoyalCountOfGame(o: OrganizedPieces): number { let royalCount = 0; for (const [type, range] of o.typeRanges) { if (!typeutil.royals.includes(typeutil.getRawType(type))) continue; // Not a royal royalCount += getPieceCountOfTypeRange(range); } return royalCount; } /** * Returns the number of pieces of a SPECIFIC color in a game, * EXCLUDING undefined placeholders */ function getPieceCountOfColor(o: OrganizedPieces, color: Player): number { let pieceCount = 0; for (const [type, range] of o.typeRanges) { const thisTypesColor = typeutil.getColorFromType(type); if (thisTypesColor !== color) continue; // Different color // Same color! Increment the counter pieceCount += getPieceCountOfTypeRange(range); } return pieceCount; } /** * Returns the number of pieces in a given type list (e.g. "pawnsW"), * EXCLUDING undefined placeholders * @param o the piece data for the game * @param type */ function getPieceCountOfType(o: OrganizedPieces, type: number): number { const typeList = o.typeRanges.get(type); if (typeList === undefined) return 0; return getPieceCountOfTypeRange(typeList); } /** Excludes undefined placeholders */ function getPieceCountOfTypeRange(range: TypeRange): number { return range.end - range.start - range.undefineds.length; } /** * Calculates and returns the total number of pieces in the `OrganizedPieces` lists, INCLUDING undefined placeholders. */ function getPieceCount_IncludingUndefineds(o: OrganizedPieces): number { return o.types.length; } // Getting All Pieces ------------------------------------------------------------------------------------------------- /** * Retrieves the coordinates of all pieces. * @param o - contains the pieces data. * @returns A list of coordinates of all pieces. */ function getCoordsOfAllPieces(o: OrganizedPieces): Coords[] { const allCoords: Coords[] = []; for (const range of o.typeRanges.values()) { getCoordsOfTypeRange(o, allCoords, range); } return allCoords; } /** * Returns an array containing the coordinates of ALL royal pieces of the specified color. * @param o - the piece lists * @param color - The color of the royals to look for. * @returns A list of coordinates where all the royals of the provided color are at. */ function getRoyalCoordsOfColor(o: OrganizedPieces, color: Player): Coords[] { const royalCoordsList: Coords[] = []; typeutil.forEachPieceType( (t) => { const range = o.typeRanges.get(t); if (range === undefined) return; getCoordsOfTypeRange(o, royalCoordsList, range); }, [color], typeutil.royals, ); return royalCoordsList; } /** * O(sqrt(n)) algorithm to get the bounding box of all pieces. * Falls back to O(n) if no vertical or horizontal slides are in the game. */ function getBoundingBoxOfAllPieces(o: OrganizedPieces): BoundingBox | undefined { if (o.coords.size === 0) return undefined; // No pieces const allSlides = Array.from(o.lines.keys()); // Find a single vertical slide direction const vertSlideKey = allSlides.find((slideKey) => { const vec = vectors.getVec2FromKey(slideKey); return vec[0] === 0n; }); // Find a single horizontal slide direction const horzSlideKey = allSlides.find((slideKey) => { const vec = vectors.getVec2FromKey(slideKey); return vec[1] === 0n; }); if (vertSlideKey === undefined || horzSlideKey === undefined) { // This can happen in practice checkmate 1K3NR-1k. // Only console warn if there is a large number of pieces if (o.coords.size > 1_000_000) console.warn( 'Falling back to slower O(n) bounding box calculation for all pieces. Either no vertical or horizontal slide found.', ); // Fallback to O(n) algorithm, we don't have the advantage of organized lines to optimize this. const allCoords = getCoordsOfAllPieces(o); return bounds.getBoxFromCoordsList(allCoords); } // Find the left-most and right-most vertical lines let left: bigint | undefined = undefined; let right: bigint | undefined = undefined; const vertSlide = vectors.getVec2FromKey(vertSlideKey); for (const lineKey of o.lines.get(vertSlideKey)!.keys()) { const C = organizedpieces.getCFromKey(lineKey); const x = C / -vertSlide[1]; // Reverse engineered vectors.getLineCFromCoordsAndVec() to obtain x if (left === undefined || x < left) left = x; if (right === undefined || x > right) right = x; } // Find the bottom-most and top-most horizontal lines let bottom: bigint | undefined = undefined; let top: bigint | undefined = undefined; const horzSlide = vectors.getVec2FromKey(horzSlideKey); for (const lineKey of o.lines.get(horzSlideKey)!.keys()) { const C = organizedpieces.getCFromKey(lineKey); const y = C / horzSlide[0]; // Reverse engineered vectors.getLineCFromCoordsAndVec() to obtain y if (bottom === undefined || y < bottom) bottom = y; if (top === undefined || y > top) top = y; } if (left === undefined || right === undefined || bottom === undefined || top === undefined) throw new Error( 'Failed to calculate bounding box of all pieces. Lines of slide direction was empty (failure of organizedpieces)', ); return { left, right, bottom, top }; } /** * Returns a list of all the jumping royal pieces of a specific color. * @param o the piece lists * @param color - The color of the jumping royals to look for. * @returns A list of coordinates where all the jumping royals of the provided color are at. */ function getJumpingRoyalCoordsOfColor(o: OrganizedPieces, color: Player): Coords[] { const royalCoordsList: Coords[] = []; // A running list of all the jumping royals of this color typeutil.forEachPieceType( (t) => { const range = o.typeRanges.get(t); if (range === undefined) return; getCoordsOfTypeRange(o, royalCoordsList, range); }, [color], typeutil.jumpingRoyals, ); return royalCoordsList; } /** * Efficiently iterates through every piece in a type range, * skipping over undefineds placeholders, executing callback * on each piece idx. */ function iteratePiecesInTypeRange( o: OrganizedPieces, type: number, callback: (_idx: number) => void, ): void { const range = o.typeRanges.get(type)!; let undefinedidx = 0; for (let idx = range.start; idx < range.end; idx++) { if (idx === range.undefineds[undefinedidx]) { // Is our next undefined piece entry, skip. undefinedidx++; continue; } callback(idx); } } /** * Efficiently iterates through every piece in a type range, * calculating if each idx is an undefined placeholder. */ function iteratePiecesInTypeRange_IncludeUndefineds( o: OrganizedPieces, type: number, callback: (_idx: number, _isUndefined: boolean) => void, ): void { const range = o.typeRanges.get(type)!; let undefinedidx = 0; for (let idx = range.start; idx < range.end; idx++) { const isUndefined = idx === range.undefineds[undefinedidx]; if (isUndefined) undefinedidx++; callback(idx, isUndefined); } } function getCoordsOfTypeRange(o: OrganizedPieces, coords: Coords[], range: TypeRange): void { let undefinedidx = 0; for (let idx = range.start; idx < range.end; idx++) { if (idx === range.undefineds[undefinedidx]) { // Is our next undefined piece entry, skip. undefinedidx++; continue; } coords.push([o.XPositions[idx]!, o.YPositions[idx]!]); } } // Getting A Single Piece ------------------------------------------------------------------------------------------------- function getCoordsFromIdx(o: OrganizedPieces, idx: number): Coords { return [o.XPositions[idx]!, o.YPositions[idx]!]; } function isIdxUndefinedPiece(o: OrganizedPieces, idx: number): boolean { return jsutil.binarySearch(o.typeRanges.get(o.types[idx]!)!.undefineds, idx).found; } function getTypeFromCoords(o: OrganizedPieces, coords: Coords): number | undefined { const key = coordutil.getKeyFromCoords(coords); if (!o.coords.has(key)) return undefined; const idx = o.coords.get(key)!; return o.types[idx]!; } function getIdxFromCoords(o: OrganizedPieces, coords: Coords): number | undefined { const key = coordutil.getKeyFromCoords(coords); if (!o.coords.has(key)) return undefined; const idx = o.coords.get(key)!; return idx; } function getPieceFromCoords(o: OrganizedPieces, coords: Coords): Piece | undefined { const key = coordutil.getKeyFromCoords(coords); if (!o.coords.has(key)) return undefined; const idx = o.coords.get(key)!; const type = o.types[idx]!; return { type, coords, index: getRelativeIdx(o, idx), }; } function getPieceFromCoordsKey(o: OrganizedPieces, coordsKey: CoordsKey): Piece | undefined { if (!o.coords.has(coordsKey)) return undefined; const idx = o.coords.get(coordsKey)!; const type = o.types[idx]!; return { type, coords: coordutil.getCoordsFromKey(coordsKey), index: getRelativeIdx(o, idx), }; } /** Returns the relative index of a piece in its type range. */ function getRelativeIdx(o: OrganizedPieces, idx: number): number { return idx - o.typeRanges.get(o.types[idx]!)!.start; } /** Reverts the relative-ness of the piece's index to the start of its type range to get its absolute index. */ function getAbsoluteIdx(o: OrganizedPieces, piece: Piece): number { return piece.index + o.typeRanges.get(piece.type)!.start; } /** * Returns the Piece object of the piece with given idx, or undefined if the * idx is an undefined placeholder (has to perform a search to find that out). * IF YOU KNOW it's not an undefined placeholder, use {@link getDefinedPieceFromIdx} instead for better performance. */ function getPieceFromIdx(o: OrganizedPieces, idx: number): Piece | undefined { if (isIdxUndefinedPiece(o, idx)) return undefined; return getDefinedPieceFromIdx(o, idx); } /** * Returns the Piece object of the piece with given idx. MORE PERFORMANT than {@link getPieceFromIdx}. * Only call if you know it's not an undefined placeholder. */ function getDefinedPieceFromIdx(o: OrganizedPieces, idx: number): Piece { const type = o.types[idx]!; return { type, coords: getCoordsFromIdx(o, idx), index: getRelativeIdx(o, idx), }; } function getTypeRangeFromIdx(o: OrganizedPieces, idx: number): TypeRange { const type = o.types[idx]; if (type === undefined) throw Error('Index is out of piece lists'); if (!o.typeRanges.has(type)) throw Error('Typerange is not initialized'); return o.typeRanges.get(type)!; } /** Whether a piece is on the provided coords */ function isPieceOnCoords(o: OrganizedPieces, coords: Coords): boolean { return o.coords.has(coordutil.getKeyFromCoords(coords)); } export type { Piece }; export default { getPieceCountOfGame, getRoyalCountOfGame, getPieceCountOfColor, getPieceCountOfType, getPieceCountOfTypeRange, getPieceCount_IncludingUndefineds, getCoordsOfAllPieces, getJumpingRoyalCoordsOfColor, getRoyalCoordsOfColor, getBoundingBoxOfAllPieces, iteratePiecesInTypeRange, iteratePiecesInTypeRange_IncludeUndefineds, isIdxUndefinedPiece, isPieceOnCoords, getTypeFromCoords, getPieceFromCoords, getPieceFromCoordsKey, getRelativeIdx, getAbsoluteIdx, getPieceFromIdx, getDefinedPieceFromIdx, getCoordsFromIdx, getTypeRangeFromIdx, getIdxFromCoords, }; ================================================ FILE: src/shared/chess/util/clockutil.ts ================================================ // src/shared/chess/util/clockutil.ts /** * The clock value for the game, `s+s`, where the left side is * start time in seconds, and the right is increment in seconds. * Untimed = `-` */ import type { TimeControl } from '../../types.js'; function getTextContentFromTimeRemain(time: number): string { let seconds = Math.ceil(time / 1000); let minutes = 0; while (seconds >= 60) { seconds -= 60; minutes++; } if (seconds < 0) seconds = 0; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } /** * Returns true if the clock value is infinite. Internally, untimed games are represented with a "-". * @param clock - The clock value (e.g. "10+5"). * @returns *true* if it's infinite. */ function isClockValueInfinite(clock: TimeControl): boolean { return clock === '-'; } /** * Returns the clock in a slightly more human-readable format: `10m+5s` * @param key - The clock string: `600+5`, where the left is the start time in seconds, right is increment in seconds. * @returns */ function getClockFromKey(key: TimeControl): string { // ssss+ss converted to 15m+15s const minutesAndIncrement = getMinutesAndIncrementFromClock(key); if (minutesAndIncrement === null) return translations['no_clock']; return `${minutesAndIncrement.minutes}m+${minutesAndIncrement.increment}s`; } /** * Splits the clock from the form `10+5` into the `minutes` and `increment` properties. * If it is an untimed game (represented by `-`), then this will return null. * @param clock - The string representing the clock value: `10+5` * @returns An object with 2 properties: `minutes`, `increment`, or `null` if the clock is infinite. */ function getMinutesAndIncrementFromClock( clock: TimeControl, ): null | { minutes: number; increment: number } { if (isClockValueInfinite(clock)) return null; const [seconds, increment] = clock.split('+').map((part) => +part) as [number, number]; // Convert them into a number const minutes = seconds / 60; return { minutes, increment }; } /** * Splits the clock from the form `s+s` into the `base_time_seconds` and `increment_seconds` properties. * @param time_control * @returns */ function splitTimeControl(time_control: TimeControl): { base_time_seconds: number | null; increment_seconds: number | null; } { // Check for the untimed indicator first if (time_control === '-') return { base_time_seconds: null, increment_seconds: null }; // Split the time control string into base time and increment const [base_time_seconds, increment_seconds] = time_control.split('+').map((part) => +part) as [ number, number, ]; // Convert them into a number // Throw error if either of them are Nan, or negative if ( isNaN(base_time_seconds) || isNaN(increment_seconds) || base_time_seconds <= 0 || increment_seconds < 0 ) throw new Error(`Invalid time control: ${time_control}`); return { base_time_seconds, increment_seconds }; } export default { getTextContentFromTimeRemain, isClockValueInfinite, getClockFromKey, getMinutesAndIncrementFromClock, splitTimeControl, }; ================================================ FILE: src/shared/chess/util/coordutil.ts ================================================ // src/shared/chess/util/coordutil.ts /** * This script contains utility methods for working with coordinates [x,y]. * * ZERO dependancies. */ import bd, { BigDecimal } from '@naviary/bigdecimal'; // Types ----------------------------------------------------------------------- /** * A length-2 array of coordinates: `[x,y]` * Contains infinite precision integers, represented as BigInt. */ type Coords = [bigint, bigint]; /** * A pair of arbitrarily large coordinates WITH decimal precision included. * Typically used for calculating graphics on the cpu-side. * BD = BigDecimal */ type BDCoords = [BigDecimal, BigDecimal]; /** For when we don't need arbitrary size. */ type DoubleCoords = [number, number]; /** * A pair of coordinates, represented in a string, separated by a `,`. * * This is often used as the key for a piece in piece lists. * * This will never be in scientific notation. However, moves beyond * Number.MAX_SAFE_INTEGER can't be expressed exactly. */ type CoordsKey = `${bigint},${bigint}`; // Functions ------------------------------------------------------------------- /** Returns the key string of the coordinates: [x,y] => 'x,y' */ function getKeyFromCoords(coords: Coords): CoordsKey { return `${coords[0]},${coords[1]}`; } /** * Returns a length-2 array of the provided coordinates * @param key - 'x,y' * @returns The coordinates of the piece, [x,y] */ function getCoordsFromKey(key: CoordsKey): Coords { return key.split(',').map(BigInt) as Coords; } /** Returns true if the coordinates are equal. */ function areCoordsEqual(coord1: Coords, coord2: Coords): boolean { return coord1[0] === coord2[0] && coord1[1] === coord2[1]; } /** Returns true if the BigDecimal coordinates are equal. */ function areBDCoordsEqual(coord1: BDCoords, coord2: BDCoords): boolean { return bd.areEqual(coord1[0], coord2[0]) && bd.areEqual(coord1[1], coord2[1]); } /** * Adds two coordinate pairs together component-wise. */ function addCoords(coord1: Coords, coord2: Coords): Coords { return [coord1[0] + coord2[0], coord1[1] + coord2[1]]; } /** Adds two BigDecimal coordinates together. */ function addBDCoords(coord1: BDCoords, coord2: BDCoords): BDCoords { return [bd.add(coord1[0], coord2[0]), bd.add(coord1[1], coord2[1])]; } /** * Subtracts two coordinate pairs together component-wise. * @param minuendCoord - The first coordinate pair [x1, y1] to start with. * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend. * @returns The resulting coordinate pair after subtracting. */ function subtractCoords(minuendCoord: Coords, subtrahendCoord: Coords): Coords { return [minuendCoord[0] - subtrahendCoord[0], minuendCoord[1] - subtrahendCoord[1]]; } /** * Subtracts two coordinate pairs together component-wise. * @param minuendCoord - The first coordinate pair [x1, y1] to start with. * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend. * @returns The resulting coordinate pair after subtracting. */ function subtractBDCoords(minuendCoord: BDCoords, subtrahendCoord: BDCoords): BDCoords { return [ bd.subtract(minuendCoord[0], subtrahendCoord[0]), bd.subtract(minuendCoord[1], subtrahendCoord[1]), ]; } /** * Subtracts two coordinate pairs together component-wise. * @param minuendCoord - The first coordinate pair [x1, y1] to start with. * @param subtrahendCoord - The second coordinate pair [x2, y2] to subtract from the minuend. * @returns The resulting coordinate pair after subtracting. */ function subtractDoubleCoords( minuendCoord: DoubleCoords, subtrahendCoord: DoubleCoords, ): DoubleCoords { return [minuendCoord[0] - subtrahendCoord[0], minuendCoord[1] - subtrahendCoord[1]]; } /** * Makes a deep copy of the provided coordinates */ function copyCoords(coords: Coords): Coords { return [...coords] as Coords; } /** * Makes a deep copy of the provided BigDecimal coordinates */ function copyBDCoords(coords: BDCoords): BDCoords { return [bd.clone(coords[0]), bd.clone(coords[1])]; } /** * [FLOATING] Interpolates between two coordinates. * Fixed mantissa bit number. * Doesn't work well for very large distances * if you also need high decimal precision. * @param start - The starting coordinate. * @param end - The ending coordinate. * @param t - The interpolation value (between 0 and 1). */ function lerpCoords(start: BDCoords, end: BDCoords, t: number): BDCoords { const bddiff: BDCoords = subtractBDCoords(end, start); const bdt: BigDecimal = bd.fromNumber(t); // console.log('bdt:', bd.toApproximateString(bdt), 't:', t); const travelX = bd.multiplyFloating(bddiff[0], bdt); const travelY = bd.multiplyFloating(bddiff[1], bdt); return [bd.add(start[0], travelX), bd.add(start[1], travelY)]; } /** * {@link lerpCoords} but for DoubleCoords. */ function lerpCoordsDouble(start: DoubleCoords, end: DoubleCoords, t: number): DoubleCoords { const diffX = end[0] - start[0]; const diffY = end[1] - start[1]; const travelX = diffX * t; const travelY = diffY * t; return [start[0] + travelX, start[1] + travelY]; } // Debugging -------------------------------------------------------------------- /** [DEBUG] Stringifies a pair of bigint coordinates into a human-readable string. */ function stringifyCoords(coords: Coords): string { return `(${coords[0]}, ${coords[1]})`; } /** [DEBUG] Stringifies a pair of BigDecimal coordinates into their exact representation. SLOW. */ function stringifyBDCoords(coords: BDCoords): string { // return `(${bd.toNumber(coords[0])}, ${bd.toNumber(coords[1])})`; return `(${bd.toExactString(coords[0])}, ${bd.toExactString(coords[1])})`; // return `(${bd.toApproximateString(coords[0])}, ${bd.toApproximateString(coords[1])})`; } // Exports -------------------------------------------------------------------- export default { getKeyFromCoords, getCoordsFromKey, areCoordsEqual, areBDCoordsEqual, addCoords, addBDCoords, subtractCoords, subtractBDCoords, subtractDoubleCoords, copyCoords, copyBDCoords, lerpCoords, lerpCoordsDouble, // Debugging stringifyCoords, stringifyBDCoords, }; export type { Coords, BDCoords, DoubleCoords, CoordsKey }; ================================================ FILE: src/shared/chess/util/gamefileutility.ts ================================================ // src/shared/chess/util/gamefileutility.ts /** * This script contains many utility methods for working with gamefiles. */ import type { Coords } from './coordutil.js'; import type { Player } from './typeutil.js'; import type { Game, Board, FullGame } from '../logic/gamefile.js'; import type { GameruleWinCondition, GameConclusion } from './winconutil.js'; import typeutil from './typeutil.js'; import moveutil from './moveutil.js'; import gamerules from './gamerules.js'; import winconutil from './winconutil.js'; import metadatautil from './metadatautil.js'; import wincondition from '../logic/wincondition.js'; // THIS IS ONLY USED FOR GAME-OVER CHECKMATE TESTS and inflates this files dependancy list!!! // Methods ------------------------------------------------------------- /** Returns true if the game is over. */ function isGameOver(basegame: Game): boolean { return basegame.gameConclusion !== undefined; } /** * Returns true if the currently-viewed position of the game file is in check */ function isCurrentViewedPositionInCheck(boardsim: Board): boolean { return boardsim.state.local.inCheck !== false; } /** * Returns a list of coordinates of all royals * in check in the currently-viewed position. */ function getCheckCoordsOfCurrentViewedPosition(boardsim: Board): Coords[] { return boardsim.state.local.inCheck || []; // Return an empty array if we're not in check. } /** * Sets the conclusion of the game, and sets/clears * the `Termination` `Result` and metadata accordingly. * If the conclusion is undefined, it removes the metadata, * essentially un-concluding the game if it was already concluded. */ function setConclusion(basegame: Game, conclusion: GameConclusion | undefined): void { basegame.gameConclusion = conclusion; if (conclusion !== undefined) { basegame.metadata.Termination = winconutil.getTerminationInEnglish( basegame.gameRules, conclusion.condition, ); basegame.metadata.Result = metadatautil.getResultFromVictor(conclusion.victor); } else { delete basegame.metadata.Result; delete basegame.metadata.Termination; } } /** * Tests if the color's opponent can win from the specified win condition. * @param basegame * @param friendlyColor - The color of friendlies. * @param winCondition - The win condition to check against. * @returns True if the opponent can win from the specified win condition, otherwise false. */ function isOpponentUsingWinCondition( basegame: Game, friendlyColor: Player, winCondition: GameruleWinCondition, ): boolean { const oppositeColor = typeutil.invertPlayer(friendlyColor)!; return gamerules.doesColorHaveWinCondition(basegame.gameRules, oppositeColor, winCondition); } // FUNCTIONS THAT SHOULD BE MOVED ELSEWHERE!!!!! They introduce too many dependancies ----------------------------------!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! /** * Tests if the game is over by the used win condition, andif so, * sets the `gameConclusion` property according to how the game was terminated, * and adds the respective mate flag on the last move played. */ function doGameOverChecks(gamefile: FullGame): void { const conclusion = wincondition.getGameConclusion(gamefile); setConclusion(gamefile.basegame, conclusion); if (conclusion !== undefined && winconutil.isConclusionMoveTriggered(conclusion.condition)) moveutil.flagLastMoveAsMate(gamefile.boardsim); } /** Returns the number of players in the game (unique players in the turnOrder). */ function getPlayerCount(basegame: Game): number { return new Set(basegame.gameRules.turnOrder).size; } /** Calculates the unique players in the turn order, in the order they appear. */ function getUniquePlayersInTurnOrder(turnOrder: Player[]): Player[] { // Using a Set removes duplicates before converting to an array return [...new Set(turnOrder)]; } // ---------------------------------------------------------------------------------------------------------------------!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! export default { isGameOver, isCurrentViewedPositionInCheck, getCheckCoordsOfCurrentViewedPosition, setConclusion, isOpponentUsingWinCondition, doGameOverChecks, getPlayerCount, getUniquePlayersInTurnOrder, }; ================================================ FILE: src/shared/chess/util/gamerules.ts ================================================ // src/shared/chess/util/gamerules.ts /** * This script contains the GameRules interface definition, * and contains utility methods for working with them. */ import type { UnboundedRectangle } from '../../util/math/bounds.js'; import type { GameruleWinCondition } from './winconutil.js'; import type { Player, RawType, PlayerGroup } from './typeutil.js'; interface GameRules { /** An object containing lists of what win conditions each color can win by. */ winConditions: PlayerGroup; /** A list of players that make up one full turn cycle. */ turnOrder: Player[]; /** * Contains a list of all promotion ranks each color promotes at, if they can promote. * If neither side can promote, this should be left as undefined. */ promotionRanks?: PlayerGroup; /** * An object containing arrays of raw types white and * black can promote to, if it's legal for them to promote. * If one color can't promote, their list should be left undefined. */ promotionsAllowed?: PlayerGroup; /** * How many plies (half-moves) can pass with no * captures or pawn pushes until a draw is declared. * Also known as the "50-move rule". */ moveRule?: number; /** The maximum number of steps any sliding piece can take. */ slideLimit?: bigint; /** * IF a world border is present, this is a bounding box * containing all integer coordinates that are inside the * playing area, not on or outside the world border. * All pieces must be within this box. * The inclusive playable region of the board. */ worldBorder?: UnboundedRectangle; } /** Checks if a specified color has a given win condition. */ function doesColorHaveWinCondition( gameRules: GameRules, color: Player, winCondition: GameruleWinCondition, ): boolean { return !!gameRules.winConditions[color]?.includes(winCondition); // The `!!` converts the result (true/false/undefined) strictly to boolean (true/false). } /** Gets the count of win conditions for a specified color in the game rules. */ function getWinConditionCountOfColor(gameRules: GameRules, player: Player): number { return gameRules.winConditions[player]?.length ?? 0; } /** * Swaps the "checkmate" win condition for "royalcapture" in the gameRules if applicable. * Modifies the gameRules object in place. */ function swapCheckmateForRoyalCapture(gameRules: GameRules): void { let changeMade = false; for (const winConditions of Object.values(gameRules.winConditions)) { // Remove "checkmate" if it exists const indexOf = winConditions.indexOf('checkmate'); if (indexOf !== -1) { winConditions.splice(indexOf, 1); // Remove "checkmate'" winConditions.push('royalcapture'); // Add "royalcapture" changeMade = true; } } if (changeMade) console.log('Swapped checkmate win conditions for royalcapture.'); } export default { doesColorHaveWinCondition, getWinConditionCountOfColor, swapCheckmateForRoyalCapture, }; export type { GameRules }; ================================================ FILE: src/shared/chess/util/metadatautil.ts ================================================ // src/shared/chess/util/metadatautil.ts /** * This script stores the type definition for a game's metadata. * * ICN (Infinite Chess Notation) is inspired from PGN notation. * https://github.com/tsevasa/infinite-chess-notation */ import type { Player } from './typeutil.js'; import type { MetaData, Rating } from '../../types.js'; import { players as p } from './typeutil.js'; // Types -------------------------------------------------------------------------- /** All valid metadata names. */ export type MetadataKey = keyof MetaData; // Constants ----------------------------------------------------------------------- /** Canonical display name used for guest players in ICN metadata. Metadata is always in English. */ const GUEST_NAME_ICN_METADATA = '(Guest)' as const; // Functions ----------------------------------------------------------------------- /** * Returns the value of the game's Result metadata, depending on the victor. * @param victor - The victor of the game, in player number. Or none if undefined. * @returns The result of the game in the format '1-0', '0-1', '1/2-1/2', or '*' (aborted). */ function getResultFromVictor(victor?: Player | null): string { if (victor === p.WHITE) return '1-0'; else if (victor === p.BLACK) return '0-1'; else if (victor === null) return '1/2-1/2'; else if (victor === undefined) return '*'; throw new Error(`Cannot get game result from unsupported victor ${victor}!`); } /** Rounds the elo. And, if we're not confident about its value, appends a question mark "?" to it. */ function getFormattedElo(rating: Rating): string { const roundedElo = Math.round(rating.value); return rating.confident ? `${roundedElo}` : `${roundedElo}?`; } /** * Takes elo change, calculates the string that should go into * the WhiteRatingDiff or BlackRatingDiff fields of the metadata. */ function getWhiteBlackRatingDiff(eloChange: number): string { const isPositive = eloChange >= 0; eloChange = Math.round(eloChange); return isPositive ? `+${eloChange}` : `${eloChange}`; // negative numbers are already negative } export default { GUEST_NAME_ICN_METADATA, getResultFromVictor, getFormattedElo, getWhiteBlackRatingDiff, }; ================================================ FILE: src/shared/chess/util/moveutil.ts ================================================ // src/shared/chess/util/moveutil.ts /** * This script contains utility methods for working with the gamefile's moves list. */ import type { Coords } from './coordutil.js'; import type { Player } from './typeutil.js'; import type { MoveFull } from '../logic/movepiece.js'; import type { MoveCoords } from '../logic/icn/icnconverter.js'; import type { Game, Board } from '../logic/gamefile.js'; import type { CoordsTagged } from '../logic/movepiece.js'; import coordutil from './coordutil.js'; // Functions ------------------------------------------------------------------------------ /** * Returns *true* if it is legal to forward the provided gamefile by 1 move, *false* if we're at the front of the game. */ function isIncrementingLegal(boardsim: Board): boolean { const incrementedIndex = boardsim.state.local.moveIndex + 1; return !isIndexOutOfRange(boardsim.moves, incrementedIndex); } /** * Returns *true* if it is legal to rewind the provided gamefile by 1 move, *false* if we're at the beginning of the game. */ function isDecrementingLegal(boardsim: Board): boolean { const decrementedIndex = boardsim.state.local.moveIndex - 1; return !isIndexOutOfRange(boardsim.moves, decrementedIndex); } /** * Tests if the provided index is out of range of the moves list length */ function isIndexOutOfRange(moves: MoveCoords[], index: number): boolean { return index < -1 || index >= moves.length; } /** * Returns the very last move played in the moves list, if there is one. Otherwise, returns undefined. */ function getLastMove(moves: MoveFull[]): MoveFull | undefined { const finalIndex = moves.length - 1; if (finalIndex < 0) return; return moves[finalIndex]; } /** * Returns the move we're currently viewing in the provided gamefile. */ function getCurrentMove(boardsim: Board): MoveFull | undefined { const index = boardsim.state.local.moveIndex; if (index < 0) return; return boardsim.moves[index]; } /** * Gets the move from the moves list at the specified index */ function getMoveFromIndex(moves: MoveFull[], index: number): MoveFull { if (isIndexOutOfRange(moves, index)) throw Error('Cannot get next move when index overflow'); return moves[index]!; } /** * Tests if the provided gamefile is viewing the front of the game, or the latest move. */ function areWeViewingLatestMove(boardsim: Board): boolean { const moveIndex = boardsim.state.local.moveIndex; const finalIndex = boardsim.moves.length - 1; return moveIndex === finalIndex; } /** * Returns total ply count (or half-moves) of the game so far. */ function getPlyCount(moves: MoveFull[]): number { return moves.length; } /** * Flags the gamefile's very last move as a "mate". */ function flagLastMoveAsMate(boardsim: Board): void { const lastMove = getLastMove(boardsim.moves); if (lastMove === undefined) return; // No moves, can't flag last move as mate (this can happen when pasting a game that's over) lastMove.flags.mate = true; } /** * Returns whether the game is resignable (at least 2 moves have been played). * If not, then the game is considered abortable. */ function isGameResignable(game: Game | Board): boolean { return game.moves.length > 1; } /** * Returns the color of the player that played the provided index within the moves list. */ function getColorThatPlayedMoveIndex(basegame: Game, index: number): Player { const turnOrder = basegame.gameRules.turnOrder; // If the starting position of the game is in check, then the player very last in the turnOrder is considered the one who *gave* the check. if (index === -1) return turnOrder[turnOrder.length - 1]!; return turnOrder[index % turnOrder.length]!; } /** * Returns the color whos turn it is after the specified move index was played. */ function getWhosTurnAtMoveIndex(basegame: Game, moveIndex: number): Player { return getColorThatPlayedMoveIndex(basegame, moveIndex + 1); } /** * Returns true if any player in the turn order ever gets to turn in a row. */ function doesAnyPlayerGet2TurnsInARow(basegame: Game): boolean { // If one player ever gets 2 turns in a row, then that also allows the capture of the king. const turnOrder = basegame.gameRules.turnOrder; for (let i = 0; i < turnOrder.length; i++) { const thisColor = turnOrder[i]; const nextColorIndex = i === turnOrder.length - 1 ? 0 : i + 1; // If the color is last, then the next color is the first color of the turn order. const nextColor = turnOrder[nextColorIndex]; if (thisColor === nextColor) return true; } return false; } /** * Strips the coordinates of any special move properties. NON-MUTATING, returns new coords. */ function stripSpecialMoveTagsFromCoords(coords: CoordsTagged): Coords { return coordutil.copyCoords(coords); // Does not copy non-enumerable properties } // ------------------------------------------------------------------------------ export default { isIncrementingLegal, isDecrementingLegal, getLastMove, getCurrentMove, getMoveFromIndex, areWeViewingLatestMove, getPlyCount, flagLastMoveAsMate, isGameResignable, getColorThatPlayedMoveIndex, getWhosTurnAtMoveIndex, doesAnyPlayerGet2TurnsInARow, stripSpecialMoveTagsFromCoords, }; ================================================ FILE: src/shared/chess/util/typeutil.ts ================================================ // src/shared/chess/util/typeutil.ts /** * This script contains lists of all piece types and players, * and utility methods for working with them. */ import * as z from 'zod'; // Constants -------------------------------------------------------------------------------- /** * Every raw type of piece supported in the game. * * This exact arrangement affects the order of which * the checkmate algorithm searches for legal moves, * and it affects the order the miniimages of the * pieces are rendered when zoomed out. */ const rawTypes = { VOID: 0, OBSTACLE: 1, KING: 2, GIRAFFE: 3, CAMEL: 4, ZEBRA: 5, KNIGHTRIDER: 6, AMAZON: 7, QUEEN: 8, ROYALQUEEN: 9, HAWK: 10, CHANCELLOR: 11, ARCHBISHOP: 12, CENTAUR: 13, ROYALCENTAUR: 14, ROSE: 15, KNIGHT: 16, GUARD: 17, HUYGEN: 18, ROOK: 19, BISHOP: 20, PAWN: 21, } as const; const neutralRawTypes: RawType[] = [rawTypes.VOID, rawTypes.OBSTACLE]; /** All player colors suppored in the game. Multiply the raw type by this to get the colored type. */ const players = { NEUTRAL: 0, WHITE: 1, BLACK: 2, // Colored players RED: 3, BLUE: 4, YELLOW: 5, GREEN: 6, } as const; const numTypes = Object.keys(rawTypes).length; /** Color extensions of all players. Add this to a raw type to get the colored type. */ const ext = { N: players.NEUTRAL * numTypes, W: players.WHITE * numTypes, B: players.BLACK * numTypes, // Colored players R: players.RED * numTypes, BU: players.BLUE * numTypes, Y: players.YELLOW * numTypes, G: players.GREEN * numTypes, } as const; /** * The string representations of each raw type. * * MUST BE IN THE EXACT SAME ORDER AS {@link rawTypes}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */ const strtypes = [ 'void', 'obstacle', 'king', 'giraffe', 'camel', 'zebra', 'knightrider', 'amazon', 'queen', 'royalQueen', 'hawk', 'chancellor', 'archbishop', 'centaur', 'royalCentaur', 'rose', 'knight', 'guard', 'huygen', 'rook', 'bishop', 'pawn', ] as const; /** A list of the royals that are compatible with checkmate. If a royal can slide, DO NOT put it in here, put it in {@link slidingRoyals} instead! */ const jumpingRoyals: RawType[] = [rawTypes.KING, rawTypes.ROYALCENTAUR]; /** * A list of the royals that the checkmate algorithm cannot detect when they are in checkmate, * however it still is illegal to move into check. * * Players have to voluntarily resign if they * believe their sliding royal is in checkmate. */ const slidingRoyals: RawType[] = [rawTypes.ROYALQUEEN]; /** * A list of the royal pieces, without the color appended. * THIS SHOULD NOT CONTAIN DUPLICATES */ const royals: RawType[] = [...jumpingRoyals, ...slidingRoyals]; /** * The string representations of each player color. * * MUST BE IN THE EXACT SAME ORDER AS {@link players}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */ const strcolors = ['neutral', 'white', 'black', 'red', 'blue', 'yellow', 'green'] as const; /** Raw piece types that don't have an SVG */ const SVGLESS_TYPES: Set = new Set([rawTypes.VOID]); // Zod Schemas -------------------------------------------------------------------------------- /** Zod schema for a player color. */ const PlayerSchema = z.literal(Object.values(players)); /** Returns the Zod schema corresponding to {@link PlayerGroup}, accepting the schema of the values as an argument. */ function GenPlayerGroupSchema( valueSchema: T, ): z.ZodObject<{ [K in Player]: z.ZodOptional }> { const shape = Object.fromEntries( Object.values(players).map((p) => [p, valueSchema.optional()]), ); return z.strictObject(shape as { [K in Player]: z.ZodOptional }); } // Types -------------------------------------------------------------------------------------- type StrPlayer = (typeof strcolors)[number]; type RawType = (typeof rawTypes)[keyof typeof rawTypes]; type Player = (typeof players)[keyof typeof players]; /** A dictionary type with raw types for keys */ type RawTypeGroup = { [_t in RawType]?: T; }; /** A dictionary type with all types for keys */ type TypeGroup = { [t: number]: T }; /** A dictionary type with player colors for keys */ type PlayerGroup = { [_p in Player]?: T; }; // Functions -------------------------------------------------------------------------------- function getRawType(type: number): RawType { return (type % numTypes) as RawType; } function getColorFromType(type: number): Player { return Math.floor(type / numTypes) as Player; } function buildType(type: RawType, color: Player): number { return type + color * numTypes; } /** Splits a type into its raw type and player */ function splitType(type: number): [RawType, Player] { return [getRawType(type), getColorFromType(type)]; } /** Repeats each rawTypes for player color provided. */ function buildAllTypesForPlayers(players: Player[], rawTypes: RawType[]): number[] { const builtTypes: number[] = []; for (let i = players.length - 1; i >= 0; i--) { for (const r of rawTypes) { builtTypes.push(buildType(r, players[i]!)); } } return builtTypes; } function forEachPieceType( callback: (_pieceType: number) => void, players: Player[], includePieces: RawType[], ): void { for (let i = players.length - 1; i >= 0; i--) { for (const r of includePieces) { callback(buildType(r, players[i]!)); } } } /** Inverts the type so it belongs to the opposite color. */ function invertType(type: number): number { const [r, p] = splitType(type); const newp = invertPlayer(p); // This will throw an error if the type is not invertible because of its color. (We should never attempt to invert it anyway) return buildType(r, newp); } /** * Inverts the player id. * Neutral gets inverted to neutral. */ function invertPlayer(player: Player): Player { // prettier-ignore return player === players.NEUTRAL ? players.NEUTRAL : player === players.WHITE ? players.BLACK : player === players.BLACK ? players.WHITE : ((): never => { throw Error(`Cannot invert player ${player}!`); })(); // No downsides to adding this, only more protection. } function getRawTypeStr(type: RawType): string { return strtypes[type]; } function getPlayerFromString(string: StrPlayer): Player { return strcolors.indexOf(string) as Player; } /** * Deletes for pieces that aren't included in this game. */ function deleteUnusedFromRawTypeGroup( existingRawTypes: RawType[], exclude: RawTypeGroup, ): void { for (const key in exclude) { const rawType = Number(key) as RawType; if (!existingRawTypes.includes(rawType)) delete exclude[rawType]; } } /** * Returns the english string of a piece type. * 30 => "[30] queen(white)" */ function debugType(type: number): string { const [raw, c] = splitType(type); return `[${type}] ${getRawTypeStr(raw)}(${strcolors[c]})`; } export type { RawType, Player, RawTypeGroup, TypeGroup, PlayerGroup }; export { rawTypes, neutralRawTypes, ext, numTypes, players }; export default { // Constants jumpingRoyals, slidingRoyals, royals, SVGLESS_TYPES, strcolors, // Schemas PlayerSchema, GenPlayerGroupSchema, // Functions getRawType, getColorFromType, buildType, splitType, invertType, buildAllTypesForPlayers, forEachPieceType, getRawTypeStr, invertPlayer, getPlayerFromString, debugType, deleteUnusedFromRawTypeGroup, }; ================================================ FILE: src/shared/chess/util/validcheckmates.ts ================================================ // src/shared/chess/util/validcheckmates.ts /** * This script stores the list of valid checkmates for the practice mode, and is used for verification clientside and serverside * It should have no dependencies at all. */ const validCheckmates = { easy: [ '2Q-1k', '3R-1k', '1Q1R1B-1k', '1Q1R1N-1k', '1K2R-1k', '1Q1CH-1k', '2CH-1k', '3B3B-1k', '1K2B2B-1k', '3AR-1k', '1K1AM-1k', ], medium: [ '1K1Q1B-1k', '1K1Q1N-1k', '1Q1B1B-1k', '1K1N2B1B-1k', '1K2N1B1B-1k', '1K1R1B1B-1k', '1K1R1N1B-1k', '1K1AR1R-1k', '2R1N1P-1k', '2AM-1rc', ], hard: [ '1Q1N1B-1k', '1Q2N-1k', '1K1R2N-1k', '2K1R-1k', '1K2N6B-1k', '1K2AR-1k', '1K2HA1B-1k', '1K1CH1N-1k', '5HU-1k', ], insane: ['1K1Q1P-1k', '1K3HA-1k', '1K3NR-1k'], // superhuman (way too hard): // "1K1AR1HA1P-1k" (the white pawn only exists in order to mitigate zugzwang for white) // "2B60N-1k" (fewer knights suffice but exact amount unknown, see proof in https://chess.stackexchange.com/q/45998/35006 ) }; // Export ------------------------------------------------------------------------------ export default { validCheckmates, }; ================================================ FILE: src/shared/chess/util/winconutil.ts ================================================ // src/shared/chess/util/winconutil.ts /** * This script contains lists of compatible win conditions in the game. * And contains a few utility methods for them. * */ import type { GameRules } from './gamerules.js'; import * as z from 'zod'; import typeutil from './typeutil.js'; // Constants ----------------------------------------------------------------- /** * Win conditions that are valid gamerule options for either color. * These are triggered by a move being made. * This excludes action-based wins like time forfeit, resignation, and disconnect. */ const GAMERULE_WIN_CONDITIONS = [ 'checkmate', 'royalcapture', 'allroyalscaptured', 'allpiecescaptured', 'koth', // King of the Hill ] as const; /** * Conditions where one player wins (victor is a Player). * Covers both move-triggered wins and action-based wins. */ const WIN_CONDITIONS = [...GAMERULE_WIN_CONDITIONS, 'time', 'resignation', 'disconnect'] as const; /** Draw conditions that are triggered by a move being made. */ const MOVE_TRIGGERED_DRAW_CONDITIONS = [ 'stalemate', 'moverule', 'repetition', 'insuffmat', // Insufficient material ] as const; /** Conditions that result in a draw (victor is null). */ const DRAW_CONDITIONS = [...MOVE_TRIGGERED_DRAW_CONDITIONS, 'agreement'] as const; /** * List of all conclusions that are triggered by a move being made. * This excludes conclusions such as resignation, time, aborted, disconnect, and agreement, * which can happen at any point in time. */ const MOVE_TRIGGERED_CONCLUSIONS = [ ...GAMERULE_WIN_CONDITIONS, ...MOVE_TRIGGERED_DRAW_CONDITIONS, ] as const; // Types -------------------------------------------------------------------------- /** Condition where one player wins. victor will be a Player. */ export type WinCondition = (typeof WIN_CONDITIONS)[number]; /** Win condition that is a valid gamerule option for either color. */ export type GameruleWinCondition = (typeof GAMERULE_WIN_CONDITIONS)[number]; /** Condition that results in a draw. victor will be null. */ export type DrawCondition = (typeof DRAW_CONDITIONS)[number]; /** Condition that aborts the game. victor will be undefined. */ type AbortCondition = 'aborted'; type MoveTriggeredCondition = (typeof MOVE_TRIGGERED_CONCLUSIONS)[number]; /** * Union type of all possible game conclusion conditions. * Represents how a game can be terminated. */ export type Condition = WinCondition | DrawCondition | AbortCondition; // Schemas -------------------------------------------------------------------------- /** Stores the results of a game, including how it was terminated, and who won. */ export type GameConclusion = z.infer; const gameConclusionSchema = z.discriminatedUnion('condition', [ z.strictObject({ condition: z.enum(WIN_CONDITIONS), victor: typeutil.PlayerSchema, }), z.strictObject({ condition: z.enum(DRAW_CONDITIONS), victor: z.literal(null), }), z.strictObject({ condition: z.literal('aborted'), victor: z.undefined().optional(), // Allows accidental inclusion of undefined victor }), ]); // Constants -------------------------------------------------------------------------- /** * Maps each game conclusion condition to its English termination string. * Always English by convention, since ICN metadata should only ever be in English. */ const TERMINATION_IN_ENGLISH = { checkmate: 'Checkmate', stalemate: 'Stalemate', repetition: 'Threefold repetition', /** The move count is inserted before this string. e.g. "50-move rule" */ moverule: '-move rule', insuffmat: 'Insufficient material', royalcapture: 'Royal capture', allroyalscaptured: 'All royals captured', allpiecescaptured: 'All pieces captured', koth: 'King of the hill', resignation: 'Resignation', agreement: 'Agreement', time: 'Time forfeit', aborted: 'Aborted', disconnect: 'Abandoned', } as const; // Functions -------------------------------------------------------------------------- /** * Calculates if the provided condition is move-triggered. * This is any conclusion that can happen after a move is made. * Excludes conclusions like resignation, time, aborted, disconnect, * and agreement, which can happen at any point in time. * @param condition - The `condition` property of a `GameConclusion` object. * @returns *true* if the condition is move-triggered. */ function isConclusionMoveTriggered(condition: Condition): boolean { return MOVE_TRIGGERED_CONCLUSIONS.includes(condition as MoveTriggeredCondition); } /** * Returns the termination of the game in english language. * @param gameRules * @param condition - The 2nd half of the gameConclusion: checkmate/stalemate/repetition/moverule/insuffmat/allpiecescaptured/royalcapture/allroyalscaptured/resignation/time/aborted/disconnect */ function getTerminationInEnglish(gameRules: GameRules, condition: Condition): string { if (condition === 'moverule') { // One exception - the move rule termination includes the number of moves until the auto-draw is triggered. For example, "50-move rule". const numbWholeMovesUntilAutoDraw = gameRules.moveRule! / 2; return `${numbWholeMovesUntilAutoDraw}${TERMINATION_IN_ENGLISH.moverule}`; } return TERMINATION_IN_ENGLISH[condition]; } export default { gameConclusionSchema, GAMERULE_WIN_CONDITIONS, isConclusionMoveTriggered, getTerminationInEnglish, }; ================================================ FILE: src/shared/chess/variants/fourdimensionalgenerator.ts ================================================ // src/shared/chess/variants/fourdimensionalgenerator.ts /** * This script dynamically generates the positions of 4 dimensional variants * with varying number of boards, board sizes, and positions on each board. * * Also generates their moveset, and specialVicinity, overrides. */ import type { Coords, CoordsKey } from '../util/coordutil.js'; import type { Movesets, RawMovesets } from '../logic/movesets.js'; import bimath from '../../util/math/bimath.js'; import movesets from '../logic/movesets.js'; import coordutil from '../util/coordutil.js'; import icnconverter from '../logic/icn/icnconverter.js'; import fourdimensionalmoves from '../logic/fourdimensionalmoves.js'; import { rawTypes as r, ext as e } from '../util/typeutil.js'; /** An object that contains all relevant quantities for the size of a single 4D chess board. */ type Dimensions = { /** The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1 */ BOARD_SPACING: bigint; /** Number of 2D boards in x direction */ BOARDS_X: bigint; /** Number of 2D boards in y direction */ BOARDS_Y: bigint; /** Board edges on the real chessboard */ MIN_X: bigint; /** Board edges on the real chessboard */ MAX_X: bigint; /** Board edges on the real chessboard */ MIN_Y: bigint; /** Board edges on the real chessboard */ MAX_Y: bigint; }; // Variables ------------------------------------------------------------------------------------------------ /** Contains all relevant quantities for the size of the 4D chess board. */ let dim: Dimensions | undefined; /** * mov: Contains all relevant parameters for movement logic on the 4D board */ const mov = { /** true: allow quadragonal and triagonal king and queen movement. false: do not allow it. */ STRONG_KINGS_AND_QUEENS: false, /** * true: pawns can capture along any forward-sideways diagonal, like brawns in 5D chess. * false: pawns can only capture along strictly spacelike or timelike diagonals, like pawns in 5D chess. */ STRONG_PAWNS: true, }; // Utility --------------------------------------------------------------------------------------------------------- function set4DBoardDimensions(boards_x: bigint, boards_y: bigint, board_spacing: bigint): void { const MIN_X = 0n; const MIN_Y = 0n; dim = { BOARDS_X: boards_x, BOARDS_Y: boards_y, BOARD_SPACING: board_spacing, MIN_X, MAX_X: MIN_X + boards_x * board_spacing, MIN_Y, MAX_Y: MIN_Y + boards_y * board_spacing, }; } function get4DBoardDimensions(): Dimensions { return dim!; } function setMovementType(strong_kings_and_queens: boolean, strong_pawns: boolean): void { mov.STRONG_KINGS_AND_QUEENS = strong_kings_and_queens; mov.STRONG_PAWNS = strong_pawns; } /** * Returns the type of queen, king, and pawn movements in the last loaded 4 dimension variant. * Triagonal? Quadragonal? Brawn? */ function getMovementType(): { STRONG_KINGS_AND_QUEENS: boolean; STRONG_PAWNS: boolean } { return mov; } // Generation --------------------------------------------------------------------------------------------------------- /** * Generate 4D chess position * @param boards_x - Number of 2D boards in x direction * @param boards_y - Number of 2D boards in y direction * @param board_spacing - The spacing of the 2D boards - should be equal to (sidelength of a 2D board) + 1 * @param input_position - If this is a position string, populate all 2D boards with it. If it is a dictionary, populate the boards according to it * @returns */ function gen4DPosition( boards_x: bigint, boards_y: bigint, board_spacing: bigint, input_position: string | { [key: string]: string }, ): Map { set4DBoardDimensions(boards_x, boards_y, board_spacing); const resultPos = new Map(); // position is string and should identically populate all 2D boards if (typeof input_position === 'string') { const input_position_long: Map = icnconverter.generatePositionFromShortForm(input_position).position; // Loop through from the leftmost column that should be voids to the right most, and also vertically for (let i = dim!.MIN_X; i <= dim!.MAX_X; i++) { for (let j = dim!.MIN_Y; j <= dim!.MAX_Y; j++) { // Only the edges of boards should be voids if (i % dim!.BOARD_SPACING === 0n || j % dim!.BOARD_SPACING === 0n) { resultPos.set(coordutil.getKeyFromCoords([i, j]), r.VOID + e.N); // Add input_position_long to the board if ( i < dim!.MAX_X && i % dim!.BOARD_SPACING === 0n && j < dim!.MAX_Y && j % dim!.BOARD_SPACING === 0n ) { for (const [key, value] of input_position_long) { const coords = coordutil.getCoordsFromKey(key); const newKey = coordutil.getKeyFromCoords([ coords[0] + i, coords[1] + j, ]); resultPos.set(newKey, value); } } } } } } // position is object and should populate 2D boards according to its entries else if (typeof input_position === 'object') { // Loop through from the leftmost column that should be voids to the right most, and also vertically for (let i = dim!.MIN_X; i <= dim!.MAX_X; i++) { for (let j = dim!.MIN_Y; j <= dim!.MAX_Y; j++) { // Only the edges of boards should be voids if ( i % dim!.BOARD_SPACING === 0n || i % dim!.BOARD_SPACING === 9n || j % dim!.BOARD_SPACING === 0n || j % dim!.BOARD_SPACING === 9n ) { resultPos.set(coordutil.getKeyFromCoords([i, j]), r.VOID + e.N); // Add the subposition to the correct board if ( i < dim!.MAX_X && i % dim!.BOARD_SPACING === 0n && j < dim!.MAX_Y && j % dim!.BOARD_SPACING === 0n ) { const sub_position_short = input_position[`${i / dim!.BOARD_SPACING},${j / dim!.BOARD_SPACING}`]; const sub_position_long: Map = sub_position_short ? icnconverter.generatePositionFromShortForm(sub_position_short) .position : new Map(); for (const [key, value] of sub_position_long) { const coords = coordutil.getCoordsFromKey(key); const newKey = coordutil.getKeyFromCoords([ coords[0] + i, coords[1] + j, ]); resultPos.set(newKey, value); } } } } } } return resultPos; } // Moveset Overrides -------------------------------------------------------------------------------------------------- /** * Generates the moveset for the sliding pieces * @param boards_x - Number of 2D boards in x direction * @param boards_y - Number of 2D boards in y direction * @param board_spacing - The spacing of the 2D boards - should be equal to (sidelength of a 2D board) + 1 * @param strong_kings_and_queens - true: allow quadragonal and triagonal movement. false: do not allow it * @param strong_pawns - true: pawns can capture along any diagonal. false: pawns can only capture along strictly spacelike or timelike diagonals * @returns */ function gen4DMoveset( boards_x: bigint, boards_y: bigint, board_spacing: bigint, strong_kings_and_queens: boolean, strong_pawns: boolean, ): Movesets { set4DBoardDimensions(boards_x, boards_y, board_spacing); setMovementType(strong_kings_and_queens, strong_pawns); const rawMovesets: RawMovesets = { [r.QUEEN]: { individual: [], sliding: {}, // Not needed if a worldBorder of 0n is added. // ignore: (startCoords: Coords, endCoords: Coords) => { // return (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y); // } }, [r.BISHOP]: { individual: [], sliding: {}, // Not needed if a worldBorder of 0n is added. // ignore: (startCoords: Coords, endCoords: Coords) => { // return (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y); // } }, [r.ROOK]: { individual: [], sliding: {}, // Not needed if a worldBorder of 0n is added. // ignore: (startCoords: Coords, endCoords: Coords) => { // return (endCoords[0] > dim!.MIN_X && endCoords[0] < dim!.MAX_X && endCoords[1] > dim!.MIN_Y && endCoords[1] < dim!.MAX_Y); // } }, [r.KING]: { individual: [], special: fourdimensionalmoves.fourDimensionalKingMove, }, [r.KNIGHT]: { individual: [], special: fourdimensionalmoves.fourDimensionalKnightMove, }, [r.PAWN]: { individual: [], special: fourdimensionalmoves.fourDimensionalPawnMove, }, }; for (let baseH = 1n; baseH >= -1n; baseH--) { for (let baseV = 1n; baseV >= -1n; baseV--) { for (let offsetH = 1n; offsetH >= -1n; offsetH--) { for (let offsetV = 1n; offsetV >= -1n; offsetV--) { const x = dim!.BOARD_SPACING * baseH + offsetH; const y = dim!.BOARD_SPACING * baseV + offsetV; if (x < 0n) continue; // If the x coordinate is negative, skip this iteration if (x === 0n && y <= 0n) continue; // Skip if x is 0 and y is negative // Add the moves // allow any queen move if STRONG_KINGS_AND_QUEENS, else group her with bishops and rooks if (mov.STRONG_KINGS_AND_QUEENS) rawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [ null, null, ]; // Only add a bishop move if the move moves in two dimensions if ( baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV === 2n ) { rawMovesets[r.BISHOP]!.sliding![coordutil.getKeyFromCoords([x, y])] = [ null, null, ]; if (!mov.STRONG_KINGS_AND_QUEENS) rawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [ null, null, ]; } // Only add a rook move if the move moves in one dimension if ( baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV === 1n ) { rawMovesets[r.ROOK]!.sliding![coordutil.getKeyFromCoords([x, y])] = [ null, null, ]; if (!mov.STRONG_KINGS_AND_QUEENS) rawMovesets[r.QUEEN]!.sliding![coordutil.getKeyFromCoords([x, y])] = [ null, null, ]; } } } } } return movesets.convertRawMovesetsToPieceMovesets(rawMovesets); } // Special Vicinity Overrides ----------------------------------------------------------------------------------------- /** * Sets the specialVicinity object for the pawn * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1. * @param strong_pawns - true: pawns can capture along any forward-sideways diagonal. * false: pawns can only capture along strictly spacelike or timelike diagonals, like in 5D chess * @returns */ function getPawnVicinity(board_spacing: bigint, strong_pawns: boolean): Coords[] { const individualMoves: Coords[] = []; for (let baseH = 1n; baseH >= -1n; baseH--) { for (let baseV = 1n; baseV >= -1n; baseV--) { for (let offsetH = 1n; offsetH >= -1n; offsetH--) { for (let offsetV = 1n; offsetV >= -1n; offsetV--) { // only allow changing two things at once if ( baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV !== 2n ) continue; // do not allow two moves forward if (baseH * baseH + offsetH * offsetH === 2n) continue; // do not allow two moves sideways if (baseV * baseV + offsetV * offsetV === 2n) continue; // disallow strong captures if pawns are weak if ( !strong_pawns && (bimath.abs(baseH) !== bimath.abs(baseV) || bimath.abs(offsetH) !== bimath.abs(offsetV)) ) continue; const x = board_spacing * baseH + offsetH; const y = board_spacing * baseV + offsetV; const endCoords = [x, y] as Coords; individualMoves.push(endCoords); } } } } return individualMoves; } /** * Sets the specialVicinity object for the knight * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1. * @returns */ function getKnightVicinity(board_spacing: bigint): Coords[] { const individualMoves: Coords[] = []; for (let baseH = 2n; baseH >= -2n; baseH--) { for (let baseV = 2n; baseV >= -2n; baseV--) { for (let offsetH = 2n; offsetH >= -2n; offsetH--) { for (let offsetV = 2n; offsetV >= -2n; offsetV--) { // If the squared distance to the tile is 5, then add the move if ( baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV === 5n ) { const x = board_spacing * baseH + offsetH; const y = board_spacing * baseV + offsetV; const endCoords = [x, y] as Coords; individualMoves.push(endCoords); } } } } } return individualMoves; } /** * Sets the specialVicinity object for the king * @param board_spacing - The spacing of the timelike boards - should be equal to (sidelength of a 2D board) + 1. * @param strong_kings_and_queens - true: allow quadragonal and triagonal king and queen movement. false: do not allow it * @returns */ function getKingVicinity(board_spacing: bigint, strong_kings_and_queens: boolean): Coords[] { const individualMoves: Coords[] = []; for (let baseH = 1n; baseH >= -1n; baseH--) { for (let baseV = 1n; baseV >= -1n; baseV--) { for (let offsetH = 1n; offsetH >= -1n; offsetH--) { for (let offsetV = 1n; offsetV >= -1n; offsetV--) { // only allow moves that change one or two dimensions if triagonals and diagonals are disabled if ( !strong_kings_and_queens && baseH * baseH + baseV * baseV + offsetH * offsetH + offsetV * offsetV > 2n ) continue; const x = board_spacing * baseH + offsetH; const y = board_spacing * baseV + offsetV; if (x === 0n && y === 0n) continue; const endCoords = [x, y] as Coords; individualMoves.push(endCoords); } } } } return individualMoves; } // Exports ------------------------------------------------------------------------------------------------------------ export default { get4DBoardDimensions, getMovementType, gen4DPosition, gen4DMoveset, getPawnVicinity, getKnightVicinity, getKingVicinity, }; ================================================ FILE: src/shared/chess/variants/omega3generator.ts ================================================ // src/shared/chess/variants/omega3generator.ts /** * Here lies the position generator for the Omega^3 Showcase variant. */ import type { BoundingBox } from '../../util/math/bounds.js'; import type { Coords, CoordsKey } from '../util/coordutil.js'; import coordutil from '../util/coordutil.js'; import { ext as e, rawTypes as r } from '../util/typeutil.js'; /** * Generates the Omega^3 position example * @returns The position in keys format */ function genPositionOfOmegaCubed(): Map { const dist = 500n; // Generate Omega^3 up to a distance of 1000 tiles away const startingPos: Map = new Map(); startingPos.set(coordutil.getKeyFromCoords([3n, 15n]), r.KING + e.W); startingPos.set(coordutil.getKeyFromCoords([4n, 13n]), r.ROOK + e.B); // First few pawn walls appendPawnTower(startingPos, 7n, -dist, dist); appendPawnTower(startingPos, 8n, -dist, dist); // Third pawn wall appendPawnTower(startingPos, 9n, -dist, dist); startingPos.set(coordutil.getKeyFromCoords([9n, 10n]), r.BISHOP + e.W); // Overwrite with bishop setAir(startingPos, [9n, 11n]); // Black king wall appendPawnTower(startingPos, 10n, -dist, dist); startingPos.set(coordutil.getKeyFromCoords([10n, 12n]), r.KING + e.B); // Overwrite with king // Spawn rook towers spawnAllRookTowers(startingPos, 11n, 8n, dist, dist); startingPos.set(coordutil.getKeyFromCoords([11n, 6n]), r.BISHOP + e.W); appendPawnTower(startingPos, 11n, -dist, 5n); appendPawnTower(startingPos, 12n, -dist, 7n); startingPos.set(coordutil.getKeyFromCoords([12n, 8n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([13n, 9n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([13n, 8n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([13n, 6n]), r.BISHOP + e.B); startingPos.set(coordutil.getKeyFromCoords([14n, 10n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([14n, 9n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([14n, 6n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([14n, 5n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([14n, 4n]), r.PAWN + e.W); genBishopTunnel(startingPos, 15n, 6n, dist, dist); surroundPositionInVoidBox(startingPos, { left: -500n, right: 500n, bottom: -500n, top: 500n }); // Bottom blip of pawns to prevent black rook from capturing them startingPos.set(coordutil.getKeyFromCoords([499n, 492n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([7n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([8n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([9n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([10n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([11n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([12n, -500n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([6n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([7n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([8n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([9n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([10n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([11n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([12n, -501n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([13n, -501n]), r.VOID + e.N); // Bishop box that prevents black stalemate ideas startingPos.set(coordutil.getKeyFromCoords([497n, -497n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([498n, -497n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([499n, -497n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([497n, -498n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([497n, -499n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([498n, -498n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([499n, -499n]), r.VOID + e.N); startingPos.set(coordutil.getKeyFromCoords([498n, -499n]), r.BISHOP + e.B); return startingPos; function appendPawnTower( position: Map, x: bigint, startY: bigint, endY: bigint, ): void { if (endY < startY) return; // Don't do negative pawn towers for (let y = startY; y <= endY; y++) { const thisCoords: Coords = [x, y]; const key = coordutil.getKeyFromCoords(thisCoords); position.set(key, r.PAWN + e.W); } } function setAir(position: Map, coords: Coords): void { const key = coordutil.getKeyFromCoords(coords); position.delete(key); } function spawnRookTower( position: Map, xStart: bigint, yStart: bigint, dist: bigint, ): void { // First wall with 4 bishops position.set(coordutil.getKeyFromCoords([xStart, yStart]), r.BISHOP + e.W); position.set(coordutil.getKeyFromCoords([xStart, yStart + 1n]), r.PAWN + e.W); position.set(coordutil.getKeyFromCoords([xStart, yStart + 2n]), r.BISHOP + e.W); position.set(coordutil.getKeyFromCoords([xStart, yStart + 4n]), r.BISHOP + e.W); position.set(coordutil.getKeyFromCoords([xStart, yStart + 6n]), r.BISHOP + e.W); appendPawnTower(position, xStart, yStart + 8n, dist); // Second wall with rook position.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 1n]), r.BISHOP + e.W); position.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 3n]), r.BISHOP + e.W); position.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 5n]), r.BISHOP + e.W); if (yStart + 7n <= dist) position.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 7n]), r.BISHOP + e.W); if (yStart + 8n <= dist) position.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 8n]), r.ROOK + e.B); // Third pawn wall appendPawnTower(position, xStart + 2n, yStart + 2n, dist); if (yStart + 7n <= dist) position.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 7n]), r.PAWN + e.B); } function spawnAllRookTowers( position: Map, xStart: bigint, yStart: bigint, xEnd: bigint, yEnd: bigint, ): void { let y = yStart; for (let x = xStart; x < xEnd; x += 3n) { spawnRookTower(position, x, y, yEnd); y += 3n; // Increment y as well! } } function genBishopTunnel( position: Map, xStart: bigint, yStart: bigint, xEnd: bigint, yEnd: bigint, ): void { let y = yStart; for (let x = xStart; x < xEnd; x++) { position.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.W); position.set(coordutil.getKeyFromCoords([x, y + 1n]), r.PAWN + e.B); position.set(coordutil.getKeyFromCoords([x, y + 4n]), r.PAWN + e.W); position.set(coordutil.getKeyFromCoords([x, y + 5n]), r.PAWN + e.B); y++; // Increment y as well! if (y > yEnd) return; } } } /** * Adds a huge void square around the provided pieces by key. * Then deletes any pieces outside it. * @param position - The position, in key format: Map with key/value pairs. * @param box - The rectangle to which to form the void box. */ function surroundPositionInVoidBox(position: Map, box: BoundingBox): void { for (let x = box.left; x <= box.right; x++) { let key = coordutil.getKeyFromCoords([x, box.bottom]); position.set(key, r.VOID + e.N); key = coordutil.getKeyFromCoords([x, box.top]); position.set(key, r.VOID + e.N); } for (let y = box.bottom; y <= box.top; y++) { let key = coordutil.getKeyFromCoords([box.left, y]); position.set(key, r.VOID + e.N); key = coordutil.getKeyFromCoords([box.right, y]); position.set(key, r.VOID + e.N); } } export default { genPositionOfOmegaCubed, }; ================================================ FILE: src/shared/chess/variants/omega4generator.ts ================================================ // src/shared/chess/variants/omega4generator.ts /** * Here lies the position generator for the Omega^4 Showcase variant. */ import { rawTypes as r, ext as e } from '../util/typeutil.js'; import coordutil, { CoordsKey, Coords } from '../util/coordutil.js'; /** * Generates the Omega^4 position example * @returns {Map} The position in Map format */ function genPositionOfOmegaFourth(): Map { const dist = 500n; // Generate Omega^4 up to a distance of 50 tiles away // Create a Map for the starting position. const startingPos: Map = new Map(); // King chamber const kingChamber: Record = { '-14,17': r.PAWN + e.W, '-14,18': r.PAWN + e.B, '-13,14': r.PAWN + e.W, '-13,15': r.PAWN + e.B, '-13,16': r.PAWN + e.W, '-13,17': r.PAWN + e.B, '-13,20': r.PAWN + e.W, '-13,21': r.PAWN + e.B, '-13,22': r.PAWN + e.W, '-13,23': r.PAWN + e.B, '-13,24': r.PAWN + e.W, '-13,25': r.PAWN + e.B, '-13,26': r.PAWN + e.W, '-13,27': r.PAWN + e.B, '-12,16': r.BISHOP + e.B, '-12,25': r.BISHOP + e.W, '-11,14': r.PAWN + e.W, '-11,15': r.PAWN + e.B, '-11,16': r.KING + e.B, '-11,17': r.PAWN + e.B, '-11,24': r.PAWN + e.W, '-11,25': r.KING + e.W, '-11,26': r.PAWN + e.W, '-11,27': r.PAWN + e.B, '-10,16': r.BISHOP + e.B, '-10,25': r.BISHOP + e.W, '-9,14': r.PAWN + e.W, '-9,15': r.PAWN + e.B, '-9,16': r.PAWN + e.W, '-9,17': r.PAWN + e.B, '-9,18': r.PAWN + e.W, '-9,19': r.PAWN + e.B, '-9,20': r.PAWN + e.W, '-9,21': r.PAWN + e.B, '-9,22': r.PAWN + e.W, '-9,23': r.PAWN + e.B, '-9,24': r.PAWN + e.W, '-9,25': r.PAWN + e.B, '-9,26': r.PAWN + e.W, '-9,27': r.PAWN + e.B, }; for (const [key, value] of Object.entries(kingChamber)) { startingPos.set(key as CoordsKey, value); } // Rook towers const startOfRookTowers: Record = { '0,3': r.PAWN + e.W, '0,4': r.PAWN + e.B, '0,5': r.PAWN + e.W, '0,6': r.PAWN + e.B, '0,11': r.PAWN + e.W, '0,12': r.PAWN + e.B, '1,4': r.BISHOP + e.W, '1,12': r.BISHOP + e.W, '1,13': r.ROOK + e.B, '2,1': r.PAWN + e.W, '2,2': r.PAWN + e.B, '2,3': r.PAWN + e.W, '2,4': r.PAWN + e.B, '2,5': r.PAWN + e.W, '2,6': r.PAWN + e.B, '2,7': r.PAWN + e.W, '2,8': r.PAWN + e.W, '2,9': r.PAWN + e.W, '2,10': r.PAWN + e.W, '2,11': r.PAWN + e.W, '2,12': r.PAWN + e.B, '3,2': r.BISHOP + e.W, '3,4': r.BISHOP + e.B, '3,6': r.PAWN + e.W, '3,7': r.PAWN + e.B, '3,8': r.BISHOP + e.W, '3,9': r.PAWN + e.W, '3,10': r.BISHOP + e.W, '3,12': r.BISHOP + e.W, '3,14': r.BISHOP + e.W, '4,1': r.PAWN + e.W, '4,2': r.PAWN + e.B, '4,3': r.PAWN + e.W, '4,4': r.PAWN + e.B, '4,7': r.PAWN + e.W, '4,8': r.PAWN + e.B, '4,9': r.BISHOP + e.W, '4,11': r.BISHOP + e.W, '4,13': r.BISHOP + e.W, '4,15': r.BISHOP + e.W, '4,16': r.ROOK + e.B, '5,4': r.PAWN + e.W, '5,5': r.PAWN + e.B, '5,8': r.PAWN + e.W, '5,9': r.PAWN + e.B, '5,10': r.PAWN + e.W, '5,11': r.PAWN + e.W, '5,12': r.PAWN + e.W, '5,13': r.PAWN + e.W, '5,14': r.PAWN + e.W, '5,15': r.PAWN + e.B, }; for (const [key, value] of Object.entries(startOfRookTowers)) { startingPos.set(key as CoordsKey, value); } appendPawnTower(startingPos, 0n, 13n, dist); appendPawnTower(startingPos, 2n, 13n, dist); appendPawnTower(startingPos, 3n, 16n, dist); appendPawnTower(startingPos, 5n, 16n, dist); spawnAllRookTowers(startingPos, 6n, 3n, dist + 3n, dist); // Bishop Cannon Battery startingPos.set(coordutil.getKeyFromCoords([0n, -6n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([0n, -7n]), r.PAWN + e.W); spawnAllBishopCannons(startingPos, 1n, -7n, dist, -dist); spawnAllWings(startingPos, -1n, -7n, -dist, -dist); addVoidSquaresToOmegaFourth(startingPos, -866n, 500n, 567n, -426n, -134n); return startingPos; function appendPawnTower( startingPos: Map, x: bigint, startY: bigint, endY: bigint, ): void { if (endY < startY) return; // Don't do negative pawn towers for (let y = startY; y <= endY; y++) { const thisCoords: Coords = [x, y]; const key: CoordsKey = coordutil.getKeyFromCoords(thisCoords); startingPos.set(key, r.PAWN + e.W); } } function setAir(startingPos: Map, coords: Coords): void { const key: CoordsKey = coordutil.getKeyFromCoords(coords); startingPos.delete(key); } // prettier-ignore function spawnRookTower( startingPos: Map, xStart: bigint, yStart: bigint, dist: bigint, ): void { // First wall with 4 bishops startingPos.set(coordutil.getKeyFromCoords([xStart, yStart]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 2n]), r.PAWN + e.W); if (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 3n]), r.PAWN + e.B); if (yStart + 6n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 6n]), r.PAWN + e.W); if (yStart + 7n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 7n]), r.PAWN + e.B); if (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 8n]), r.BISHOP + e.W); if (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 9n]), r.PAWN + e.W); if (yStart + 10n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 10n]), r.BISHOP + e.W); if (yStart + 12n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 12n]), r.BISHOP + e.W); if (yStart + 14n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart, yStart + 14n]), r.BISHOP + e.W); appendPawnTower(startingPos, xStart, yStart + 16n, dist); // Second wall with rook startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 2n]), r.PAWN + e.B); if (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 3n]), r.PAWN + e.W); if (yStart + 4n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 4n]), r.PAWN + e.B); if (yStart + 7n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 7n]), r.PAWN + e.W); if (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 8n]), r.PAWN + e.B); if (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 9n]), r.BISHOP + e.W); if (yStart + 11n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 11n]), r.BISHOP + e.W); if (yStart + 13n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 13n]), r.BISHOP + e.W); if (yStart + 15n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 15n]), r.BISHOP + e.W); if (yStart + 16n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 1n, yStart + 16n]), r.ROOK + e.B); // Third pawn wall startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 2n]), r.PAWN + e.W); if (yStart + 3n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 3n]), r.PAWN + e.B); if (yStart + 4n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 4n]), r.PAWN + e.W); if (yStart + 5n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 5n]), r.PAWN + e.B); if (yStart + 8n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 8n]), r.PAWN + e.W); if (yStart + 9n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 9n]), r.PAWN + e.B); if (yStart + 10n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 10n]), r.PAWN + e.W); if (yStart + 11n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 11n]), r.PAWN + e.W); if (yStart + 12n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 12n]), r.PAWN + e.W); if (yStart + 13n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 13n]), r.PAWN + e.W); if (yStart + 14n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 14n]), r.PAWN + e.W); if (yStart + 15n <= dist) startingPos.set(coordutil.getKeyFromCoords([xStart + 2n, yStart + 15n]), r.PAWN + e.B); appendPawnTower(startingPos, xStart + 2n, yStart + 16n, dist); } function spawnAllRookTowers( startingPos: Map, xStart: bigint, yStart: bigint, xEnd: bigint, yEnd: bigint, ): void { let y: bigint = yStart; for (let x = xStart; x < xEnd; x += 3n) { spawnRookTower(startingPos, x, y, yEnd); y += 3n; // Increment y as well! } } function spawnAllBishopCannons( startingPos: Map, startX: bigint, startY: bigint, endX: bigint, endY: bigint, ): void { const spacing = 7n; let currX: bigint = startX; let currY: bigint = startY; let i = 0; do { genBishopCannon(startingPos, currX, currY, i); currX += spacing; currY -= spacing; i++; } while (currX < endX && currY > endY); } // prettier-ignore function genBishopCannon( startingPos: Map, x: bigint, y: bigint, i: number, ): void { // Pawn staples that never change startingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 2n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 3n]), r.PAWN + e.W); if (y - 3n - x + 3n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 3n, y - 3n]), r.PAWN + e.B); if (y - 4n - x + 3n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 3n, y - 4n]), r.PAWN + e.W); if (y - 5n - x + 4n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 4n, y - 4n]), r.PAWN + e.B); if (y - 3n - x + 4n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 4n, y - 5n]), r.PAWN + e.W); if (y - 4n - x + 5n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 5n, y - 3n]), r.PAWN + e.B); if (y - 4n - x + 5n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 5n, y - 4n]), r.PAWN + e.W); if (y - 2n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 2n]), r.PAWN + e.B); if (y - 3n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 3n]), r.PAWN + e.W); if (y - 1n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 1n]), r.PAWN + e.B); if (y - 2n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 2n]), r.PAWN + e.W); if (y + 1n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y + 1n]), r.PAWN + e.B); if (y + 0n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y + 0n]), r.PAWN + e.W); if (y - 2n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 2n]), r.BISHOP + e.B); if (y - 6n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 6n]), r.PAWN + e.B); if (y - 7n - x + 6n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 6n, y - 7n]), r.PAWN + e.W); if (y - 5n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 5n]), r.PAWN + e.B); if (y - 6n - x + 7n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 7n, y - 6n]), r.PAWN + e.W); if (y - 4n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 4n]), r.PAWN + e.B); if (y - 5n - x + 8n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 8n, y - 5n]), r.PAWN + e.W); if (y - 3n - x + 9n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 9n, y - 3n]), r.PAWN + e.B); if (y - 4n - x + 9n > -980n) startingPos.set(coordutil.getKeyFromCoords([x + 9n, y - 4n]), r.PAWN + e.W); // Generate bishop puzzle pieces. // it tells us how many to iteratively gen! const count: number = i + 2; let puzzleX: bigint = x + 8n; let puzzleY: bigint = y + 2n; const upDiag: bigint = puzzleY - puzzleX; if (upDiag > -990n) { for (let a = 1; a <= count; a++) { const isLastIndex: boolean = a === count; genBishopPuzzlePiece(startingPos, puzzleX, puzzleY, isLastIndex); puzzleX += 1n; puzzleY += 1n; } } // White pawn strip let pawnX: bigint = x + 4n; let pawnY: bigint = y; for (let a = 0; a < i; a++) { startingPos.set(coordutil.getKeyFromCoords([pawnX, pawnY]), r.PAWN + e.W); pawnX++; pawnY++; } } function genBishopPuzzlePiece( startingPos: Map, x: bigint, y: bigint, isLastIndex: boolean, ): void { startingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x, y - 2n]), r.BISHOP + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 3n]), r.BISHOP + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 4n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 5n]), r.PAWN + e.W); if (!isLastIndex) return; // Is last index startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x + 1n, y - 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 3n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x + 2n, y - 2n]), r.PAWN + e.B); } function spawnAllWings( startingPos: Map, startX: bigint, startY: bigint, endX: bigint, endY: bigint, ): void { const spacing = 8n; let currX: bigint = startX; let currY: bigint = startY; let i = 0; do { spawnWing(startingPos, currX, currY, i); currX -= spacing; currY -= spacing; i++; } while (currX > endX && currY > endY); } function spawnWing(startingPos: Map, x: bigint, y: bigint, i: number): void { startingPos.set(coordutil.getKeyFromCoords([x, y]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y - 2n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y - 3n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y - 3n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y - 4n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y - 4n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y - 5n]), r.PAWN + e.W); // Generate segments const count: number = i + 1; const segSpacing = 6n; let segX: bigint = x - 5n; let segY: bigint = y - 8n; for (let a = 1; a <= count; a++) { const isLastIndex: boolean = a === count; genWingSegment(startingPos, segX, segY, isLastIndex); segX -= segSpacing; segY += segSpacing; } setAir(startingPos, [x - 6n, y - 8n]); setAir(startingPos, [x - 6n, y - 9n]); setAir(startingPos, [x - 5n, y - 9n]); setAir(startingPos, [x - 5n, y - 10n]); } function genWingSegment( startingPos: Map, x: bigint, y: bigint, isLastIndex: boolean, ): void { startingPos.set(coordutil.getKeyFromCoords([x, y - 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x, y - 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y - 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 0n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 0n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 1n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 1n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 2n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 3n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 3n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 4n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x, y + 2n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x, y + 3n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 3n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 1n, y + 4n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 4n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 5n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 6n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 7n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 8n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 9n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 10n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 2n, y + 11n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 11n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 3n, y + 12n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 12n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 13n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 11n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 12n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 10n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 9n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 8n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 7n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 7n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 6n]), r.PAWN + e.W); startingPos.set(coordutil.getKeyFromCoords([x - 4n, y + 10n]), r.BISHOP + e.W); if (!isLastIndex) return; // Is last wing segment! startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 6n]), r.PAWN + e.B); startingPos.set(coordutil.getKeyFromCoords([x - 5n, y + 5n]), r.PAWN + e.W); } function addVoidSquaresToOmegaFourth( startingPos: Map, left: bigint, top: bigint, right: bigint, bottomright: bigint, bottomleft: bigint, ): void { for (let x = left; x <= right; x++) { const key: CoordsKey = coordutil.getKeyFromCoords([x, top]); startingPos.set(key, r.VOID + e.N); } for (let y = top; y >= bottomright; y--) { const key: CoordsKey = coordutil.getKeyFromCoords([right, y]); startingPos.set(key, r.VOID + e.N); } let y: bigint = bottomright; for (let x = right; x >= -3n; x--) { let key: CoordsKey = coordutil.getKeyFromCoords([x, y]); startingPos.set(key, r.VOID + e.N); key = coordutil.getKeyFromCoords([x, y - 1n]); startingPos.set(key, r.VOID + e.N); y--; } for (let y = top; y >= bottomleft; y--) { const key: CoordsKey = coordutil.getKeyFromCoords([left, y]); startingPos.set(key, r.VOID + e.N); } y = bottomleft; for (let x = left; x <= -4n; x++) { let key: CoordsKey = coordutil.getKeyFromCoords([x, y]); startingPos.set(key, r.VOID + e.N); key = coordutil.getKeyFromCoords([x, y - 1n]); startingPos.set(key, r.VOID + e.N); y--; } startingPos.set(coordutil.getKeyFromCoords([492n, 493n]), r.VOID + e.N); } } export default { genPositionOfOmegaFourth, }; ================================================ FILE: src/shared/chess/variants/servervalidation.ts ================================================ // src/shared/chess/variants/servervalidation.ts /** * This script defines which variants support server-side move legality validation. * * Variants with a position string length <= POSITION_STRING_THRESHOLD are considered * supported. Variants with large position strings (like Omega Squared and above) or * generator-based variants are excluded to avoid server hitches on legal move gen. */ import type { VariantCode } from './variantdictionary.js'; import variant from './variant.js'; // Constants ----------------------------------------------------------------- /** * The maximum position string length (in characters) for a variant to be * eligible for server-side move validation. * Obstocean (length 2425) is the largest supported variant. * Omega Squared and above (length > 2500) are excluded. */ const POSITION_STRING_THRESHOLD = 2500; // Functions ----------------------------------------------------------------- /** * Returns `true` if the given variant supports server-side move legality validation. * Variants whose position string exceeds {@link POSITION_STRING_THRESHOLD} characters, * or that use position generators, are not supported. * @param variantCode - The variant code, if available. * @param timestamp - The game's start timestamp in ms since epoch. */ function doesVariantSupportServerValidation( variantCode: VariantCode | null, timestamp: number, ): boolean { if (variantCode === null) return false; const positionString = variant.getVariantPositionString(variantCode, timestamp); if (positionString === undefined) return false; // Generator-based variant return positionString.length <= POSITION_STRING_THRESHOLD; } /** * Returns `true` if the game is deleted instantly on conclusion — meaning the server * either validated every move (cheating is impossible) or it's a private game (cheat * reports are not allowed). In both cases: * - The server removes players from the active-games list immediately. * - Clients do not need to send `removefromplayersinactivegames`. * - Clients should not send cheat reports. * @param variantCode - The variant code, if available. * @param timestamp - The game's start timestamp in ms since epoch. * @param isPrivate - Whether the game is a private match. */ function isGameInstantlyDeleted( variantCode: VariantCode | null, timestamp: number, isPrivate: boolean, ): boolean { return isPrivate || doesVariantSupportServerValidation(variantCode, timestamp); } export { doesVariantSupportServerValidation, isGameInstantlyDeleted }; ================================================ FILE: src/shared/chess/variants/validleaderboard.ts ================================================ // src/shared/chess/variants/validleaderboard.ts /** * This script stores all global variables related to our leaderboards. */ import type { VariantCode } from './variantdictionary.js'; const Leaderboards = { /** * The main leaderboard for all same-ish, infinity, variants. * Doesn't include any finite variants, or non-symmetrical ones. */ INFINITY: 0, // Add more leaderboards here as needed } as const; type Leaderboard = (typeof Leaderboards)[keyof typeof Leaderboards]; /** Maps variants to the leaderboard they belong to, if they have one. */ const VariantLeaderboards: Partial> = { Classical: Leaderboards.INFINITY, Confined_Classical: Leaderboards.INFINITY, Classical_Plus: Leaderboards.INFINITY, CoaIP: Leaderboards.INFINITY, CoaIP_HO: Leaderboards.INFINITY, CoaIP_RO: Leaderboards.INFINITY, CoaIP_NO: Leaderboards.INFINITY, Palace: Leaderboards.INFINITY, Pawndard: Leaderboards.INFINITY, Core: Leaderboards.INFINITY, Standarch: Leaderboards.INFINITY, Space_Classic: Leaderboards.INFINITY, Space: Leaderboards.INFINITY, Abundance: Leaderboards.INFINITY, // Add more variants and their corresponding leaderboard here }; export { Leaderboard, Leaderboards, VariantLeaderboards }; ================================================ FILE: src/shared/chess/variants/variant.ts ================================================ // src/shared/chess/variants/variant.ts /** * This script contains methods for retrieving the game rules, or movesets of any given variant. */ import type { BaseRay } from '../../util/math/geometry.js'; import type { GameRules } from '../util/gamerules.js'; import type { CoordsKey, Coords } from '../util/coordutil.js'; import type { GameruleWinCondition } from '../util/winconutil.js'; import type { Movesets, PieceMoveset } from '../logic/movesets.js'; import type { RawType, RawTypeGroup, PlayerGroup } from '../util/typeutil.js'; import type { SpecialMoveFunction, SpecialVicinity } from '../logic/specialmove.js'; import type { VariantCode, GameRuleModifications, TimeVariantProperty, Variant, } from './variantdictionary.js'; import jsutil from '../../util/jsutil.js'; import movesets from '../logic/movesets.js'; import specialmove from '../logic/specialmove.js'; import icnconverter from '../logic/icn/icnconverter.js'; import { players as p } from '../util/typeutil.js'; import variantDictionary from './variantdictionary.js'; // Constants ------------------------------------------------------------------------------- const defaultWinConditions: PlayerGroup = { [p.WHITE]: ['checkmate'], [p.BLACK]: ['checkmate'], }; const defaultTurnOrder = [p.WHITE, p.BLACK]; /** Tuple of all valid variant code strings, for use in runtime validation (e.g. Zod schemas). */ export const variantCodes = Object.keys(variantDictionary) as VariantCode[]; // Functions --------------------------------------------------------------------------------- /** * Tests if the provided variant is a valid variant. * Acts as a type guard, narrowing the input to {@link VariantCode}. * @param variantName - The name of the variant * @returns Whether the variant is a valid variant */ function isVariantValid(variantName: string | undefined): variantName is VariantCode { if (variantName === undefined) return false; return variantName in variantDictionary; } /** * Resolves a variant string (English name or code) sourced from metadata into a {@link VariantCode}. * Warns if the variant is not recognized. * @param variantName - The variant string from metadata (may be an English name, code, or undefined). * @returns The corresponding {@link VariantCode}, or `null` if the input is not recognized. */ function resolveVariantCode(variantName: string | undefined): VariantCode | null { if (variantName === undefined) return null; // Direct code match if (variantName in variantDictionary) return variantName as VariantCode; // Search by English display name for (const [code, variantEntry] of Object.entries(variantDictionary) as [ VariantCode, Variant, ][]) { if (variantEntry.name === variantName) return code; } console.warn(`Variant "${variantName}" is not recognized. Treating as no variant.`); return null; } /** * Resolves the variant from the metadata, normalizes the metadata's `Variant` property to the * English display name (if recognized), or deletes it (if not recognized), then returns the * resolved {@link VariantCode}. * @param metadata - The metadata of the game with the optional `Variant` property. MUST BE A DIRECT REFERENCE (not a copy) * @returns The resolved {@link VariantCode}, or `null` if no valid variant was found. */ function resolveAndNormalizeVariantInMetadata(metadata: { Variant?: string }): VariantCode | null { if (!metadata.Variant) return null; const resolved = resolveVariantCode(metadata.Variant); if (resolved !== null) { // Normalize to English display name metadata.Variant = variantDictionary[resolved].name; } else { // Unrecognized Variant: Treat as if no variant was specified delete metadata.Variant; } return resolved; } /** * Given the variant code and timestamp, calculates the starting position and specialRights. * @param variantCode - The variant code. * @param timestamp - The game's start timestamp in ms since epoch. * @returns An object containing 2 properties: `position`, and `specialRights`. */ function getStartingPositionOfVariant( variantCode: VariantCode, timestamp: number, ): { position: Map; specialRights: Set; } { const variantEntry = variantDictionary[variantCode]; let positionString: string; let position: Map; // Does the entry have a `positionString` property, or a `generator` property? if (variantEntry.positionString !== undefined) { positionString = getApplicableTimestampEntry(variantEntry.positionString, timestamp); return icnconverter.generatePositionFromShortForm(positionString); } else { // Generate the starting position position = variantEntry.generator.algorithm(); const specialRights = icnconverter.generateSpecialRights( position, variantEntry.generator.rules.pawnDoublePush, variantEntry.generator.rules.castleWith, ); return { position, specialRights }; } } /** * Returns the variant's gamerules at the provided timestamp. * @param variantCode - The variant code. * @param timestamp - The game's start timestamp in ms since epoch. * @returns The gamerules object for the variant. */ function getGameRulesOfVariant(variantCode: VariantCode, timestamp: number): GameRules { const gameruleModifications: GameRuleModifications = jsutil.deepCopyObject( getVariantGameRuleModifications(variantCode, timestamp), ); return getGameRules(gameruleModifications); } /** Returns the gamerule modifications for the given variant at the given timestamp. */ function getVariantGameRuleModifications( variantCode: VariantCode, timestamp: number, ): GameRuleModifications { const variantEntry = variantDictionary[variantCode]; // Does the gameruleModifications entry have multiple UTC timestamps? Or just one? return getApplicableTimestampEntry(variantEntry.gameruleModifications, timestamp); } /** * Returns default gamerules with provided modifications * @param modifications - The modifications to the default gamerules. * @returns The gamerules */ function getGameRules(modifications: GameRuleModifications = {}): GameRules { // { slideLimit, promotionRanks, position } const gameRules: GameRules = { // REQUIRED gamerules winConditions: modifications.winConditions || jsutil.deepCopyObject(defaultWinConditions), turnOrder: modifications.turnOrder || jsutil.deepCopyObject(defaultTurnOrder), }; // GameRules that have a dedicated ICN spot... if (modifications.promotionRanks !== null) { // Either undefined (use default), or custom gameRules.promotionRanks = modifications.promotionRanks || { [p.WHITE]: [8n], [p.BLACK]: [1n], }; if (!modifications.promotionsAllowed) throw new Error( 'When overriding promotionRanks, you must also override promotionsAllowed!', ); gameRules.promotionsAllowed = modifications.promotionsAllowed; } if (modifications.moveRule !== null) gameRules.moveRule = modifications.moveRule || 100; // GameRules that DON'T have a dedicated ICN spot... if (modifications.slideLimit !== undefined) gameRules.slideLimit = modifications.slideLimit; return jsutil.deepCopyObject(gameRules) as GameRules; // Copy it so the game doesn't modify the values in this module. } /** * Returns the bare-minimum gamerules a game needs to function. * @returns {GameRules} The gameRules object */ function getBareMinimumGameRules(): GameRules { return getGameRules({ promotionRanks: null, moveRule: null }); // Erase the defaults to end up with only the required's } /** * Accepts a time-variant property and a timestamp, returns the value that should be used for that point in time. * @param object - A time-variant property (positionString, gameruleModifications, etc.) * @param timestamp - The timestamp in ms since epoch to select the appropriate value. */ function getApplicableTimestampEntry( object: TimeVariantProperty, timestamp: number, ): Inner { // Each of these checks are needed to determine whether ANY TimeVariantProperty has timestamp entries if (typeof object !== 'object' || object === null || !object.hasOwnProperty(0)) { return object as Inner; } let timeStampKeys = Object.keys(object as Object); timeStampKeys = timeStampKeys.sort().reverse(); // [1709017200000, 0] let timestampToUse: number; for (const ts of timeStampKeys) { const thisTimestamp = Number.parseInt(ts); if (thisTimestamp <= timestamp) { timestampToUse = thisTimestamp; break; } } return (object as { [timestamp: number]: Inner })[timestampToUse!]!; } /** * Gets the piece movesets for the given variant and timestamp. * @param variantCode - The variant code, or null for pasted games with no variant specified. * @param timestamp - The game's start timestamp in ms since epoch. * @param slideLimit - If provided, overrides the slideLimit gamerule of the variant. Only meaningful for variants without a movesetGenerator (i.e. those that use default movesets), because custom movesets define their own slide ranges explicitly and don't inherit a global slide limit. * @returns The pieceMovesets property of the gamefile. */ function getMovesetsOfVariant( variantCode: VariantCode | null, timestamp: number, slideLimit?: bigint, ): RawTypeGroup<() => PieceMoveset> { // Pasted games with no variant specified use the default movesets if (variantCode === null) return getMovesets(undefined, slideLimit); const variantEntry = variantDictionary[variantCode]; let movesetModifications: Movesets; if (!variantEntry.movesetGenerator) { movesetModifications = {}; slideLimit = slideLimit ?? getApplicableTimestampEntry(variantEntry.gameruleModifications, timestamp).slideLimit; } else { movesetModifications = getApplicableTimestampEntry( variantEntry.movesetGenerator, timestamp, )(); } return getMovesets(movesetModifications, slideLimit); } /** * Returns default movesets with provided modifications such that each piece contains a function returning a copy of its moveset (to avoid modifying originals). * Any piece type present in the modifications will replace the default move that for that piece. * The slidelimit gamerule will only be applied to default movesets, not modified ones. * @param movesetModifications - The modifications to the default movesets. * @param [defaultSlideLimitForOldVariants] Optional. The slidelimit to use for default movesets, if applicable. * @returns The pieceMovesets property of the gamefile. */ function getMovesets( movesetModifications: Movesets = {}, defaultSlideLimitForOldVariants?: bigint, ): RawTypeGroup<() => PieceMoveset> { const origMoveset = movesets.getPieceDefaultMovesets(defaultSlideLimitForOldVariants); // The running piece movesets property of the gamefile. const pieceMovesets: RawTypeGroup<() => PieceMoveset> = {}; for (const [piece, moves] of Object.entries(origMoveset)) { const intPiece = Number(piece) as RawType; pieceMovesets[intPiece] = movesetModifications[intPiece] ? (): PieceMoveset => jsutil.deepCopyObject(movesetModifications[intPiece]!) : (): PieceMoveset => jsutil.deepCopyObject(moves); } return pieceMovesets; } /** Returns the special moves for the given variant at the specified timestamp. */ function getSpecialMovesOfVariant( variantCode: VariantCode | null, timestamp: number, ): RawTypeGroup { const defaultSpecialMoves = jsutil.deepCopyObject(specialmove.defaultSpecialMoves); // Pasted games with no variant specified use the default if (variantCode === null) return defaultSpecialMoves; const variantEntry = variantDictionary[variantCode]; if (variantEntry.specialMoves === undefined) return defaultSpecialMoves; const overrides = getApplicableTimestampEntry(variantEntry.specialMoves, timestamp); jsutil.copyPropertiesToObject(overrides, defaultSpecialMoves); return defaultSpecialMoves; } /** Returns the special vicinity for the given variant at the specified timestamp. */ function getSpecialVicinityOfVariant( variantCode: VariantCode | null, timestamp: number, ): SpecialVicinity { const defaultSpecialVicinityByPiece = specialmove.getDefaultSpecialVicinitiesByPiece(); // Pasted games with no variant specified use the default if (variantCode === null) return defaultSpecialVicinityByPiece; const variantEntry = variantDictionary[variantCode]; if (variantEntry.specialVicinity === undefined) return defaultSpecialVicinityByPiece; const overrides = getApplicableTimestampEntry(variantEntry.specialVicinity, timestamp); jsutil.copyPropertiesToObject(overrides, defaultSpecialVicinityByPiece); return defaultSpecialVicinityByPiece; } /** Returns the preset square annotations for the given variant, if they have any. */ function getSquarePresets(variantCode: VariantCode | null): Coords[] { if (variantCode === null) return []; const square_presets = variantDictionary[variantCode].annotePresets?.squares; return square_presets ? icnconverter.parsePresetSquares(square_presets) : []; } /** Returns the preset ray annotations for the given variant, if they have any. */ function getRayPresets(variantCode: VariantCode | null): BaseRay[] { if (variantCode === null) return []; const ray_presets = variantDictionary[variantCode].annotePresets?.rays; return ray_presets ? icnconverter.parsePresetRays(ray_presets) : []; } /** Returns the worldBorder property for the given variant, if they have one. */ function getVariantWorldBorder(variantCode: VariantCode | null): bigint | undefined { if (variantCode === null) return undefined; return variantDictionary[variantCode].worldBorderDist; } /** * Returns the position string for the given variant at the specified timestamp, * or `undefined` if the variant uses a generator (no fixed position string). * @param variantCode - The variant code. * @param timestamp - The game's start timestamp in ms since epoch. */ function getVariantPositionString(variantCode: VariantCode, timestamp: number): string | undefined { const variantEntry = variantDictionary[variantCode]; if (!variantEntry.positionString) return undefined; // Generator-based variant // Multiple position strings for different timestamps return getApplicableTimestampEntry(variantEntry.positionString, timestamp); } /** Returns the English display name of the given variant, as stored in the variant dictionary. */ function getVariantName(variantCode: VariantCode): string { return variantDictionary[variantCode].name; } // Exports ------------------------------------------------------------------ export default { isVariantValid, resolveVariantCode, resolveAndNormalizeVariantInMetadata, getStartingPositionOfVariant, getGameRulesOfVariant, getMovesetsOfVariant, getSpecialMovesOfVariant, getSpecialVicinityOfVariant, getBareMinimumGameRules, getSquarePresets, getRayPresets, getVariantWorldBorder, getVariantPositionString, getVariantName, }; ================================================ FILE: src/shared/chess/variants/variantdictionary.ts ================================================ // src/shared/chess/variants/variantdictionary.ts /** * This script stores the variant dictionary: the source of truth for every * variant's starting position, gamerule overrides, and moveset/special-move overrides. */ import type { Movesets } from '../logic/movesets.js'; import type { CoordsKey } from '../util/coordutil.js'; import type { GameruleWinCondition } from '../util/winconutil.js'; import type { RawType, Player, PlayerGroup } from '../util/typeutil.js'; import type { SpecialMoveFunction, SpecialVicinity } from '../logic/specialmove.js'; import omega3generator from './omega3generator.js'; import omega4generator from './omega4generator.js'; import fourdimensionalmoves from '../logic/fourdimensionalmoves.js'; import fourdimensionalgenerator from './fourdimensionalgenerator.js'; import { rawTypes as r, players as p } from '../util/typeutil.js'; // Types ------------------------------------------------------------------------------- /** An object that describes what modifications to make to default gamerules in a variant. */ export interface GameRuleModifications { promotionRanks?: { [color: string]: bigint[] } | null; moveRule?: number | null; turnOrder?: Player[]; promotionsAllowed?: PlayerGroup; winConditions?: PlayerGroup; slideLimit?: bigint; } /** Keys (if present) should be timestamps */ export type TimeVariantProperty = | T | { [timestamp: number]: T; }; /** A single variant entry object in the variant dictionary */ export type Variant = { /** The English display name of the variant, used in game metadata (e.g. "Chess on an Infinite Plane"). */ name: string; /** * A function that returns the movesetModifications for the variant. * The movesetModifications do NOT need to contain the movesets of every piece, * but only of the pieces you do not want to use their default movement! */ movesetGenerator?: TimeVariantProperty<() => Movesets>; gameruleModifications: TimeVariantProperty; /** Special Move overrides */ specialMoves?: TimeVariantProperty<{ [piece: string]: SpecialMoveFunction; }>; /** * Used for check calculation. * If we have any overrides for specialMoves, we should have overrides for * this, because it means the piece could make captures on different locations. */ specialVicinity?: TimeVariantProperty; /** * Permanent preset annotations. Can't be erased. * Helpful for emphasizing important lines/squares in showcasings. */ annotePresets?: { /** In compacted string form: '23,94|23,76' */ squares?: string; /** In compacted string form: '23,94>-1,0|23,76>-1,0' */ rays?: string; }; /** If present, its how many squares of padding exist between the furthest piece on each side to the world border. */ worldBorderDist?: bigint; } & ( | { /** The position string of the variant, in the same format as ICN. */ positionString: TimeVariantProperty; generator?: never; } | { /** A function that generates the starting position of the variant, in key format `{ 'x,y':'type' }`. */ generator: { algorithm: () => Map; rules: { pawnDoublePush: boolean; castleWith?: RawType; }; }; positionString?: never; } ); /** Union of all valid variant codes, derived from the keys of {@link variantDictionary}. */ export type VariantCode = keyof typeof variantDictionary; // Constants ------------------------------------------------------------------------------- const positionStringOfClassical = 'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|R1,1+|R8,1+|r1,8+|r8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+'; const positionStringOfCoaIP = 'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|HA-2,-6|HA11,-6|ha-2,15|ha11,15|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+'; // const KOTHWinConditions: PlayerGroup = { [p.WHITE]: ['checkmate','koth'], [p.BLACK]: ['checkmate','koth'] }; const royalCaptureWinConditions: PlayerGroup = { [p.WHITE]: ['royalcapture'], [p.BLACK]: ['royalcapture'], }; const defaultPromotions = [r.KNIGHT, r.BISHOP, r.ROOK, r.QUEEN]; const defaultPromotionsAllowed = repeatPromotionsAllowedForEachColor(defaultPromotions); const coaIPPromotions = [...defaultPromotions, r.GUARD, r.CHANCELLOR, r.HAWK]; const coaIPPromotionsAllowed = repeatPromotionsAllowedForEachColor(coaIPPromotions); const gameruleModificationsOfOmegaShowcasings: GameRuleModifications = { promotionRanks: null, moveRule: null, turnOrder: [p.BLACK, p.WHITE], }; // No promotions, no 50-move rule, and reversed turn order. // ====================================== VARIANT DICTIONARY ====================================== /** * An object that contains each variant's positional and gamerule information: * * A variant may contain either the `positionString` property, or `algorithm` property, * and may contain a `gameruleModifications` property (if not specified, default gamerules are used). * * `positionString` is in the same format as ICN. * `algorithm` needs to contain properties `algorithm`, and `rules`, the first of which points to a function * that returns a position in key format `{ 'x,y':'type' }`, and the second of which is an object which may * contain `pawnDoublePush` and `castleWith` properties, seeing as that info is not present in positional data. * * If either `positionString` or `gameruleModifications` has different values for different points * in time (variant has received an update), then it may contain nested UTC timestamps representing * the new values after that point in time. */ const variantDictionary = buildVariantDictionary({ Classical: { name: 'Classical', positionString: positionStringOfClassical, gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, // Enable to test world border in Classical variant // worldBorder: 4n, // worldBorder: BigInt(1e4), // annotePresets: { // rays: '19,-1000>0,1|39,-1000>0,1|59,-1000>0,1|79,-1000>0,1|99,-1000>0,1|119,-1000>0,1|139,-1000>0,1|159,-1000>0,1|179,-1000>0,1|199,-1000>0,1|219,-1000>0,1|239,-1000>0,1' // } }, Core: { name: 'Core', positionString: 'p-1,10+|p3,10+|p4,10+|p5,10+|p6,10+|p10,10+|p0,9+|p9,9+|n0,8|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|n9,8|p-2,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p11,7+|p-3,6+|p12,6+|p1,5+|P2,5+|P7,5+|p8,5+|P1,4+|p2,4+|p7,4+|P8,4+|P-3,3+|P12,3+|P-2,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P11,2+|N0,1|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|N9,1|P0,0+|P9,0+|P-1,-1+|P3,-1+|P4,-1+|P5,-1+|P6,-1+|P10,-1+', gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, }, Standarch: { name: 'Standarch', positionString: 'p4,11+|p5,11+|p1,10+|p2,10+|p3,10+|p6,10+|p7,10+|p8,10+|p0,9+|ar4,9|ch5,9|p9,9+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P0,0+|AR4,0|CH5,0|P9,0+|P1,-1+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P8,-1+|P4,-2+|P5,-2+', gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.CHANCELLOR, r.ARCHBISHOP, ]), }, }, Space_Classic: { name: 'Space Classic', positionString: { // March 12, 2024, 12:00 AM - Swapped black king & queen so they are on the same side as white king & queen. 1710201600000: 'p-3,18+|r2,18|b4,18|b5,18|r7,18|p12,18+|p-4,17+|p13,17+|p-5,16+|p14,16+|p3,9+|p4,9+|p5,9+|p6,9+|n3,8|k4,8|q5,8|n6,8|p-6,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p-8,6+|p-7,6+|p16,6+|p17,6+|p-9,5+|p18,5+|P-9,4+|P18,4+|P-8,3+|P-7,3+|P16,3+|P17,3+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P15,2+|N3,1|K4,1|Q5,1|N6,1|P3,0+|P4,0+|P5,0+|P6,0+|P-5,-7+|P14,-7+|P-4,-8+|P13,-8+|P-3,-9+|R2,-9|B4,-9|B5,-9|R7,-9|P12,-9+', // UTC Feb 27, 2024, 7:00 AM - Rebalanced. No more queen-bishop skewer. 1709017200000: 'p-3,18+|r2,18|b4,18|b5,18|r7,18|p12,18+|p-4,17+|p13,17+|p-5,16+|p14,16+|p3,9+|p4,9+|p5,9+|p6,9+|n3,8|q4,8|k5,8|n6,8|p-6,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p-8,6+|p-7,6+|p16,6+|p17,6+|p-9,5+|p18,5+|P-9,4+|P18,4+|P-8,3+|P-7,3+|P16,3+|P17,3+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P15,2+|N3,1|K4,1|Q5,1|N6,1|P3,0+|P4,0+|P5,0+|P6,0+|P-5,-7+|P14,-7+|P-4,-8+|P13,-8+|P-3,-9+|R2,-9|B4,-9|B5,-9|R7,-9|P12,-9+', // Original. Queen & rook were easily skewer'able 0: 'p-3,15+|q4,15|p11,15+|p-4,14+|b4,14|p12,14+|p-5,13+|r2,13|b4,13|r6,13|p13,13+|p3,5+|p4,5+|p5,5+|n3,4|k4,4|n5,4|p-6,3+|p1,3+|p2,3+|p3,3+|p4,3+|p5,3+|p6,3+|p7,3+|p-8,2+|p-7,2+|p15,2+|p16,2+|p-9,1+|p17,1+|P-9,0+|P17,0+|P-8,-1+|P-7,-1+|P15,-1+|P16,-1+|P1,-2+|P2,-2+|P3,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P14,-2+|N3,-3|K4,-3|N5,-3|P3,-4+|P4,-4+|P5,-4+|P-5,-12+|R2,-12|B4,-12|R6,-12|P13,-12+|P-4,-13+|B4,-13|P12,-13+|P-3,-14+|Q4,-14|P11,-14+', }, gameruleModifications: { // UTC Feb 27, 2024, 7:00 AM 1709017200000: { promotionsAllowed: defaultPromotionsAllowed }, // Use standard [8,1] promotion lines 0: { promotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [-3n] }, promotionsAllowed: defaultPromotionsAllowed, }, }, }, CoaIP: { name: 'Chess on an Infinite Plane', positionString: positionStringOfCoaIP, gameruleModifications: { promotionsAllowed: coaIPPromotionsAllowed }, }, Pawn_Horde: { name: 'Pawn Horde', positionString: { // UTC Jan 25, 2024, 4:00 AM - 1 pawn was removed on the sides, for a total of 2 added. // Win rates now show it's relatively balanced, white winning slightly above 50%, // however, high level players are confident they can always win with black. 1706155200000: 'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|P0,-6+|P0,-7+|P9,-6+|P9,-7+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+', // UTC Nov 17, 2023, 12:00 AM - 3 more pawns were added on sides. White has slight advantage. 1700179200000: 'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|P0,-6+|P0,-7+|P9,-6+|P9,-7+|P0,-5+|P9,-5+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+', // No pawns on the side. These games go back as far as we started logging games. White has massive disadvantage. 0: 'k5,2+|q4,2|r1,2+|n7,2|n2,2|r8,2+|b3,2|b6,2|P2,-1+|P3,-1+|P6,-1+|P7,-1+|P1,-2+|P2,-2+|P4,-2+|P5,-2+|P6,-2+|P7,-2+|P8,-2+|P1,-3+|P2,-3+|P4,-3+|P5,-3+|P6,-3+|P7,-3+|P8,-3+|P1,-4+|P2,-4+|P4,-4+|P5,-4+|P6,-4+|P7,-4+|P8,-4+|P1,-5+|P2,-5+|P4,-5+|P5,-5+|P6,-5+|P7,-5+|P8,-5+|P1,-6+|P2,-6+|P4,-6+|P5,-6+|P6,-6+|P7,-6+|P8,-6+|P3,-2+|P3,-3+|P3,-4+|P3,-5+|P3,-6+|P1,-7+|P2,-7+|P3,-7+|P4,-7+|P5,-7+|P6,-7+|P7,-7+|P8,-7+|p9,2+|p1,1+|p2,1+|p3,1+|p4,1+|p5,1+|p6,1+|p7,1+|p8,1+|p0,2+', // Pawn Horde USED to have a massive ticking time bomb tower on the side, // but those games were back far enough when we weren't logging games. }, gameruleModifications: { winConditions: { [p.WHITE]: ['checkmate'], [p.BLACK]: ['allpiecescaptured'] }, promotionRanks: { [p.WHITE]: [2n], [p.BLACK]: [-7n] }, promotionsAllowed: defaultPromotionsAllowed, }, }, Space: { name: 'Space', positionString: 'q4,31|ch4,23|p-12,18+|b4,18|p20,18+|p-11,17+|ar-10,17|p0,17+|b4,17|p8,17+|ar18,17|p19,17+|p-11,16+|p-10,16+|p-1,16+|p9,16+|p18,16+|p19,16+|p-1,15+|r0,15|ha4,15|r8,15|p9,15+|p3,6+|p4,6+|p5,6+|p2,5+|k4,5|p6,5+|n1,4|ce4,4|n7,4|p-10,3+|p-1,3+|p0,3+|p2,3+|p3,3+|p4,3+|p5,3+|p6,3+|p8,3+|p9,3+|p-12,2+|p-11,2+|p19,2+|p20,2+|p-13,1+|p21,1+|P-13,0+|P21,0+|P-12,-1+|P-11,-1+|P19,-1+|P20,-1+|P-1,-2+|P0,-2+|P2,-2+|P3,-2+|P4,-2+|P5,-2+|P6,-2+|P8,-2+|P9,-2+|P18,-2+|N1,-3|CE4,-3|N7,-3|P2,-4+|K4,-4|P6,-4+|P3,-5+|P4,-5+|P5,-5+|P-1,-14+|R0,-14|HA4,-14|R8,-14|P9,-14+|P-11,-15+|P-10,-15+|P-1,-15+|P9,-15+|P18,-15+|P19,-15+|P-11,-16+|AR-10,-16|P0,-16+|B4,-16|P8,-16+|AR18,-16|P19,-16+|P-12,-17+|B4,-17|P20,-17+|CH4,-22|Q4,-30', gameruleModifications: { promotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [-3n] }, promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.HAWK, r.CENTAUR, r.ARCHBISHOP, r.CHANCELLOR, ]), }, }, Obstocean: { name: 'Obstocean', positionString: 'ob-6,12|ob-5,12|ob-4,12|ob-3,12|ob-2,12|ob-1,12|ob0,12|ob1,12|ob2,12|ob3,12|ob4,12|ob5,12|ob6,12|ob7,12|ob8,12|ob9,12|ob10,12|ob11,12|ob12,12|ob13,12|ob14,12|ob15,12|ob-6,11|ob-5,11|ob-4,11|ob-3,11|ob-2,11|ob-1,11|ob0,11|ob1,11|ob2,11|ob3,11|ob4,11|ob5,11|ob6,11|ob7,11|ob8,11|ob9,11|ob10,11|ob11,11|ob12,11|ob13,11|ob14,11|ob15,11|ob-6,10|ob-5,10|ob-4,10|ob-3,10|ob-2,10|ob-1,10|ob0,10|ob1,10|ob2,10|ob3,10|ob4,10|ob5,10|ob6,10|ob7,10|ob8,10|ob9,10|ob10,10|ob11,10|ob12,10|ob13,10|ob14,10|ob15,10|ob-6,9|ob-5,9|ob-4,9|ob-3,9|ob-2,9|ob-1,9|ob0,9|ob1,9|ob2,9|ob3,9|ob4,9|ob5,9|ob6,9|ob7,9|ob8,9|ob9,9|ob10,9|ob11,9|ob12,9|ob13,9|ob14,9|ob15,9|ob-6,8|ob-5,8|ob-4,8|ob-3,8|ob-2,8|ob-1,8|ob0,8|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|ob9,8|ob10,8|ob11,8|ob12,8|ob13,8|ob14,8|ob15,8|ob-6,7|ob-5,7|ob-4,7|ob-3,7|ob-2,7|ob-1,7|ob0,7|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|ob9,7|ob10,7|ob11,7|ob12,7|ob13,7|ob14,7|ob15,7|ob-6,6|ob-5,6|ob-4,6|ob-3,6|ob-2,6|ob-1,6|ob0,6|ob1,6|ob2,6|ob3,6|ob4,6|ob5,6|ob6,6|ob7,6|ob8,6|ob9,6|ob10,6|ob11,6|ob12,6|ob13,6|ob14,6|ob15,6|ob-6,5|ob-5,5|ob-4,5|ob-3,5|ob-2,5|ob-1,5|ob0,5|ob1,5|ob2,5|ob3,5|ob4,5|ob5,5|ob6,5|ob7,5|ob8,5|ob9,5|ob10,5|ob11,5|ob12,5|ob13,5|ob14,5|ob15,5|ob-6,4|ob-5,4|ob-4,4|ob-3,4|ob-2,4|ob-1,4|ob0,4|ob1,4|ob2,4|ob3,4|ob4,4|ob5,4|ob6,4|ob7,4|ob8,4|ob9,4|ob10,4|ob11,4|ob12,4|ob13,4|ob14,4|ob15,4|ob-6,3|ob-5,3|ob-4,3|ob-3,3|ob-2,3|ob-1,3|ob0,3|ob1,3|ob2,3|ob3,3|ob4,3|ob5,3|ob6,3|ob7,3|ob8,3|ob9,3|ob10,3|ob11,3|ob12,3|ob13,3|ob14,3|ob15,3|ob-6,2|ob-5,2|ob-4,2|ob-3,2|ob-2,2|ob-1,2|ob0,2|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|ob9,2|ob10,2|ob11,2|ob12,2|ob13,2|ob14,2|ob15,2|ob-6,1|ob-5,1|ob-4,1|ob-3,1|ob-2,1|ob-1,1|ob0,1|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|ob9,1|ob10,1|ob11,1|ob12,1|ob13,1|ob14,1|ob15,1|ob-6,0|ob-5,0|ob-4,0|ob-3,0|ob-2,0|ob-1,0|ob0,0|ob1,0|ob2,0|ob3,0|ob4,0|ob5,0|ob6,0|ob7,0|ob8,0|ob9,0|ob10,0|ob11,0|ob12,0|ob13,0|ob14,0|ob15,0|ob-6,-1|ob-5,-1|ob-4,-1|ob-3,-1|ob-2,-1|ob-1,-1|ob0,-1|ob1,-1|ob2,-1|ob3,-1|ob4,-1|ob5,-1|ob6,-1|ob7,-1|ob8,-1|ob9,-1|ob10,-1|ob11,-1|ob12,-1|ob13,-1|ob14,-1|ob15,-1|ob-6,-2|ob-5,-2|ob-4,-2|ob-3,-2|ob-2,-2|ob-1,-2|ob0,-2|ob1,-2|ob2,-2|ob3,-2|ob4,-2|ob5,-2|ob6,-2|ob7,-2|ob8,-2|ob9,-2|ob10,-2|ob11,-2|ob12,-2|ob13,-2|ob14,-2|ob15,-2|ob-6,-3|ob-5,-3|ob-4,-3|ob-3,-3|ob-2,-3|ob-1,-3|ob0,-3|ob1,-3|ob2,-3|ob3,-3|ob4,-3|ob5,-3|ob6,-3|ob7,-3|ob8,-3|ob9,-3|ob10,-3|ob11,-3|ob12,-3|ob13,-3|ob14,-3|ob15,-3', gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, worldBorderDist: 0n, }, Abundance: { name: 'Abundance', positionString: 'p-3,10+|ha-2,10|ha-1,10|r0,10|ha1,10|ha2,10|p3,10+|p-2,9+|p-1,9+|p1,9+|p2,9+|p-5,6+|gu-4,6|r-3,6+|b-2,6|b-1,6|k0,6+|b1,6|b2,6|r3,6+|gu4,6|p5,6+|p-4,5+|gu-3,5|n-1,5|q0,5|n1,5|gu3,5|p4,5+|p-3,4+|p-2,4+|gu-1,4|ch0,4|gu1,4|p2,4+|p3,4+|p-1,3+|p0,3+|p1,3+|P-1,-3+|P0,-3+|P1,-3+|P-3,-4+|P-2,-4+|GU-1,-4|CH0,-4|GU1,-4|P2,-4+|P3,-4+|P-4,-5+|GU-3,-5|N-1,-5|Q0,-5|N1,-5|GU3,-5|P4,-5+|P-5,-6+|GU-4,-6|R-3,-6+|B-2,-6|B-1,-6|K0,-6+|B1,-6|B2,-6|R3,-6+|GU4,-6|P5,-6+|P-2,-9+|P-1,-9+|P1,-9+|P2,-9+|P-3,-10+|HA-2,-10|HA-1,-10|R0,-10|HA1,-10|HA2,-10|P3,-10+', gameruleModifications: { promotionRanks: { [p.WHITE]: [6n], [p.BLACK]: [-6n] }, promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.GUARD, r.HAWK, r.CHANCELLOR, ]), }, }, // Amazon_Chandelier: { // positionString: 'p-1,26+|p1,26+|p-2,25+|p-1,25+|p0,25+|p1,25+|p2,25+|p-2,24+|p-1,24+|am0,24|p1,24+|p2,24+|p-2,23+|p-1,23+|p0,23+|p1,23+|p2,23+|p-2,22+|p-1,22+|p1,22+|p2,22+|p-5,21+|p-4,21+|p-3,21+|p-2,21+|p-1,21+|p1,21+|p2,21+|p3,21+|p4,21+|p5,21+|p-5,20+|q-4,20|p-3,20+|p-2,20+|p-1,20+|p1,20+|p2,20+|p3,20+|q4,20|p5,20+|p-5,19+|p-4,19+|p-3,19+|p-2,19+|p-1,19+|p1,19+|p2,19+|p3,19+|p4,19+|p5,19+|p-5,18+|p-3,18+|p-2,18+|p-1,18+|p1,18+|p2,18+|p3,18+|p5,18+|p-8,17+|p-5,17+|p-3,17+|p-2,17+|p-1,17+|p1,17+|p2,17+|p3,17+|p5,17+|p8,17+|p-11,16+|p-10,16+|gu-9,16|ha-8,16|p-7,16+|gu-6,16|p-5,16+|p-3,16+|p-2,16+|p-1,16+|p1,16+|p2,16+|p3,16+|p5,16+|gu6,16|p7,16+|ha8,16|gu9,16|p10,16+|p11,16+|p-11,15+|r-10,15|p-9,15+|p-8,15+|r-7,15|p-6,15+|p-5,15+|p-3,15+|p-2,15+|p-1,15+|p1,15+|p2,15+|p3,15+|p5,15+|p6,15+|r7,15|p8,15+|p9,15+|r10,15|p11,15+|gu-12,14|p-11,14+|p-10,14+|p-9,14+|p-8,14+|p-7,14+|p-6,14+|p-5,14+|p-3,14+|p-2,14+|p-1,14+|p1,14+|p2,14+|p3,14+|p5,14+|p6,14+|p7,14+|p8,14+|p9,14+|p10,14+|p11,14+|gu12,14|p-19,13+|p-17,13+|gu-16,13|p-14,13+|p-12,13+|p-11,13+|p-9,13+|p-8,13+|p-6,13+|p-5,13+|p-3,13+|p-2,13+|p-1,13+|p1,13+|p2,13+|p3,13+|p5,13+|p6,13+|p8,13+|p9,13+|p11,13+|p12,13+|p14,13+|gu16,13|p17,13+|p19,13+|p-19,12+|b-18,12|p-17,12+|gu-16,12|p-14,12+|b-13,12|p-12,12+|p-11,12+|p-9,12+|p-8,12+|p-6,12+|p-5,12+|p-3,12+|p-2,12+|p-1,12+|p1,12+|p2,12+|p3,12+|p5,12+|p6,12+|p8,12+|p9,12+|p11,12+|p12,12+|b13,12|p14,12+|gu16,12|p17,12+|b18,12|p19,12+|gu-20,11|p-19,11+|p-17,11+|p-14,11+|p-12,11+|p-11,11+|p-9,11+|p-8,11+|p-6,11+|p-5,11+|p-3,11+|p-2,11+|p-1,11+|p1,11+|p2,11+|p3,11+|p5,11+|p6,11+|p8,11+|p9,11+|p11,11+|p12,11+|p14,11+|p17,11+|p19,11+|gu20,11|ha-20,10|p-19,10+|p-17,10+|p-14,10+|p-12,10+|p-11,10+|p-9,10+|p-8,10+|p-6,10+|p-5,10+|p-3,10+|p-2,10+|p-1,10+|p1,10+|p2,10+|p3,10+|p5,10+|p6,10+|p8,10+|p9,10+|p11,10+|p12,10+|p14,10+|p17,10+|p19,10+|ha20,10|n-11,9|n11,9|n-10,7|gu-5,7|gu-4,7|gu4,7|gu5,7|n10,7|n-8,6|n8,6|n-6,5|n6,5|n-4,4|k0,4|n4,4|n-2,3|n2,3|n0,2|N0,-1|N-2,-2|N2,-2|N-4,-3|K0,-3|N4,-3|N-6,-4|N6,-4|N-8,-5|N8,-5|N-10,-6|GU-5,-6|GU-4,-6|GU4,-6|GU5,-6|N10,-6|N-11,-8|N11,-8|HA-20,-9|P-19,-9+|P-17,-9+|P-14,-9+|P-12,-9+|P-11,-9+|P-9,-9+|P-8,-9+|P-6,-9+|P-5,-9+|P-3,-9+|P-2,-9+|P-1,-9+|P1,-9+|P2,-9+|P3,-9+|P5,-9+|P6,-9+|P8,-9+|P9,-9+|P11,-9+|P12,-9+|P14,-9+|P17,-9+|P19,-9+|HA20,-9|GU-20,-10|P-19,-10+|P-17,-10+|P-14,-10+|P-12,-10+|P-11,-10+|P-9,-10+|P-8,-10+|P-6,-10+|P-5,-10+|P-3,-10+|P-2,-10+|P-1,-10+|P1,-10+|P2,-10+|P3,-10+|P5,-10+|P6,-10+|P8,-10+|P9,-10+|P11,-10+|P12,-10+|P14,-10+|P17,-10+|P19,-10+|GU20,-10|P-19,-11+|B-18,-11|P-17,-11+|GU-16,-11|P-14,-11+|B-13,-11|P-12,-11+|P-11,-11+|P-9,-11+|P-8,-11+|P-6,-11+|P-5,-11+|P-3,-11+|P-2,-11+|P-1,-11+|P1,-11+|P2,-11+|P3,-11+|P5,-11+|P6,-11+|P8,-11+|P9,-11+|P11,-11+|P12,-11+|B13,-11|P14,-11+|GU16,-11|P17,-11+|B18,-11|P19,-11+|P-19,-12+|P-17,-12+|GU-16,-12|P-14,-12+|P-12,-12+|P-11,-12+|P-9,-12+|P-8,-12+|P-6,-12+|P-5,-12+|P-3,-12+|P-2,-12+|P-1,-12+|P1,-12+|P2,-12+|P3,-12+|P5,-12+|P6,-12+|P8,-12+|P9,-12+|P11,-12+|P12,-12+|P14,-12+|GU16,-12|P17,-12+|P19,-12+|GU-12,-13|P-11,-13+|P-10,-13+|P-9,-13+|P-8,-13+|P-7,-13+|P-6,-13+|P-5,-13+|P-3,-13+|P-2,-13+|P-1,-13+|P1,-13+|P2,-13+|P3,-13+|P5,-13+|P6,-13+|P7,-13+|P8,-13+|P9,-13+|P10,-13+|P11,-13+|GU12,-13|P-11,-14+|R-10,-14|P-9,-14+|P-8,-14+|R-7,-14|P-6,-14+|P-5,-14+|P-3,-14+|P-2,-14+|P-1,-14+|P1,-14+|P2,-14+|P3,-14+|P5,-14+|P6,-14+|R7,-14|P8,-14+|P9,-14+|R10,-14|P11,-14+|P-11,-15+|P-10,-15+|GU-9,-15|HA-8,-15|P-7,-15+|GU-6,-15|P-5,-15+|P-3,-15+|P-2,-15+|P-1,-15+|P1,-15+|P2,-15+|P3,-15+|P5,-15+|GU6,-15|P7,-15+|HA8,-15|GU9,-15|P10,-15+|P11,-15+|P-8,-16+|P-5,-16+|P-3,-16+|P-2,-16+|P-1,-16+|P1,-16+|P2,-16+|P3,-16+|P5,-16+|P8,-16+|P-5,-17+|P-3,-17+|P-2,-17+|P-1,-17+|P1,-17+|P2,-17+|P3,-17+|P5,-17+|P-5,-18+|P-4,-18+|P-3,-18+|P-2,-18+|P-1,-18+|P1,-18+|P2,-18+|P3,-18+|P4,-18+|P5,-18+|P-5,-19+|Q-4,-19|P-3,-19+|P-2,-19+|P-1,-19+|P1,-19+|P2,-19+|P3,-19+|Q4,-19|P5,-19+|P-5,-20+|P-4,-20+|P-3,-20+|P-2,-20+|P-1,-20+|P1,-20+|P2,-20+|P3,-20+|P4,-20+|P5,-20+|P-2,-21+|P-1,-21+|P1,-21+|P2,-21+|P-2,-22+|P-1,-22+|P0,-22+|P1,-22+|P2,-22+|P-2,-23+|P-1,-23+|AM0,-23|P1,-23+|P2,-23+|P-2,-24+|P-1,-24+|P0,-24+|P1,-24+|P2,-24+|P-1,-25+|P1,-25+', // gameruleModifications: { promotionRanks: { [p.WHITE]: [10], [p.BLACK]: [-9] }, promotionsAllowed: repeatPromotionsAllowedForEachColor([...defaultPromotions, r.HAWK, r.GUARD, r.AMAZON]) } // }, // Containment: { // positionString: 'K5,-5|k5,14|Q4,-5|q4,14|HA1,-6|HA8,-6|ha1,15|ha8,15|CH-6,-6|CH15,-6|ch-6,15|ch15,15|AR-6,-5|AR15,-5|ar-6,14|ar15,14|N-1,0|N1,0|N2,0|N4,-1|N5,-1|N7,0|N8,0|N10,0|n-1,9|n1,9|n2,9|n4,10|n5,10|n7,9|n8,9|n10,9|GU-2,-2|GU1,-3|GU3,-4|GU6,-4|GU8,-3|GU11,-2|gu-2,11|gu1,12|gu3,13|gu6,13|gu8,12|gu11,11|R-5,-6|R-5,-5|R-4,-5|R-4,-6|R13,-6|R13,-5|R14,-5|R14,-6|r-5,15|r-5,14|r-4,14|r-4,15|r13,15|r13,14|r14,14|r14,15|B-5,-2|B-4,-3|B-3,-2|B12,-2|B13,-3|B14,-2|b-5,11|b-4,12|b-3,11|b12,11|b13,12|b14,11|P-9,-8+|P-9,-6+|P-9,-4+|P-9,-2+|P-9,0+|P-9,2+|P-9,4+|P-9,6+|P-9,8+|P-9,10+|P-9,12+|P-9,14+|P-9,16+|P-8,-7+|P-8,-5+|P-8,-3+|P-8,-1+|P-8,1+|P-8,3+|P-8,5+|P-8,7+|P-8,9+|P-8,11+|P-8,13+|P-8,15+|P-8,17+|P17,-8+|P17,-6+|P17,-4+|P17,-2+|P17,0+|P17,2+|P17,4+|P17,6+|P17,8+|P17,10+|P17,12+|P17,14+|P17,16+|P18,-7+|P18,-5+|P18,-3+|P18,-1+|P18,1+|P18,3+|P18,5+|P18,7+|P18,9+|P18,11+|P18,13+|P18,15+|P18,17+|P-7,-8+|P-5,-8+|P-3,-8+|P-1,-8+|P1,-8+|P3,-8+|P5,-8+|P7,-8+|P9,-8+|P11,-8+|P13,-8+|P15,-8+|P-6,-7+|P-4,-7+|P-2,-7+|P0,-7+|P2,-7+|P4,-7+|P6,-7+|P8,-7+|P10,-7+|P12,-7+|P14,-7+|P16,-7+|P-7,16+|P-5,16+|P-3,16+|P-1,16+|P1,16+|P3,16+|P5,16+|P7,16+|P9,16+|P11,16+|P13,16+|P15,16+|P-6,17+|P-4,17+|P-2,17+|P0,17+|P2,17+|P4,17+|P6,17+|P8,17+|P10,17+|P12,17+|P14,17+|P16,17+|P-7,-6+|P-7,-4+|P-7,-2+|P-6,-2+|P-6,-1+|P-5,-1+|P-5,0+|P-5,-4+|P-4,-4+|P-4,-2+|P-4,-1+|P-3,-6+|P-3,-5+|P-3,-1+|P-3,0+|P-2,0+|P-2,1+|P-1,1+|P-1,-4+|P0,-3+|P1,-2+|P0,-1+|P0,1+|P1,1+|P2,1+|P3,1+|P3,0+|P3,-3+|P3,-5+|P4,-4+|P4,1+|P5,1+|P5,-4+|P6,-5+|P6,-3+|P6,0+|P6,1+|P7,1+|P8,1+|P9,1+|P9,-1+|P8,-2+|P9,-3+|P10,-4+|P10,1+|P11,1+|P11,0+|P12,0+|P12,-1+|P12,-5+|P12,-6+|P13,-4+|P13,-2+|P13,-1+|P14,0+|P14,-1+|P14,-4+|P15,-2+|P15,-1+|P16,-1+|P16,-3+|P16,-5+|p-9,-7+|p-9,-5+|p-9,-3+|p-9,-1+|p-9,1+|p-9,3+|p-9,5+|p-9,7+|p-9,9+|p-9,11+|p-9,13+|p-9,15+|p-9,17+|p-8,-8+|p-8,-6+|p-8,-4+|p-8,-2+|p-8,0+|p-8,2+|p-8,4+|p-8,6+|p-8,8+|p-8,10+|p-8,12+|p-8,14+|p-8,16+|p17,-7+|p17,-5+|p17,-3+|p17,-1+|p17,1+|p17,3+|p17,5+|p17,7+|p17,9+|p17,11+|p17,13+|p17,15+|p17,17+|p18,-8+|p18,-6+|p18,-4+|p18,-2+|p18,0+|p18,2+|p18,4+|p18,6+|p18,8+|p18,10+|p18,12+|p18,14+|p18,16+|p-6,-8+|p-4,-8+|p-2,-8+|p0,-8+|p2,-8+|p4,-8+|p6,-8+|p8,-8+|p10,-8+|p12,-8+|p14,-8+|p16,-8+|p-7,-7+|p-5,-7+|p-3,-7+|p-1,-7+|p1,-7+|p3,-7+|p5,-7+|p7,-7+|p9,-7+|p11,-7+|p13,-7+|p15,-7+|p-6,16+|p-4,16+|p-2,16+|p0,16+|p2,16+|p4,16+|p6,16+|p8,16+|p10,16+|p12,16+|p14,16+|p16,16+|p-7,17+|p-5,17+|p-3,17+|p-1,17+|p1,17+|p3,17+|p5,17+|p7,17+|p9,17+|p11,17+|p13,17+|p15,17+|p-7,15+|p-7,13+|p-7,11+|p-6,11+|p-6,10+|p-5,10+|p-5,9+|p-5,13+|p-4,13+|p-4,11+|p-4,10+|p-3,15+|p-3,14+|p-3,10+|p-3,9+|p-2,9+|p-2,8+|p-1,8+|p-1,13+|p0,12+|p1,11+|p0,10+|p0,8+|p1,8+|p2,8+|p3,8+|p3,9+|p3,12+|p3,14+|p4,13+|p4,8+|p5,8+|p5,13+|p6,14+|p6,12+|p6,9+|p6,8+|p7,8+|p8,8+|p9,8+|p9,10+|p8,11+|p9,12+|p10,13+|p10,8+|p11,8+|p11,9+|p12,9+|p12,10+|p12,14+|p12,15+|p13,13+|p13,11+|p13,10+|p14,9+|p14,10+|p14,13+|p15,11+|p15,10+|p16,10+|p16,12+|p16,14+', // gameruleModifications: { promotionRanks: null } // }, // Classical_Limit_7: { // positionString: positionStringOfClassical, // gameruleModifications: { slideLimit: 7, promotionsAllowed: defaultPromotionsAllowed } // }, // CoaIP_Limit_7: { // positionString: positionStringOfCoaIP, // gameruleModifications: { slideLimit: 7, promotionsAllowed: coaIPPromotionsAllowed } // }, Chess: { name: 'Chess', positionString: positionStringOfClassical, gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, worldBorderDist: 0n, }, // Classical_KOTH: { // positionString: positionStringOfClassical, // gameruleModifications: { winConditions: KOTHWinConditions, promotionsAllowed: defaultPromotionsAllowed } // }, // CoaIP_KOTH: { // positionString: positionStringOfCoaIP, // gameruleModifications: { winConditions: KOTHWinConditions, promotionsAllowed: coaIPPromotionsAllowed } // }, Confined_Classical: { name: 'Confined Classical', positionString: 'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|R1,1+|R8,1+|r1,8+|r8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|ob0,0|ob0,1|ob0,2|ob0,7|ob0,8|ob0,9|ob9,0|ob9,1|ob9,2|ob9,7|ob9,8|ob9,9|ob1,0|ob2,0|ob3,0|ob4,0|ob5,0|ob6,0|ob7,0|ob8,0|ob1,9|ob2,9|ob3,9|ob4,9|ob5,9|ob6,9|ob7,9|ob8,9', gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, }, Classical_Plus: { name: 'Classical+', positionString: 'p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p3,5+|p6,5+|P3,4+|P6,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+', gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, }, Pawndard: { name: 'Pawndard', positionString: { // March 31, 2026, 11:10AM UTC - Kings are no longer given special rights. 1774955419082: 'b4,14|b5,14|r4,12|r5,12|p2,10+|p3,10+|p6,10+|p7,10+|p1,9+|p8,9+|p0,8+|n2,8|n3,8|k4,8|q5,8|n6,8|n7,8|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,5+|p2,5+|P3,5+|p6,5+|P7,5+|p8,5+|p1,4+|P2,4+|p3,4+|P6,4+|p7,4+|P8,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|N2,1|N3,1|Q4,1|K5,1|N6,1|N7,1|P9,1+|P1,0+|P8,0+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|R4,-3|R5,-3|B4,-5|B5,-5', // Kings were originally given special rights. 0: 'b4,14|b5,14|r4,12|r5,12|p2,10+|p3,10+|p6,10+|p7,10+|p1,9+|p8,9+|p0,8+|n2,8|n3,8|k4,8+|q5,8|n6,8|n7,8|p9,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,5+|p2,5+|P3,5+|p6,5+|P7,5+|p8,5+|p1,4+|P2,4+|p3,4+|P6,4+|p7,4+|P8,4+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P0,1+|N2,1|N3,1|Q4,1|K5,1+|N6,1|N7,1|P9,1+|P1,0+|P8,0+|P2,-1+|P3,-1+|P6,-1+|P7,-1+|R4,-3|R5,-3|B4,-5|B5,-5', }, gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed }, }, Knightline: { name: 'Knightline', positionString: 'k5,8|n3,8|n4,8|n6,8|n7,8|p-5,7+|p-4,7+|p-3,7+|p-2,7+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,7+|p12,7+|p13,7+|p14,7+|p15,7+|K5,1|N3,1|N4,1|N6,1|N7,1|P-5,2+|P-4,2+|P-3,2+|P-2,2+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,2+|P12,2+|P13,2+|P14,2+|P15,2+', gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([r.KNIGHT, r.QUEEN]), }, }, Palace: { name: 'Palace', positionString: 'K4,1|Q5,1|P6,2+|P5,2+|P4,2+|P3,2+|P2,2+|P1,2+|p1,4+|p2,4+|p3,4+|p4,4+|p5,4+|p6,4+|N6,1|AM3,1|Q2,1|N1,1|n1,5|n6,5|k4,5|q5,5|q2,5|am3,5|P6,-1+|P7,-1+|P8,-1+|P9,-1+|P1,-1+|P0,-1+|P-1,-1+|P-2,-1+|P2,-2+|P-3,-2+|P5,-2+|P10,-2+|p7,7+|p6,7+|p8,7+|p9,7+|p1,7+|p0,7+|p-1,7+|p-2,7+|p-3,8+|p2,8+|p5,8+|p10,8+|r-1,8|r-2,8|r8,8|r9,8|R8,-2|R9,-2|R-1,-2|R-2,-2|B0,-2|B1,-2|B7,-2|B6,-2|b0,8|b1,8|b7,8|b6,8', gameruleModifications: { promotionRanks: { [p.WHITE]: [4n], [p.BLACK]: [2n] }, promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.AMAZON, ]), }, }, Omega: { name: 'Showcase: Omega', positionString: { // May 15, 2024, 12:00AM - Pawns could no longer double push, that was a bug. 1715731200000: 'r-2,4|r2,4|r-2,2|r2,2|r-2,0|r0,0|r2,0|k0,-1|R1,-2|P-2,-3|Q-1,-3|P2,-3|K0,-4', // Pawns could originally double push, as a bug. 0: 'r-2,4|r2,4|r-2,2|r2,2|r-2,0|r0,0|r2,0|k0,-1|R1,-2|P-2,-3+|Q-1,-3|P2,-3+|K0,-4', }, gameruleModifications: gameruleModificationsOfOmegaShowcasings, }, Omega_Squared: { name: 'Showcase: Omega^2', positionString: { // May 15, 2024, 12:00AM - Pawns could no longer double push, that was a bug. 1715731200000: 'K51,94|k46,80|Q30,148|Q32,148|Q29,3|q29,148|q24,98|q24,97|q24,92|q24,91|q24,86|q24,85|q24,80|q24,79|q46,78|q45,77|q46,77|q45,76|q46,76|q78,60|N15,84|n63,64|r53,96|r45,81|r46,81|r46,79|r47,79|r45,78|B27,152|B29,152|B27,151|B28,151|B30,151|B32,151|B27,150|B28,150|B29,150|B30,150|B31,150|B32,150|B32,149|B9,96|B11,96|B15,96|B20,96|B47,87|B43,86|B44,82|B50,82|B51,81|B8,79|B10,79|B8,78|B10,78|B14,78|B19,78|B49,77|B41,72|B43,72|B45,72|B47,72|B49,72|B51,72|B53,72|B68,72|B10,71|B14,71|B18,71|B20,71|B22,71|B24,71|B76,55|B78,55|B80,55|B82,55|B84,55|B27,20|B29,20|B29,4|b27,155|b29,155|b31,155|b32,154|b9,99|b11,99|b15,99|b20,97|b33,97|b24,96|b11,92|b13,92|b15,92|b19,92|b47,91|b48,91|b49,91|b50,91|b51,91|b24,90|b47,90|b49,90|b51,90|b48,89|b50,89|b51,89|b47,88|b49,88|b51,88|b37,87|b48,87|b50,87|b51,87|b19,86|b49,86|b51,86|b48,85|b50,85|b24,84|b49,84|b51,84|b9,83|b48,83|b50,83|b51,82|b18,80|b14,79|b24,78|b52,77|b53,77|b47,76|b49,76|b51,76|b52,76|b53,76|b66,76|b70,76|b45,75|b47,75|b49,75|b51,75|b53,75|b10,74|b14,74|b18,74|b20,74|b22,74|b24,74|b58,74|b75,71|b78,58|b80,58|b82,58|b84,58|b27,23|b29,23|P26,155|P28,155|P30,155|P32,155|P27,154|P29,154|P31,154|P33,154|P26,153|P28,153|P30,153|P32,153|P26,152|P28,152|P31,152|P33,152|P26,151|P29,151|P31,151|P33,151|P26,150|P33,150|P26,149|P27,149|P28,149|P29,149|P30,149|P31,149|P33,149|P31,148|P33,148|P26,147|P28,147|P30,147|P31,147|P32,147|P33,147|P15,146|P27,146|P29,146|P28,145|P25,111|P24,110|P23,109|P22,108|P21,107|P25,107|P20,106|P24,106|P19,105|P23,105|P20,104|P19,103|P25,103|P20,102|P24,102|P19,101|P23,101|P20,100|P4,99|P6,99|P8,99|P10,99|P12,99|P14,99|P16,99|P19,99|P3,98|P5,98|P7,98|P9,98|P11,98|P15,98|P20,98|P4,97|P6,97|P8,97|P10,97|P12,97|P14,97|P16,97|P19,97|P21,97|P32,97|P34,97|P3,96|P5,96|P8,96|P10,96|P12,96|P33,96|P35,96|P4,95|P6,95|P8,95|P9,95|P10,95|P11,95|P12,95|P14,95|P16,95|P19,95|P21,95|P32,95|P34,95|P36,95|P23,94|P33,94|P35,94|P37,94|P8,93|P9,93|P34,93|P36,93|P38,93|P4,92|P6,92|P8,92|P10,92|P12,92|P14,92|P16,92|P18,92|P20,92|P35,92|P37,92|P39,92|P3,91|P5,91|P7,91|P9,91|P11,91|P13,91|P15,91|P19,91|P21,91|P36,91|P38,91|P40,91|P4,90|P6,90|P8,90|P10,90|P12,90|P14,90|P16,90|P18,90|P20,90|P35,90|P39,90|P41,90|P3,89|P5,89|P7,89|P9,89|P11,89|P13,89|P15,89|P19,89|P21,89|P34,89|P40,89|P42,89|P4,88|P6,88|P8,88|P10,88|P12,88|P14,88|P16,88|P23,88|P33,88|P37,88|P41,88|P43,88|P46,88|P48,88|P3,87|P5,87|P7,87|P9,87|P11,87|P13,87|P15,87|P32,87|P36,87|P38,87|P42,87|P44,87|P4,86|P6,86|P8,86|P10,86|P12,86|P14,86|P18,86|P20,86|P31,86|P35,86|P37,86|P39,86|P42,86|P44,86|P46,86|P48,86|P3,85|P5,85|P7,85|P9,85|P11,85|P13,85|P15,85|P17,85|P19,85|P21,85|P32,85|P36,85|P38,85|P40,85|P42,85|P43,85|P44,85|P3,84|P5,84|P7,84|P9,84|P11,84|P13,84|P18,84|P20,84|P33,84|P37,84|P39,84|P42,84|P43,84|P44,84|P52,84|P4,83|P6,83|P8,83|P10,83|P12,83|P14,83|P16,83|P19,83|P21,83|P34,83|P38,83|P40,83|P42,83|P43,83|P44,83|P49,83|P51,83|P3,82|P5,82|P7,82|P9,82|P11,82|P13,82|P15,82|P23,82|P31,82|P35,82|P39,82|P42,82|P43,82|P52,82|P2,81|P4,81|P6,81|P8,81|P10,81|P12,81|P14,81|P32,81|P38,81|P40,81|P42,81|P43,81|P44,81|P49,81|P3,80|P5,80|P7,80|P9,80|P11,80|P17,80|P19,80|P21,80|P31,80|P33,80|P37,80|P39,80|P50,80|P52,80|P2,79|P4,79|P7,79|P9,79|P11,79|P13,79|P15,79|P18,79|P20,79|P32,79|P34,79|P36,79|P38,79|P40,79|P44,79|P3,78|P5,78|P7,78|P9,78|P11,78|P17,78|P21,78|P33,78|P35,78|P37,78|P39,78|P41,78|P43,78|P2,77|P4,77|P7,77|P8,77|P9,77|P10,77|P11,77|P13,77|P15,77|P18,77|P20,77|P34,77|P36,77|P38,77|P40,77|P42,77|P23,76|P35,76|P37,76|P39,76|P41,76|P65,76|P67,76|P69,76|P71,76|P7,75|P8,75|P36,75|P38,75|P40,75|P42,75|P64,75|P66,75|P70,75|P3,74|P5,74|P7,74|P9,74|P11,74|P13,74|P15,74|P17,74|P19,74|P21,74|P23,74|P25,74|P37,74|P39,74|P41,74|P57,74|P59,74|P63,74|P65,74|P67,74|P69,74|P71,74|P2,73|P4,73|P6,73|P8,73|P10,73|P14,73|P18,73|P20,73|P22,73|P24,73|P38,73|P40,73|P42,73|P44,73|P46,73|P48,73|P50,73|P52,73|P54,73|P58,73|P62,73|P64,73|P66,73|P70,73|P72,73|P3,72|P5,72|P7,72|P9,72|P11,72|P13,72|P15,72|P17,72|P19,72|P21,72|P23,72|P25,72|P39,72|P57,72|P59,72|P61,72|P63,72|P65,72|P71,72|P2,71|P4,71|P6,71|P8,71|P40,71|P42,71|P44,71|P46,71|P48,71|P50,71|P52,71|P54,71|P58,71|P62,71|P64,71|P66,71|P70,71|P72,71|P74,71|P76,71|P3,70|P5,70|P7,70|P9,70|P11,70|P13,70|P15,70|P17,70|P19,70|P21,70|P23,70|P25,70|P57,70|P59,70|P61,70|P63,70|P65,70|P71,70|P75,70|P77,70|P56,69|P58,69|P62,69|P64,69|P72,69|P74,69|P76,69|P78,69|P57,68|P59,68|P61,68|P63,68|P67,68|P69,68|P75,68|P77,68|P79,68|P56,67|P58,67|P62,67|P66,67|P70,67|P74,67|P76,67|P78,67|P80,67|P57,66|P59,66|P64,66|P67,66|P69,66|P71,66|P75,66|P77,66|P79,66|P81,66|P56,65|P59,65|P63,65|P66,65|P70,65|P76,65|P78,65|P80,65|P82,65|P57,64|P59,64|P62,64|P65,64|P67,64|P69,64|P71,64|P73,64|P77,64|P79,64|P81,64|P83,64|P56,63|P58,63|P66,63|P70,63|P74,63|P78,63|P80,63|P82,63|P84,63|P57,62|P59,62|P61,62|P63,62|P65,62|P67,62|P69,62|P71,62|P73,62|P75,62|P79,62|P81,62|P83,62|P85,62|P56,61|P58,61|P60,61|P62,61|P64,61|P66,61|P70,61|P74,61|P76,61|P82,61|P84,61|P57,60|P59,60|P61,60|P63,60|P65,60|P67,60|P69,60|P71,60|P73,60|P75,60|P80,60|P82,60|P56,59|P58,59|P60,59|P62,59|P64,59|P66,59|P70,59|P74,59|P57,58|P59,58|P61,58|P63,58|P65,58|P73,58|P75,58|P58,57|P60,57|P62,57|P64,57|P74,57|P73,56|P75,56|P77,56|P79,56|P81,56|P83,56|P85,56|P74,55|P75,54|P77,54|P79,54|P81,54|P83,54|P85,54|P26,23|P28,23|P30,23|P27,22|P29,22|P26,21|P28,21|P30,21|P26,19|P28,19|P30,19|P26,18|P30,18|P26,17|P30,17|P26,16|P28,16|P30,16|P26,15|P28,15|P30,15|P26,14|P28,14|P30,14|P26,13|P28,13|P30,13|P26,12|P28,12|P30,12|P26,11|P28,11|P30,11|P26,10|P28,10|P30,10|P26,9|P28,9|P30,9|P26,8|P28,8|P30,8|P26,7|P28,7|P30,7|P26,6|P28,6|P30,6|P26,5|P28,5|P30,5|P26,4|P28,4|P30,4|P26,3|P28,3|P30,3|P26,2|P27,2|P28,2|P29,2|P30,2|p26,156|p28,156|p30,156|p32,156|p33,155|p26,154|p28,154|p30,154|p31,153|p33,153|p15,147|p25,112|p24,111|p23,110|p22,109|p25,109|p21,108|p25,108|p20,107|p24,107|p19,106|p23,106|p20,105|p25,105|p19,104|p25,104|p20,103|p24,103|p19,102|p23,102|p20,101|p25,101|p4,100|p6,100|p8,100|p10,100|p12,100|p14,100|p16,100|p19,100|p24,100|p25,100|p3,99|p5,99|p7,99|p20,99|p23,99|p24,99|p25,99|p4,98|p6,98|p8,98|p10,98|p12,98|p14,98|p16,98|p19,98|p21,98|p23,98|p25,98|p32,98|p34,98|p3,97|p5,97|p15,97|p23,97|p25,97|p35,97|p4,96|p6,96|p14,96|p16,96|p19,96|p21,96|p23,96|p25,96|p32,96|p34,96|p36,96|p18,95|p23,95|p25,95|p33,95|p35,95|p37,95|p25,94|p34,94|p36,94|p38,94|p4,93|p6,93|p10,93|p12,93|p14,93|p16,93|p18,93|p20,93|p23,93|p24,93|p25,93|p35,93|p37,93|p39,93|p3,92|p5,92|p7,92|p9,92|p21,92|p23,92|p25,92|p36,92|p38,92|p40,92|p46,92|p47,92|p48,92|p49,92|p50,92|p51,92|p52,92|p4,91|p6,91|p8,91|p10,91|p12,91|p14,91|p16,91|p18,91|p20,91|p23,91|p25,91|p35,91|p39,91|p41,91|p46,91|p52,91|p3,90|p5,90|p7,90|p9,90|p11,90|p13,90|p15,90|p19,90|p21,90|p23,90|p25,90|p34,90|p40,90|p42,90|p46,90|p48,90|p50,90|p52,90|p4,89|p6,89|p8,89|p10,89|p12,89|p14,89|p16,89|p23,89|p25,89|p33,89|p37,89|p41,89|p43,89|p46,89|p52,89|p3,88|p5,88|p7,88|p9,88|p11,88|p13,88|p15,88|p25,88|p32,88|p36,88|p38,88|p42,88|p44,88|p50,88|p52,88|p4,87|p6,87|p8,87|p10,87|p12,87|p14,87|p18,87|p20,87|p23,87|p24,87|p25,87|p31,87|p35,87|p39,87|p46,87|p52,87|p3,86|p5,86|p7,86|p9,86|p11,86|p13,86|p15,86|p17,86|p21,86|p23,86|p25,86|p32,86|p36,86|p38,86|p40,86|p47,86|p50,86|p52,86|p18,85|p20,85|p23,85|p25,85|p33,85|p37,85|p39,85|p46,85|p47,85|p49,85|p52,85|p4,84|p6,84|p8,84|p10,84|p12,84|p14,84|p16,84|p19,84|p21,84|p23,84|p25,84|p34,84|p38,84|p40,84|p46,84|p47,84|p3,83|p5,83|p7,83|p11,83|p13,83|p15,83|p23,83|p25,83|p31,83|p35,83|p39,83|p46,83|p47,83|p52,83|p2,82|p4,82|p6,82|p8,82|p10,82|p12,82|p14,82|p25,82|p32,82|p38,82|p40,82|p46,82|p47,82|p49,82|p3,81|p5,81|p7,81|p9,81|p11,81|p13,81|p15,81|p17,81|p19,81|p21,81|p23,81|p24,81|p25,81|p31,81|p33,81|p37,81|p39,81|p47,81|p50,81|p52,81|p2,80|p4,80|p13,80|p15,80|p20,80|p23,80|p25,80|p32,80|p34,80|p36,80|p38,80|p40,80|p44,80|p47,80|p3,79|p5,79|p17,79|p19,79|p21,79|p23,79|p25,79|p33,79|p35,79|p37,79|p39,79|p41,79|p43,79|p45,79|p2,78|p4,78|p13,78|p15,78|p18,78|p20,78|p23,78|p25,78|p34,78|p36,78|p38,78|p40,78|p42,78|p44,78|p47,78|p49,78|p51,78|p52,78|p53,78|p54,78|p17,77|p23,77|p25,77|p35,77|p37,77|p39,77|p41,77|p44,77|p47,77|p48,77|p50,77|p51,77|p54,77|p65,77|p67,77|p69,77|p71,77|p25,76|p36,76|p38,76|p40,76|p42,76|p44,76|p48,76|p50,76|p54,76|p64,76|p3,75|p5,75|p9,75|p11,75|p13,75|p15,75|p17,75|p19,75|p21,75|p23,75|p25,75|p37,75|p39,75|p41,75|p44,75|p46,75|p48,75|p50,75|p52,75|p54,75|p57,75|p59,75|p63,75|p65,75|p67,75|p69,75|p71,75|p2,74|p4,74|p6,74|p8,74|p38,74|p40,74|p42,74|p44,74|p46,74|p48,74|p50,74|p52,74|p54,74|p62,74|p64,74|p66,74|p70,74|p72,74|p3,73|p5,73|p7,73|p9,73|p11,73|p13,73|p15,73|p17,73|p19,73|p21,73|p23,73|p25,73|p39,73|p41,73|p43,73|p45,73|p47,73|p49,73|p51,73|p53,73|p57,73|p59,73|p61,73|p63,73|p65,73|p71,73|p2,72|p4,72|p6,72|p8,72|p10,72|p14,72|p18,72|p20,72|p22,72|p24,72|p40,72|p42,72|p44,72|p46,72|p48,72|p50,72|p52,72|p54,72|p58,72|p62,72|p64,72|p66,72|p70,72|p72,72|p74,72|p76,72|p3,71|p5,71|p7,71|p9,71|p11,71|p13,71|p15,71|p17,71|p19,71|p21,71|p23,71|p25,71|p53,71|p57,71|p59,71|p61,71|p63,71|p65,71|p71,71|p77,71|p56,70|p58,70|p62,70|p64,70|p67,70|p69,70|p72,70|p74,70|p76,70|p78,70|p57,69|p59,69|p61,69|p63,69|p67,69|p69,69|p75,69|p77,69|p79,69|p56,68|p58,68|p62,68|p66,68|p70,68|p74,68|p76,68|p78,68|p80,68|p57,67|p59,67|p64,67|p67,67|p69,67|p71,67|p75,67|p77,67|p79,67|p81,67|p56,66|p63,66|p66,66|p70,66|p73,66|p76,66|p78,66|p80,66|p82,66|p57,65|p62,65|p65,65|p67,65|p69,65|p71,65|p73,65|p77,65|p79,65|p81,65|p83,65|p56,64|p58,64|p61,64|p66,64|p70,64|p74,64|p78,64|p80,64|p82,64|p84,64|p57,63|p59,63|p61,63|p63,63|p65,63|p67,63|p69,63|p71,63|p73,63|p75,63|p79,63|p81,63|p83,63|p85,63|p56,62|p58,62|p60,62|p62,62|p64,62|p66,62|p70,62|p74,62|p76,62|p80,62|p82,62|p84,62|p57,61|p59,61|p61,61|p63,61|p65,61|p67,61|p69,61|p71,61|p73,61|p75,61|p77,61|p78,61|p80,61|p56,60|p58,60|p60,60|p62,60|p64,60|p66,60|p70,60|p74,60|p77,60|p79,60|p57,59|p59,59|p61,59|p63,59|p65,59|p73,59|p75,59|p77,59|p78,59|p79,59|p80,59|p81,59|p82,59|p83,59|p84,59|p85,59|p58,58|p60,58|p62,58|p64,58|p74,58|p77,58|p79,58|p81,58|p83,58|p85,58|p73,57|p75,57|p77,57|p79,57|p81,57|p83,57|p85,57|p74,56|p76,56|p78,56|p80,56|p82,56|p84,56|p75,55|p77,55|p79,55|p81,55|p83,55|p85,55|p26,24|p28,24|p30,24|p26,22|p28,22|p30,22|p27,21|p29,21|p26,20|p28,20|p30,20|p28,17', // Pawns could originally double push, as a bug. 0: 'K51,94|k46,80|Q30,148|Q32,148|Q29,3|q29,148|q24,98|q24,97|q24,92|q24,91|q24,86|q24,85|q24,80|q24,79|q46,78|q45,77|q46,77|q45,76|q46,76|q78,60|N15,84|n63,64|r53,96|r45,81|r46,81|r46,79|r47,79|r45,78|B27,152|B29,152|B27,151|B28,151|B30,151|B32,151|B27,150|B28,150|B29,150|B30,150|B31,150|B32,150|B32,149|B9,96|B11,96|B15,96|B20,96|B47,87|B43,86|B44,82|B50,82|B51,81|B8,79|B10,79|B8,78|B10,78|B14,78|B19,78|B49,77|B41,72|B43,72|B45,72|B47,72|B49,72|B51,72|B53,72|B68,72|B10,71|B14,71|B18,71|B20,71|B22,71|B24,71|B76,55|B78,55|B80,55|B82,55|B84,55|B27,20|B29,20|B29,4|b27,155|b29,155|b31,155|b32,154|b9,99|b11,99|b15,99|b20,97|b33,97|b24,96|b11,92|b13,92|b15,92|b19,92|b47,91|b48,91|b49,91|b50,91|b51,91|b24,90|b47,90|b49,90|b51,90|b48,89|b50,89|b51,89|b47,88|b49,88|b51,88|b37,87|b48,87|b50,87|b51,87|b19,86|b49,86|b51,86|b48,85|b50,85|b24,84|b49,84|b51,84|b9,83|b48,83|b50,83|b51,82|b18,80|b14,79|b24,78|b52,77|b53,77|b47,76|b49,76|b51,76|b52,76|b53,76|b66,76|b70,76|b45,75|b47,75|b49,75|b51,75|b53,75|b10,74|b14,74|b18,74|b20,74|b22,74|b24,74|b58,74|b75,71|b78,58|b80,58|b82,58|b84,58|b27,23|b29,23|P26,155+|P28,155+|P30,155+|P32,155+|P27,154+|P29,154+|P31,154+|P33,154+|P26,153+|P28,153+|P30,153+|P32,153+|P26,152+|P28,152+|P31,152+|P33,152+|P26,151+|P29,151+|P31,151+|P33,151+|P26,150+|P33,150+|P26,149+|P27,149+|P28,149+|P29,149+|P30,149+|P31,149+|P33,149+|P31,148+|P33,148+|P26,147+|P28,147+|P30,147+|P31,147+|P32,147+|P33,147+|P15,146+|P27,146+|P29,146+|P28,145+|P25,111+|P24,110+|P23,109+|P22,108+|P21,107+|P25,107+|P20,106+|P24,106+|P19,105+|P23,105+|P20,104+|P19,103+|P25,103+|P20,102+|P24,102+|P19,101+|P23,101+|P20,100+|P4,99+|P6,99+|P8,99+|P10,99+|P12,99+|P14,99+|P16,99+|P19,99+|P3,98+|P5,98+|P7,98+|P9,98+|P11,98+|P15,98+|P20,98+|P4,97+|P6,97+|P8,97+|P10,97+|P12,97+|P14,97+|P16,97+|P19,97+|P21,97+|P32,97+|P34,97+|P3,96+|P5,96+|P8,96+|P10,96+|P12,96+|P33,96+|P35,96+|P4,95+|P6,95+|P8,95+|P9,95+|P10,95+|P11,95+|P12,95+|P14,95+|P16,95+|P19,95+|P21,95+|P32,95+|P34,95+|P36,95+|P23,94+|P33,94+|P35,94+|P37,94+|P8,93+|P9,93+|P34,93+|P36,93+|P38,93+|P4,92+|P6,92+|P8,92+|P10,92+|P12,92+|P14,92+|P16,92+|P18,92+|P20,92+|P35,92+|P37,92+|P39,92+|P3,91+|P5,91+|P7,91+|P9,91+|P11,91+|P13,91+|P15,91+|P19,91+|P21,91+|P36,91+|P38,91+|P40,91+|P4,90+|P6,90+|P8,90+|P10,90+|P12,90+|P14,90+|P16,90+|P18,90+|P20,90+|P35,90+|P39,90+|P41,90+|P3,89+|P5,89+|P7,89+|P9,89+|P11,89+|P13,89+|P15,89+|P19,89+|P21,89+|P34,89+|P40,89+|P42,89+|P4,88+|P6,88+|P8,88+|P10,88+|P12,88+|P14,88+|P16,88+|P23,88+|P33,88+|P37,88+|P41,88+|P43,88+|P46,88+|P48,88+|P3,87+|P5,87+|P7,87+|P9,87+|P11,87+|P13,87+|P15,87+|P32,87+|P36,87+|P38,87+|P42,87+|P44,87+|P4,86+|P6,86+|P8,86+|P10,86+|P12,86+|P14,86+|P18,86+|P20,86+|P31,86+|P35,86+|P37,86+|P39,86+|P42,86+|P44,86+|P46,86+|P48,86+|P3,85+|P5,85+|P7,85+|P9,85+|P11,85+|P13,85+|P15,85+|P17,85+|P19,85+|P21,85+|P32,85+|P36,85+|P38,85+|P40,85+|P42,85+|P43,85+|P44,85+|P3,84+|P5,84+|P7,84+|P9,84+|P11,84+|P13,84+|P18,84+|P20,84+|P33,84+|P37,84+|P39,84+|P42,84+|P43,84+|P44,84+|P52,84+|P4,83+|P6,83+|P8,83+|P10,83+|P12,83+|P14,83+|P16,83+|P19,83+|P21,83+|P34,83+|P38,83+|P40,83+|P42,83+|P43,83+|P44,83+|P49,83+|P51,83+|P3,82+|P5,82+|P7,82+|P9,82+|P11,82+|P13,82+|P15,82+|P23,82+|P31,82+|P35,82+|P39,82+|P42,82+|P43,82+|P52,82+|P2,81+|P4,81+|P6,81+|P8,81+|P10,81+|P12,81+|P14,81+|P32,81+|P38,81+|P40,81+|P42,81+|P43,81+|P44,81+|P49,81+|P3,80+|P5,80+|P7,80+|P9,80+|P11,80+|P17,80+|P19,80+|P21,80+|P31,80+|P33,80+|P37,80+|P39,80+|P50,80+|P52,80+|P2,79+|P4,79+|P7,79+|P9,79+|P11,79+|P13,79+|P15,79+|P18,79+|P20,79+|P32,79+|P34,79+|P36,79+|P38,79+|P40,79+|P44,79+|P3,78+|P5,78+|P7,78+|P9,78+|P11,78+|P17,78+|P21,78+|P33,78+|P35,78+|P37,78+|P39,78+|P41,78+|P43,78+|P2,77+|P4,77+|P7,77+|P8,77+|P9,77+|P10,77+|P11,77+|P13,77+|P15,77+|P18,77+|P20,77+|P34,77+|P36,77+|P38,77+|P40,77+|P42,77+|P23,76+|P35,76+|P37,76+|P39,76+|P41,76+|P65,76+|P67,76+|P69,76+|P71,76+|P7,75+|P8,75+|P36,75+|P38,75+|P40,75+|P42,75+|P64,75+|P66,75+|P70,75+|P3,74+|P5,74+|P7,74+|P9,74+|P11,74+|P13,74+|P15,74+|P17,74+|P19,74+|P21,74+|P23,74+|P25,74+|P37,74+|P39,74+|P41,74+|P57,74+|P59,74+|P63,74+|P65,74+|P67,74+|P69,74+|P71,74+|P2,73+|P4,73+|P6,73+|P8,73+|P10,73+|P14,73+|P18,73+|P20,73+|P22,73+|P24,73+|P38,73+|P40,73+|P42,73+|P44,73+|P46,73+|P48,73+|P50,73+|P52,73+|P54,73+|P58,73+|P62,73+|P64,73+|P66,73+|P70,73+|P72,73+|P3,72+|P5,72+|P7,72+|P9,72+|P11,72+|P13,72+|P15,72+|P17,72+|P19,72+|P21,72+|P23,72+|P25,72+|P39,72+|P57,72+|P59,72+|P61,72+|P63,72+|P65,72+|P71,72+|P2,71+|P4,71+|P6,71+|P8,71+|P40,71+|P42,71+|P44,71+|P46,71+|P48,71+|P50,71+|P52,71+|P54,71+|P58,71+|P62,71+|P64,71+|P66,71+|P70,71+|P72,71+|P74,71+|P76,71+|P3,70+|P5,70+|P7,70+|P9,70+|P11,70+|P13,70+|P15,70+|P17,70+|P19,70+|P21,70+|P23,70+|P25,70+|P57,70+|P59,70+|P61,70+|P63,70+|P65,70+|P71,70+|P75,70+|P77,70+|P56,69+|P58,69+|P62,69+|P64,69+|P72,69+|P74,69+|P76,69+|P78,69+|P57,68+|P59,68+|P61,68+|P63,68+|P67,68+|P69,68+|P75,68+|P77,68+|P79,68+|P56,67+|P58,67+|P62,67+|P66,67+|P70,67+|P74,67+|P76,67+|P78,67+|P80,67+|P57,66+|P59,66+|P64,66+|P67,66+|P69,66+|P71,66+|P75,66+|P77,66+|P79,66+|P81,66+|P56,65+|P59,65+|P63,65+|P66,65+|P70,65+|P76,65+|P78,65+|P80,65+|P82,65+|P57,64+|P59,64+|P62,64+|P65,64+|P67,64+|P69,64+|P71,64+|P73,64+|P77,64+|P79,64+|P81,64+|P83,64+|P56,63+|P58,63+|P66,63+|P70,63+|P74,63+|P78,63+|P80,63+|P82,63+|P84,63+|P57,62+|P59,62+|P61,62+|P63,62+|P65,62+|P67,62+|P69,62+|P71,62+|P73,62+|P75,62+|P79,62+|P81,62+|P83,62+|P85,62+|P56,61+|P58,61+|P60,61+|P62,61+|P64,61+|P66,61+|P70,61+|P74,61+|P76,61+|P82,61+|P84,61+|P57,60+|P59,60+|P61,60+|P63,60+|P65,60+|P67,60+|P69,60+|P71,60+|P73,60+|P75,60+|P80,60+|P82,60+|P56,59+|P58,59+|P60,59+|P62,59+|P64,59+|P66,59+|P70,59+|P74,59+|P57,58+|P59,58+|P61,58+|P63,58+|P65,58+|P73,58+|P75,58+|P58,57+|P60,57+|P62,57+|P64,57+|P74,57+|P73,56+|P75,56+|P77,56+|P79,56+|P81,56+|P83,56+|P85,56+|P74,55+|P75,54+|P77,54+|P79,54+|P81,54+|P83,54+|P85,54+|P26,23+|P28,23+|P30,23+|P27,22+|P29,22+|P26,21+|P28,21+|P30,21+|P26,19+|P28,19+|P30,19+|P26,18+|P30,18+|P26,17+|P30,17+|P26,16+|P28,16+|P30,16+|P26,15+|P28,15+|P30,15+|P26,14+|P28,14+|P30,14+|P26,13+|P28,13+|P30,13+|P26,12+|P28,12+|P30,12+|P26,11+|P28,11+|P30,11+|P26,10+|P28,10+|P30,10+|P26,9+|P28,9+|P30,9+|P26,8+|P28,8+|P30,8+|P26,7+|P28,7+|P30,7+|P26,6+|P28,6+|P30,6+|P26,5+|P28,5+|P30,5+|P26,4+|P28,4+|P30,4+|P26,3+|P28,3+|P30,3+|P26,2+|P27,2+|P28,2+|P29,2+|P30,2+|p26,156+|p28,156+|p30,156+|p32,156+|p33,155+|p26,154+|p28,154+|p30,154+|p31,153+|p33,153+|p15,147+|p25,112+|p24,111+|p23,110+|p22,109+|p25,109+|p21,108+|p25,108+|p20,107+|p24,107+|p19,106+|p23,106+|p20,105+|p25,105+|p19,104+|p25,104+|p20,103+|p24,103+|p19,102+|p23,102+|p20,101+|p25,101+|p4,100+|p6,100+|p8,100+|p10,100+|p12,100+|p14,100+|p16,100+|p19,100+|p24,100+|p25,100+|p3,99+|p5,99+|p7,99+|p20,99+|p23,99+|p24,99+|p25,99+|p4,98+|p6,98+|p8,98+|p10,98+|p12,98+|p14,98+|p16,98+|p19,98+|p21,98+|p23,98+|p25,98+|p32,98+|p34,98+|p3,97+|p5,97+|p15,97+|p23,97+|p25,97+|p35,97+|p4,96+|p6,96+|p14,96+|p16,96+|p19,96+|p21,96+|p23,96+|p25,96+|p32,96+|p34,96+|p36,96+|p18,95+|p23,95+|p25,95+|p33,95+|p35,95+|p37,95+|p25,94+|p34,94+|p36,94+|p38,94+|p4,93+|p6,93+|p10,93+|p12,93+|p14,93+|p16,93+|p18,93+|p20,93+|p23,93+|p24,93+|p25,93+|p35,93+|p37,93+|p39,93+|p3,92+|p5,92+|p7,92+|p9,92+|p21,92+|p23,92+|p25,92+|p36,92+|p38,92+|p40,92+|p46,92+|p47,92+|p48,92+|p49,92+|p50,92+|p51,92+|p52,92+|p4,91+|p6,91+|p8,91+|p10,91+|p12,91+|p14,91+|p16,91+|p18,91+|p20,91+|p23,91+|p25,91+|p35,91+|p39,91+|p41,91+|p46,91+|p52,91+|p3,90+|p5,90+|p7,90+|p9,90+|p11,90+|p13,90+|p15,90+|p19,90+|p21,90+|p23,90+|p25,90+|p34,90+|p40,90+|p42,90+|p46,90+|p48,90+|p50,90+|p52,90+|p4,89+|p6,89+|p8,89+|p10,89+|p12,89+|p14,89+|p16,89+|p23,89+|p25,89+|p33,89+|p37,89+|p41,89+|p43,89+|p46,89+|p52,89+|p3,88+|p5,88+|p7,88+|p9,88+|p11,88+|p13,88+|p15,88+|p25,88+|p32,88+|p36,88+|p38,88+|p42,88+|p44,88+|p50,88+|p52,88+|p4,87+|p6,87+|p8,87+|p10,87+|p12,87+|p14,87+|p18,87+|p20,87+|p23,87+|p24,87+|p25,87+|p31,87+|p35,87+|p39,87+|p46,87+|p52,87+|p3,86+|p5,86+|p7,86+|p9,86+|p11,86+|p13,86+|p15,86+|p17,86+|p21,86+|p23,86+|p25,86+|p32,86+|p36,86+|p38,86+|p40,86+|p47,86+|p50,86+|p52,86+|p18,85+|p20,85+|p23,85+|p25,85+|p33,85+|p37,85+|p39,85+|p46,85+|p47,85+|p49,85+|p52,85+|p4,84+|p6,84+|p8,84+|p10,84+|p12,84+|p14,84+|p16,84+|p19,84+|p21,84+|p23,84+|p25,84+|p34,84+|p38,84+|p40,84+|p46,84+|p47,84+|p3,83+|p5,83+|p7,83+|p11,83+|p13,83+|p15,83+|p23,83+|p25,83+|p31,83+|p35,83+|p39,83+|p46,83+|p47,83+|p52,83+|p2,82+|p4,82+|p6,82+|p8,82+|p10,82+|p12,82+|p14,82+|p25,82+|p32,82+|p38,82+|p40,82+|p46,82+|p47,82+|p49,82+|p3,81+|p5,81+|p7,81+|p9,81+|p11,81+|p13,81+|p15,81+|p17,81+|p19,81+|p21,81+|p23,81+|p24,81+|p25,81+|p31,81+|p33,81+|p37,81+|p39,81+|p47,81+|p50,81+|p52,81+|p2,80+|p4,80+|p13,80+|p15,80+|p20,80+|p23,80+|p25,80+|p32,80+|p34,80+|p36,80+|p38,80+|p40,80+|p44,80+|p47,80+|p3,79+|p5,79+|p17,79+|p19,79+|p21,79+|p23,79+|p25,79+|p33,79+|p35,79+|p37,79+|p39,79+|p41,79+|p43,79+|p45,79+|p2,78+|p4,78+|p13,78+|p15,78+|p18,78+|p20,78+|p23,78+|p25,78+|p34,78+|p36,78+|p38,78+|p40,78+|p42,78+|p44,78+|p47,78+|p49,78+|p51,78+|p52,78+|p53,78+|p54,78+|p17,77+|p23,77+|p25,77+|p35,77+|p37,77+|p39,77+|p41,77+|p44,77+|p47,77+|p48,77+|p50,77+|p51,77+|p54,77+|p65,77+|p67,77+|p69,77+|p71,77+|p25,76+|p36,76+|p38,76+|p40,76+|p42,76+|p44,76+|p48,76+|p50,76+|p54,76+|p64,76+|p3,75+|p5,75+|p9,75+|p11,75+|p13,75+|p15,75+|p17,75+|p19,75+|p21,75+|p23,75+|p25,75+|p37,75+|p39,75+|p41,75+|p44,75+|p46,75+|p48,75+|p50,75+|p52,75+|p54,75+|p57,75+|p59,75+|p63,75+|p65,75+|p67,75+|p69,75+|p71,75+|p2,74+|p4,74+|p6,74+|p8,74+|p38,74+|p40,74+|p42,74+|p44,74+|p46,74+|p48,74+|p50,74+|p52,74+|p54,74+|p62,74+|p64,74+|p66,74+|p70,74+|p72,74+|p3,73+|p5,73+|p7,73+|p9,73+|p11,73+|p13,73+|p15,73+|p17,73+|p19,73+|p21,73+|p23,73+|p25,73+|p39,73+|p41,73+|p43,73+|p45,73+|p47,73+|p49,73+|p51,73+|p53,73+|p57,73+|p59,73+|p61,73+|p63,73+|p65,73+|p71,73+|p2,72+|p4,72+|p6,72+|p8,72+|p10,72+|p14,72+|p18,72+|p20,72+|p22,72+|p24,72+|p40,72+|p42,72+|p44,72+|p46,72+|p48,72+|p50,72+|p52,72+|p54,72+|p58,72+|p62,72+|p64,72+|p66,72+|p70,72+|p72,72+|p74,72+|p76,72+|p3,71+|p5,71+|p7,71+|p9,71+|p11,71+|p13,71+|p15,71+|p17,71+|p19,71+|p21,71+|p23,71+|p25,71+|p53,71+|p57,71+|p59,71+|p61,71+|p63,71+|p65,71+|p71,71+|p77,71+|p56,70+|p58,70+|p62,70+|p64,70+|p67,70+|p69,70+|p72,70+|p74,70+|p76,70+|p78,70+|p57,69+|p59,69+|p61,69+|p63,69+|p67,69+|p69,69+|p75,69+|p77,69+|p79,69+|p56,68+|p58,68+|p62,68+|p66,68+|p70,68+|p74,68+|p76,68+|p78,68+|p80,68+|p57,67+|p59,67+|p64,67+|p67,67+|p69,67+|p71,67+|p75,67+|p77,67+|p79,67+|p81,67+|p56,66+|p63,66+|p66,66+|p70,66+|p73,66+|p76,66+|p78,66+|p80,66+|p82,66+|p57,65+|p62,65+|p65,65+|p67,65+|p69,65+|p71,65+|p73,65+|p77,65+|p79,65+|p81,65+|p83,65+|p56,64+|p58,64+|p61,64+|p66,64+|p70,64+|p74,64+|p78,64+|p80,64+|p82,64+|p84,64+|p57,63+|p59,63+|p61,63+|p63,63+|p65,63+|p67,63+|p69,63+|p71,63+|p73,63+|p75,63+|p79,63+|p81,63+|p83,63+|p85,63+|p56,62+|p58,62+|p60,62+|p62,62+|p64,62+|p66,62+|p70,62+|p74,62+|p76,62+|p80,62+|p82,62+|p84,62+|p57,61+|p59,61+|p61,61+|p63,61+|p65,61+|p67,61+|p69,61+|p71,61+|p73,61+|p75,61+|p77,61+|p78,61+|p80,61+|p56,60+|p58,60+|p60,60+|p62,60+|p64,60+|p66,60+|p70,60+|p74,60+|p77,60+|p79,60+|p57,59+|p59,59+|p61,59+|p63,59+|p65,59+|p73,59+|p75,59+|p77,59+|p78,59+|p79,59+|p80,59+|p81,59+|p82,59+|p83,59+|p84,59+|p85,59+|p58,58+|p60,58+|p62,58+|p64,58+|p74,58+|p77,58+|p79,58+|p81,58+|p83,58+|p85,58+|p73,57+|p75,57+|p77,57+|p79,57+|p81,57+|p83,57+|p85,57+|p74,56+|p76,56+|p78,56+|p80,56+|p82,56+|p84,56+|p75,55+|p77,55+|p79,55+|p81,55+|p83,55+|p85,55+|p26,24+|p28,24+|p30,24+|p26,22+|p28,22+|p30,22+|p27,21+|p29,21+|p26,20+|p28,20+|p30,20+|p28,17+', }, gameruleModifications: gameruleModificationsOfOmegaShowcasings, annotePresets: { squares: '-42,76|16,86|15,84|27,88|35,80|37,82|33,86|37,90|41,86|41,80|44,80|27,2|53,71', rays: '23,94>-1,0|23,76>-1,0|17,88>0,1|16,82>0,-1|68,72>0,1|68,71>0,-1|60,64>0,1|72,68>0,-1', }, }, Omega_Cubed: { name: 'Showcase: Omega^3', generator: { algorithm: omega3generator.genPositionOfOmegaCubed, // Additional properties that are normally stored in the position string in the form of '+', but isn't present since it's a generated position. rules: { pawnDoublePush: false }, }, // WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate. gameruleModifications: { winConditions: royalCaptureWinConditions, ...gameruleModificationsOfOmegaShowcasings, }, }, Omega_Fourth: { name: 'Showcase: Omega^4', generator: { algorithm: omega4generator.genPositionOfOmegaFourth, // Additional properties that are normally stored in the position string in the form of '+', but isn't present since it's a generated position. rules: { pawnDoublePush: false }, }, // WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate. gameruleModifications: { winConditions: royalCaptureWinConditions, ...gameruleModificationsOfOmegaShowcasings, }, }, // DISABLED BECAUSE IT has a smothered mate in 2 if one side allows it. // Trappist_1: { // Also has the huygen featured in it! // positionString: 'p-6,16+|ha-4,16|p-2,16+|p11,16+|ha13,16|p15,16+|p-5,15+|p-3,15+|p12,15+|p14,15+|p-4,14+|p13,14+|p-3,9+|hu-2,9|n3,9|b4,9|b5,9|n6,9|hu11,9|p12,9+|p-2,8+|r-1,8+|ch0,8|gu1,8|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|gu8,8|ch9,8|r10,8+|p11,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P-2,1+|R-1,1+|CH0,1|GU1,1|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|GU8,1|CH9,1|R10,1+|P11,1+|P-3,0+|HU-2,0|N3,0|B4,0|B5,0|N6,0|HU11,0|P12,0+|P-4,-5+|P13,-5+|P-5,-6+|P-3,-6+|P12,-6+|P14,-6+|P-6,-7+|HA-4,-7|P-2,-7+|P11,-7+|HA13,-7|P15,-7+', // gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([...coaIPPromotions, r.HUYGEN]) } // }, // Chess on an Infinite Plane - Huygens Options CoaIP_HO: { name: 'Chess on an Infinite Plane - Huygens Option', positionString: 'p-4,14+|ha-2,14|p0,14+|p9,14+|ha11,14|p13,14+|p-3,13+|p-1,13+|p10,13+|p12,13+|p-2,12+|p11,12+|gu-1,9|hu0,9|ch1,9|ch8,9|hu9,9|gu10,9|p-1,8+|p0,8+|r1,8+|n2,8|b3,8|q4,8|k5,8+|b6,8|n7,8|r8,8+|p9,8+|p10,8+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P-1,1+|P0,1+|R1,1+|N2,1|B3,1|Q4,1|K5,1+|B6,1|N7,1|R8,1+|P9,1+|P10,1+|GU-1,0|HU0,0|CH1,0|CH8,0|HU9,0|GU10,0|P-2,-3+|P11,-3+|P-3,-4+|P-1,-4+|P10,-4+|P12,-4+|P-4,-5+|HA-2,-5|P0,-5+|P9,-5+|HA11,-5|P13,-5+', gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([...coaIPPromotions, r.HUYGEN]), }, }, CoaIP_RO: { name: 'Chess on an Infinite Plane - Roses Option', positionString: 'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|RO-2,-6|RO11,-6|ro-2,15|ro11,15', gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.GUARD, r.CHANCELLOR, r.ROSE, ]), }, }, CoaIP_NO: { name: 'Chess on an Infinite Plane - Knightriders Option', positionString: { // 6:43 PM Dec 24, 2025, MST - Knightriders can no longer give a discovered check on move one. 1766627026138: 'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|nr-2,16|nr11,16|NR-2,-7|NR11,-7', 0: 'P-2,1+|P-1,2+|P0,2+|P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|P9,2+|P10,2+|P11,1+|P-4,-6+|P-3,-5+|P-2,-4+|P-1,-5+|P0,-6+|P9,-6+|P10,-5+|P11,-4+|P12,-5+|P13,-6+|p-2,8+|p-1,7+|p0,7+|p1,7+|p2,7+|p3,7+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p9,7+|p10,7+|p11,8+|p-4,15+|p-3,14+|p-2,13+|p-1,14+|p0,15+|p9,15+|p10,14+|p11,13+|p12,14+|p13,15+|R-1,1|R10,1|r-1,8|r10,8|CH0,1|CH9,1|ch0,8|ch9,8|GU1,1+|GU8,1+|gu1,8+|gu8,8+|N2,1|N7,1|n2,8|n7,8|B3,1|B6,1|b3,8|b6,8|Q4,1|q4,8|K5,1+|k5,8+|nr-2,15|nr11,15|NR-2,-6|NR11,-6', }, gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([ ...defaultPromotions, r.GUARD, r.CHANCELLOR, r.KNIGHTRIDER, ]), }, }, '4x4x4x4_Chess': { name: '4×4×4×4 Chess', generator: { algorithm: (): Map => { return fourdimensionalgenerator.gen4DPosition(4n, 4n, 5n, { '0,0': 'P1,2|P2,2|P3,2|P4,2|R1,1|N2,1|N3,1|R4,1', '1,0': 'P1,2|P2,2|P3,2|P4,2|P1,1|P2,1|P3,1|P4,1', '2,0': 'P1,2|P2,2|P3,2|P4,2|B1,1|K2,1|Q3,1|B4,1', '3,0': 'P1,2|P2,2|P3,2|P4,2|R1,1|N2,1|N3,1|R4,1', '0,3': 'p1,3|p2,3|p3,3|p4,3|r1,4|n2,4|n3,4|r4,4', '1,3': 'p1,3|p2,3|p3,3|p4,3|b1,4|q2,4|k3,4|b4,4', '2,3': 'p1,3|p2,3|p3,3|p4,3|p1,4|p2,4|p3,4|p4,4', '3,3': 'p1,3|p2,3|p3,3|p4,3|r1,4|n2,4|n3,4|r4,4', }); }, rules: { pawnDoublePush: true }, }, movesetGenerator: (): Movesets => fourdimensionalgenerator.gen4DMoveset(4n, 4n, 5n, false, true), gameruleModifications: { promotionsAllowed: defaultPromotionsAllowed, promotionRanks: { [p.WHITE]: [19n], [p.BLACK]: [1n] }, }, specialMoves: { pawns: fourdimensionalmoves.doFourDimensionalPawnMove }, specialVicinity: { [r.PAWN]: fourdimensionalgenerator.getPawnVicinity(5n, true), [r.KNIGHT]: fourdimensionalgenerator.getKnightVicinity(5n), [r.KING]: fourdimensionalgenerator.getKingVicinity(5n, false), }, worldBorderDist: 0n, }, '5D_Chess': { name: '5D Chess', generator: { algorithm: (): Map => { return fourdimensionalgenerator.gen4DPosition( 8n, 8n, 9n, positionStringOfClassical, ); }, rules: { pawnDoublePush: true, castleWith: r.ROOK }, }, movesetGenerator: (): Movesets => fourdimensionalgenerator.gen4DMoveset(8n, 8n, 9n, true, false), // WE HAVE TO EXPLICITLY STATE the royalcapture win condition so that it will go into the ICN!!! It doesn't matter the game will automatically swap from checkmate. gameruleModifications: { winConditions: royalCaptureWinConditions, promotionsAllowed: defaultPromotionsAllowed, promotionRanks: { [p.WHITE]: [8n, 17n, 26n, 35n, 44n, 53n, 62n, 71n], [p.BLACK]: [1n, 10n, 19n, 28n, 37n, 46n, 55n, 64n], }, }, specialMoves: { pawns: fourdimensionalmoves.doFourDimensionalPawnMove }, specialVicinity: { [r.PAWN]: fourdimensionalgenerator.getPawnVicinity(9n, false), [r.KNIGHT]: fourdimensionalgenerator.getKnightVicinity(9n), [r.KING]: fourdimensionalgenerator.getKingVicinity(9n, true), }, worldBorderDist: 0n, }, // DELETED (but still in here to support pasting old game notation) Knighted_Chess: { name: 'Knighted Chess', positionString: { // UTC Aug 1, 2024, 12:00AM 1722470400000: 'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|P0,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+|P9,1+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p0,8+|p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p9,8+|CH1,1+|CH8,1+|ch1,8+|ch8,8+|NR2,1|NR7,1|nr2,8|nr7,8|AR3,1|AR6,1|ar3,8|ar6,8|AM4,1|am4,8|RC5,1+|rc5,8+', 0: 'P1,2+|P2,2+|P3,2+|P4,2+|P5,2+|P6,2+|P7,2+|P8,2+|p1,7+|p2,7+|p3,7+|P0,1+|P1,0+|P2,0+|P3,0+|P6,0+|P7,0+|P8,0+|P9,1+|p4,7+|p5,7+|p6,7+|p7,7+|p8,7+|p0,8+|p1,9+|p2,9+|p3,9+|p6,9+|p7,9+|p8,9+|p9,8+|CH1,1+|CH8,1+|ch1,8+|ch8,8+|N2,1|N7,1|n2,8|n7,8|AR3,1|AR6,1|ar3,8|ar6,8|AM4,1|am4,8|RC5,1+|rc5,8+', }, gameruleModifications: { promotionsAllowed: repeatPromotionsAllowedForEachColor([ r.CHANCELLOR, r.KNIGHTRIDER, r.ARCHBISHOP, r.AMAZON, ]), }, }, }); // Functions --------------------------------------------------------------------------------- /** * Type helper: validates each variant entry against the Variant interface while preserving * the literal key names, so that `keyof typeof variantDictionary` remains a union of * specific string literals and every lookup returns `Variant` instead of a narrow union. */ function buildVariantDictionary(dict: { [key in K]: Variant }): { [key in K]: Variant; } { return dict; } /** * Takes a single list of possible promotions: `[r.ROOK,r.QUEEN...]`, * repeats it for every color to produce the full `promotionsAllowed` gamerule: * `{ [p.WHITE]: [r.ROOK,r.QUEEN...], [p.BLACK]: [r.ROOK,r.QUEEN...] }` */ function repeatPromotionsAllowedForEachColor( promotions: RawType[], players: Player[] = [p.WHITE, p.BLACK], ): PlayerGroup { const promotionRule: PlayerGroup = {}; for (const player of players) { promotionRule[player] = promotions; } return promotionRule; } // Exports ---------------------------------------------------------------------------------- export default variantDictionary; ================================================ FILE: src/shared/components/header/pieceThemes.ts ================================================ // src/shared/components/header/pieceThemes.ts /** * This script stores the SVG locations and default tint colors for the pieces. */ import type { Color } from '../../util/math/math.js'; import type { RawType, Player } from '../../chess/util/typeutil.js'; import { rawTypes as r, players as p } from '../../chess/util/typeutil.js'; type PieceColorGroup = { [_team in Player]: Color; }; /** The default tints for a piece, if not provided. */ const defaultBaseColors: PieceColorGroup = { [p.NEUTRAL]: [0.5, 0.5, 0.5, 1], [p.WHITE]: [1, 1, 1, 1], [p.BLACK]: [1, 1, 1, 1], // If these are solid color, they're quite saturated [p.RED]: [1, 0.17, 0.17, 1], [p.BLUE]: [0.23, 0.23, 1, 1], [p.YELLOW]: [1, 1, 0.1, 1], [p.GREEN]: [0.1, 1, 0.1, 1], }; /** Config for the SVGs of the pieces */ const SVGConfig: { [_type in RawType]: { /** null if the raw type doesn't have an svg (VOID) */ location: string | null; colors?: PieceColorGroup; }; } = { [r.VOID]: { location: null, // VOID has no svg colors: { [p.NEUTRAL]: [0, 0, 0, 1], [p.WHITE]: [1, 1, 1, 1], [p.BLACK]: [0.3, 0.3, 0.3, 1], [p.RED]: [1, 0, 0, 1], [p.BLUE]: [0, 0, 1, 1], [p.YELLOW]: [1, 1, 0, 1], [p.GREEN]: [0, 1, 0, 1], }, }, [r.OBSTACLE]: { location: 'fairy/obstacle', colors: { [p.NEUTRAL]: [0.08, 0.08, 0.08, 1], [p.WHITE]: [1, 1, 1, 1], [p.BLACK]: [0, 0, 0, 1], [p.RED]: [1, 0, 0, 1], [p.BLUE]: [0, 0, 1, 1], [p.YELLOW]: [1, 1, 0, 1], [p.GREEN]: [0, 1, 0, 1], }, }, [r.KING]: { location: 'classical' }, [r.GIRAFFE]: { location: 'fairy/giraffe' }, [r.CAMEL]: { location: 'fairy/camel' }, [r.ZEBRA]: { location: 'fairy/zebra' }, [r.KNIGHTRIDER]: { location: 'fairy/knightrider' }, [r.AMAZON]: { location: 'fairy/amazon' }, [r.QUEEN]: { location: 'classical' }, [r.ROYALQUEEN]: { location: 'fairy/royalQueen' }, [r.HAWK]: { location: 'fairy/hawk' }, [r.CHANCELLOR]: { location: 'fairy/chancellor' }, [r.ARCHBISHOP]: { location: 'fairy/archbishop' }, [r.CENTAUR]: { location: 'fairy/centaur' }, [r.ROYALCENTAUR]: { location: 'fairy/royalCentaur' }, [r.ROSE]: { location: 'fairy/rose' }, [r.KNIGHT]: { location: 'classical' }, [r.GUARD]: { location: 'fairy/guard' }, [r.HUYGEN]: { location: 'fairy/huygen' }, [r.ROOK]: { location: 'classical' }, [r.BISHOP]: { location: 'classical' }, [r.PAWN]: { location: 'classical' }, }; function getLocationsForTypes(types: Iterable): Set { const locations: Set = new Set(); for (const raw of types) { const location = getLocationForType(raw); if (location) locations.add(location); } return locations; } function getLocationForType(type: RawType): string | null { return SVGConfig[type].location; } function getBaseColorForType(type: RawType, team: Player): Color { return (SVGConfig[type].colors ?? defaultBaseColors)[team]; } export type { PieceColorGroup }; export default { getLocationsForTypes, getLocationForType, getBaseColorForType, }; ================================================ FILE: src/shared/components/header/themes.ts ================================================ // src/shared/components/header/themes.ts // This module stores our themes. Straight forward :P import type { Color } from '../../util/math/math.js'; import type { PieceColorGroup } from './pieceThemes.js'; import jsutil from '../../util/jsutil.js'; /* * Strings for computed property names. * * By using computed property names, we greatly compact this script, * as our bundler changes the symbols to a single letter. */ const lightTiles = 'lightTiles'; const darkTiles = 'darkTiles'; const legalMovesHighlightColor_Friendly = 'legalMovesHighlightColor_Friendly'; const legalMovesHighlightColor_Opponent = 'legalMovesHighlightColor_Opponent'; const legalMovesHighlightColor_Premove = 'legalMovesHighlightColor_Premove'; const lastMoveHighlightColor = 'lastMoveHighlightColor'; const checkHighlightColor = 'checkHighlightColor'; const boxOutlineColor = 'boxOutlineColor'; const annoteSquareColor = 'annoteSquareColor'; const annoteArrowColor = 'annoteArrowColor'; const pieceTheme = 'pieceTheme'; interface ThemeProperties { [lightTiles]: Color; [darkTiles]: Color; [legalMovesHighlightColor_Friendly]: Color; [legalMovesHighlightColor_Opponent]: Color; [legalMovesHighlightColor_Premove]: Color; [lastMoveHighlightColor]?: Color; [checkHighlightColor]?: Color; [boxOutlineColor]?: Color; [annoteSquareColor]?: Color; [annoteArrowColor]?: Color; [pieceTheme]?: Partial; } /** * Fallback properties for a themes properties * to use if it doesn't have them present */ const defaults: Partial = { [lastMoveHighlightColor]: [0.72, 1, 0, 0.28], [checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0, 0.7], [boxOutlineColor]: [1, 1, 1, 0.45], [annoteSquareColor]: [1, 0, 0, 0.35], // .43 with no .08 offset to squares. This matches the Ray color exactly, though [annoteArrowColor]: [1, 0.65, 0.15, 0.8], [pieceTheme]: {}, }; const defaultTheme = 'wood_light'; const themeDictionary: { [themeName: string]: ThemeProperties } = { /* * By using computed property names, we greatly compact this script, * as our bundler changes the symbols to a single letter. */ wood_light: { // 5D Chess [lightTiles]: [1, 0.85, 0.66, 1], [darkTiles]: [0.87, 0.68, 0.46, 1], [legalMovesHighlightColor_Friendly]: [0, 0.5, 0.14, 0.38], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.37], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32], [lastMoveHighlightColor]: [0.9, 1, 0, 0.3], // [annoteSquareColor]: [1, 0, 0, 0.35], // .43 with no .08 offset to squares // [annoteArrowColor]: [1, 0.65, 0.15, 0.8], }, sandstone: { // Sometimes thanksgiving uses this [lightTiles]: [0.94, 0.88, 0.78, 1], [darkTiles]: [0.74, 0.63, 0.53, 1], [legalMovesHighlightColor_Friendly]: [1, 0.2, 0, 0.35], // 0.5 for BIG positions 0.35 for SMALL [legalMovesHighlightColor_Opponent]: [1, 0.7, 0, 0.35], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], [lastMoveHighlightColor]: [0.3, 1, 0, 0.35], // 0.3 for small, 0.35 for BIG positions }, wood: { [lightTiles]: [0.96, 0.87, 0.75, 1], [darkTiles]: [0.71, 0.54, 0.38, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.42], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.43], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32], }, sandstone_dark: { [lightTiles]: [0.86, 0.76, 0.5, 1], [darkTiles]: [0.69, 0.55, 0.35, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], }, maple: { [lightTiles]: [0.96, 0.81, 0.65, 1], [darkTiles]: [0.83, 0.52, 0.32, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.52, 0, 0.57], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], }, red_wood: { [lightTiles]: [0.96, 0.82, 0.7, 1], [darkTiles]: [0.76, 0.35, 0.24, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.48], [legalMovesHighlightColor_Opponent]: [1, 0.52, 0, 0.61], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.36], }, cyan_ocean: { [lightTiles]: [0.06, 1, 1, 1], [darkTiles]: [0.18, 0.76, 0.78, 1], [legalMovesHighlightColor_Friendly]: [0, 0.46, 0.1, 0.42], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0.24, 0.46], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.3], [annoteSquareColor]: [0, 0, 1, 0.29], [annoteArrowColor]: [0.66, 0, 1, 0.62], }, ocean: { [lightTiles]: [0.42, 0.75, 0.96, 1], [darkTiles]: [0.25, 0.46, 0.73, 1], [legalMovesHighlightColor_Friendly]: [0, 0.86, 0.14, 0.5], [legalMovesHighlightColor_Opponent]: [1, 0, 0.22, 0.35], [legalMovesHighlightColor_Premove]: [0.12, 0, 0.24, 0.48], }, blue_hard: { [lightTiles]: [0.84, 0.91, 0.94, 1], [darkTiles]: [0.26, 0.55, 0.78, 1], [legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.46], [legalMovesHighlightColor_Opponent]: [1, 0, 0.22, 0.37], [legalMovesHighlightColor_Premove]: [0.12, 0, 0.24, 0.42], }, blue: { [lightTiles]: [0.87, 0.89, 0.91, 1], [darkTiles]: [0.55, 0.64, 0.68, 1], [legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.34], [legalMovesHighlightColor_Opponent]: [1, 0.46, 0, 0.35], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.34], [lastMoveHighlightColor]: [0, 1, 1, 0.3], }, blue_soft: { [lightTiles]: [0.59, 0.7, 0.78, 1], [darkTiles]: [0.45, 0.55, 0.62, 1], [legalMovesHighlightColor_Friendly]: [0, 0.6, 0.1, 0.36], [legalMovesHighlightColor_Opponent]: [1, 0.46, 0, 0.37], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.36], }, green_plastic: { [lightTiles]: [0.95, 0.98, 0.73, 1], [darkTiles]: [0.35, 0.58, 0.36, 1], [legalMovesHighlightColor_Friendly]: [0, 0.26, 0.64, 0.56], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.43], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.4], }, green: { [lightTiles]: [0.92, 0.93, 0.82, 1], [darkTiles]: [0.45, 0.58, 0.32, 1], [legalMovesHighlightColor_Friendly]: [1, 1, 0, 0.48], [legalMovesHighlightColor_Opponent]: [0.28, 0, 1, 0.31], [legalMovesHighlightColor_Premove]: [1, 0.12, 0.12, 0.38], [lastMoveHighlightColor]: [1, 1, 0, 0.4], }, lime: { [lightTiles]: [0.8, 0.94, 0.39, 1], [darkTiles]: [0.39, 0.71, 0.06, 1], [legalMovesHighlightColor_Friendly]: [0, 0.26, 0.48, 0.52], [legalMovesHighlightColor_Opponent]: [1, 0, 0, 0.35], [legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.34], [lastMoveHighlightColor]: [0, 0.26, 1, 0.24], }, avocado: { [lightTiles]: [0.84, 0.98, 0.5, 1], [darkTiles]: [0.62, 0.77, 0.35, 1], [legalMovesHighlightColor_Friendly]: [0, 0.26, 0.48, 0.4], [legalMovesHighlightColor_Opponent]: [1, 0, 0, 0.31], [legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.3], [lastMoveHighlightColor]: [0, 0.28, 1, 0.24], }, white: { [lightTiles]: [1, 1, 1, 1], [darkTiles]: [0.78, 0.78, 0.78, 1], [legalMovesHighlightColor_Friendly]: [0, 0, 1, 0.28], [legalMovesHighlightColor_Opponent]: [1, 0.72, 0, 0.37], [legalMovesHighlightColor_Premove]: [0, 0, 0.26, 0.36], [lastMoveHighlightColor]: [0.28, 1, 0, 0.28], [boxOutlineColor]: [0, 0, 0, 0.25], }, poison: { [lightTiles]: [0.93, 0.93, 0.93, 1], [darkTiles]: [0.76, 0.76, 0.56, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], }, grey: { [lightTiles]: [0.72, 0.72, 0.72, 1], [darkTiles]: [0.55, 0.55, 0.55, 1], // tad darker than lichess [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.27], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.26], }, olive: { [lightTiles]: [0.71, 0.68, 0.62, 1], [darkTiles]: [0.55, 0.51, 0.45, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.34], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], }, dark_grey: { [lightTiles]: [0.45, 0.45, 0.45, 1], [darkTiles]: [0.3, 0.3, 0.3, 1], [legalMovesHighlightColor_Friendly]: [0, 0.58, 0.1, 0.34], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.31], [legalMovesHighlightColor_Premove]: [0, 0, 0.4, 0.26], }, seabed: { [lightTiles]: [0.56, 0.66, 0.57, 1], [darkTiles]: [0.42, 0.51, 0.42, 1], [legalMovesHighlightColor_Friendly]: [0, 0.2, 0.78, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29], [legalMovesHighlightColor_Premove]: [0, 0, 0.28, 0.28], }, marble: { [lightTiles]: [0.78, 0.78, 0.7, 1], [darkTiles]: [0.44, 0.42, 0.4, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.37], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.34], }, purple: { [lightTiles]: [0.93, 0.89, 0.96, 1], [darkTiles]: [0.59, 0.49, 0.7, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.39], [legalMovesHighlightColor_Premove]: [0, 0, 0.3, 0.42], }, pink: { [lightTiles]: [0.98, 0.93, 0.93, 1], [darkTiles]: [0.95, 0.76, 0.76, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.32], [legalMovesHighlightColor_Opponent]: [1, 0.18, 0, 0.29], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.28], }, beehive: { [lightTiles]: [1, 0.86, 0.35, 1], [darkTiles]: [0.88, 0.52, 0.05, 1], [legalMovesHighlightColor_Friendly]: [0, 0.48, 0.1, 0.44], [legalMovesHighlightColor_Opponent]: [1, 0.14, 0, 0.49], [legalMovesHighlightColor_Premove]: [0, 0, 0.38, 0.32], [lastMoveHighlightColor]: [0, 1, 0, 0.28], }, // purple_hard: { // [a]: [0.95, 0.95, 0.95, 1], // [b]: [0.49, 0.42, 0.68, 1], // }, // Holiday themes // halloween: { // [lightTiles]: [1, 0.65, 0.4, 1], // [darkTiles]: [1, 0.4, 0, 1], // [legalMovesHighlightColor_Friendly]: [0.6, 0, 1, 0.55], // [legalMovesHighlightColor_Opponent]: [0, 0.5, 0, 0.35], // [legalMovesHighlightColor_Premove]: [1, 0.15, 0, 0.65], // [lastMoveHighlightColor]: [0.5, 0.2, 0, 0.75], // [checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0.5, 0.76], // [pieceTheme]: { // [players.WHITE]: [0.6, 0.5, 0.45, 1], // [players.BLACK]: [0.8, 0, 1, 1], // }, // }, // christmas: { // [lightTiles]: [0.60, 0.93, 1, 1], // [darkTiles]: [0 / 255, 199 / 255, 238 / 255, 1], // [legalMovesHighlightColor_Friendly]: [0, 0, 1, 0.35], // [legalMovesHighlightColor_Opponent]: [1, 0.7, 0, 0.35], // [legalMovesHighlightColor_Premove]: [0.25, 0, 0.7, 0.3], // [lastMoveHighlightColor]: [0, 0, 0.3, 0.35], // [checkHighlightColor]: /* checkHighlightColor */ [1, 0, 0, 0.7], // [pieceTheme]: { // [players.WHITE]: [0.4, 1, 0.4, 1], // [players.BLACK]: [1, 0.2, 0.2, 1], // }, // } }; /** * Returns the specified property of the provided theme. * @param {string} themeName - The name of the theme, e.g., "sandstone". * @param {string} property - The property to retrieve, e.g., "legalMovesHighlightColor_Friendly". * @returns - The property of the theme or the default value. */ function getPropertyOfTheme(themeName: string, property: keyof ThemeProperties): any { const value = themeDictionary[themeName]?.[property] ?? defaults[property]!; return jsutil.deepCopyObject(value); // Return a deep copy to avoid modifying the original. } /** * Checks if a theme name is valid. * @param themeName - The name of the theme to check. * @returns - True if the theme exists, false otherwise. */ function isThemeValid(themeName: string): boolean { return themeDictionary[themeName] !== undefined; } export default { defaultTheme, themes: themeDictionary, getPropertyOfTheme, isThemeValid, }; ================================================ FILE: src/shared/game_version.ts ================================================ // src/shared/game_version.ts /** * The latest version of the game the website is running. * If the client is ever using an old version, we will tell them to hard-refresh. */ export const GAME_VERSION = '1.10.1'; ================================================ FILE: src/shared/types.ts ================================================ // src/shared/types.ts /** * Miscellaneous shared type definitions and schemas between server and client. * * Centralized here to avoid circular dependency issues. */ import * as z from 'zod'; import typeutil from './chess/util/typeutil.js'; import winconutil from './chess/util/winconutil.js'; // Common Helper Schemas --------------------------------------------------------------- /** A player's rating value and whether we are confident about it. */ export type Rating = z.infer; export const RatingSchema = z.strictObject({ value: z.number(), confident: z.boolean(), }); /** * The clock value for the game, `s+s`, where the left side is * start time in seconds, and the right is increment in seconds. * Untimed = `-` */ export type TimeControl = z.infer; export const TimeControlSchema = z.union([ z.templateLiteral([z.number(), '+', z.number()]), z.literal('-'), ]); // Invite Helper Schemas --------------------------------------------------------------- /** The username container of an invite sent by the server. DIFFERENT FROM UsernameContainerProperties!!!! */ export type ServerUsernameContainer = z.infer; export const ServerUsernameContainerSchema = z.strictObject({ type: z.enum(['player', 'guest']), username: z.string(), /** The rating of the user. Falls back to the INFINITY leaderboard. */ rating: RatingSchema.optional(), }); // Game Helper Schemas --------------------------------------------------------------- /** The values of each color's clock, and which one is currently counting down, if any. */ export type ClockValues = z.infer; export const ClockValuesSchema = z.strictObject({ /** Each color's remaining time in milliseconds, keyed by player number. */ clocks: typeutil.GenPlayerGroupSchema(z.number()), /** * If a player's timer is currently counting down, this should be specified. * No clock is ticking if less than 2 moves are played, or if the game is over. * The color specified should have their time immediately accommodated for ping. */ colorTicking: typeutil.PlayerSchema.optional(), /** * The timestamp the color ticking (if there is one) will lose by timeout. * This should be calculated AFTER we adjust the clock values for ping. * The server should NOT specify this when sending the clock information * to the client, because the server and client's clocks are not always in sync. */ timeColorTickingLosesAt: z.number().optional(), }); /** A move as transmitted over the wire: the serialized move token (e.g. `"1,2>3,4=N"`) and an optional clock stamp. */ export type MovePacket = z.infer; export const MovePacketSchema = z.strictObject({ token: z.string(), clockStamp: z.number().optional(), }); /** Info storing draw offers of the game. */ export type DrawOfferInfo = z.infer; export const DrawOfferInfoSchema = z.strictObject({ /** True if our opponent has extended a draw offer we haven't yet confirmed/denied. */ unconfirmed: z.boolean(), /** The move ply WE HAVE last offered a draw, if we have, otherwise undefined. */ lastOfferPly: z.number().int().optional(), }); /** Contains information about an opponent's disconnection. */ export const DisconnectInfoSchema = z.strictObject({ /** * How many milliseconds left until our opponent will be auto-resigned from disconnection, * at the time the server sent the message. Subtract half our ping to get the correct estimated value! */ millisUntilAutoDisconnectResign: z.number(), /** Whether the opponent disconnected by choice, or if it was non-intentional (lost network). */ wasByChoice: z.boolean(), }); /** The state of the game unique to participants, while the game is ongoing — not for spectators, and not when the game is over. */ export type ParticipantState = z.infer; export const ParticipantStateSchema = z.strictObject({ drawOffer: DrawOfferInfoSchema, /** If our opponent has disconnected, this will be present. */ disconnect: DisconnectInfoSchema.optional(), /** * If our opponent is AFK, this is how many milliseconds left until they will be auto-resigned, * at the time the server sent the message. Subtract half our ping to get the correct estimated value! */ millisUntilAutoAFKResign: z.number().optional(), }); /** The message contents of a server websocket `'gameupdate'` message. */ export type GameUpdateMessage = z.infer; export const GameUpdateMessageSchema = z.strictObject({ gameConclusion: winconutil.gameConclusionSchema.optional(), /** Existing moves, if any, to forward to the front of the game. */ moves: z.array(MovePacketSchema), participantState: ParticipantStateSchema, clockValues: ClockValuesSchema.optional(), /** * When true, the client's resync logic should force its move list to exactly match * the server's, even if the client has one extra move at the end that is "ours". * The client must revert it rather than re-submitting it. */ forceSync: z.boolean(), }); /** The message contents of a server websocket `'move'` message — our opponent's move. */ export type OpponentsMoveMessage = z.infer; export const OpponentsMoveMessageSchema = z.strictObject({ /** The move our opponent played. In the most compact notation: `"5,2>5,4"`. */ move: MovePacketSchema, gameConclusion: winconutil.gameConclusionSchema.optional(), /** Our opponent's move number, 1-based. */ moveNumber: z.number().int().positive(), /** If the game is timed, this will be the current clock values. */ clockValues: ClockValuesSchema.optional(), }); /** ICN (Infinite Chess Notation) metadata for a game, inspired by PGN notation. */ export type MetaData = z.infer; export const MetaDataSchema = z.strictObject({ /** What kind of game (rated/casual), and variant, in spoken language. E.g. "Casual local Classical infinite chess game". */ Event: z.string().optional(), /** What website the game was played on. */ Site: z.literal('https://www.infinitechess.org/').optional(), TimeControl: TimeControlSchema.optional(), /** The round number. A pgn-required metadata with no current application to infinitechess.org. */ Round: z.literal('-').optional(), /** The UTC date of the game, in the format `"YYYY.MM.DD"`. */ UTCDate: z.string().optional(), /** The UTC time the game started, in the format `"HH:MM:SS"`. */ UTCTime: z.string().optional(), /** If it's not a custom position, this must be one of the valid variants. */ Variant: z.string().optional(), White: z.string().optional(), Black: z.string().optional(), /** The ID of the white player, if they are signed in, converted to base 62. */ WhiteID: z.string().optional(), /** The ID of the black player, if they are signed in, converted to base 62. */ BlackID: z.string().optional(), /** The display elo of the white player, which may include a "?" if we're uncertain about their rating. */ WhiteElo: z.string().optional(), /** The display elo of the black player, which may include a "?" if we're uncertain about their rating. */ BlackElo: z.string().optional(), /** How much elo white gained/lost from the match. */ WhiteRatingDiff: z.string().optional(), /** How much elo black gained/lost from the match. */ BlackRatingDiff: z.string().optional(), /** How many points each side received from the game (e.g. `"1-0"` means white won, `"1/2-1/2"` means a draw). */ Result: z.string().optional(), /** What caused the game to end, in spoken language. E.g. "Time forfeit". */ Termination: z.string().optional(), }); /** A single player's rating change from a completed rated game. */ export type PlayerRatingChangeInfo = z.infer; export const PlayerRatingChangeInfoSchema = z.strictObject({ newRating: RatingSchema, change: z.number(), }); ================================================ FILE: src/shared/util/EventBus.ts ================================================ // src/shared/util/EventBus.ts /** * Typed Event Bus * T is the mapping of event names to detail types. */ export class EventBus> { private target = new EventTarget(); addEventListener( type: K, listener: (event: CustomEvent) => void, options?: boolean | AddEventListenerOptions, ): void { // We cast 'as any' here because the internal EventTarget expects a // generic EventListener, but we are enforcing a stricter one. this.target.addEventListener(type, listener as any, options); } removeEventListener( type: K, listener: (event: CustomEvent) => void, options?: boolean | EventListenerOptions, ): void { this.target.removeEventListener(type, listener as any, options); } dispatch( type: K, ...args: undefined extends T[K] ? [detail?: T[K]] : [detail: T[K]] ): boolean { const [detail] = args; const event = new CustomEvent(type, { detail, cancelable: true }); return this.target.dispatchEvent(event); } } ================================================ FILE: src/shared/util/editorutil.ts ================================================ // src/shared/util/editorutil.ts /** * Board Editor shared constants between client and server. */ // Constants ------------------------------------------ /** Maximum length for a position name */ const MAX_POSITION_NAME_LENGTH = 70; /** Maximum byte length for ICN notation of a saved position */ const MAX_ICN_LENGTH = 1_000_000; // Exports -------------------------------------------- export default { MAX_POSITION_NAME_LENGTH, MAX_ICN_LENGTH }; ================================================ FILE: src/shared/util/isprime.ts ================================================ // src/shared/util/isprime.ts /* Source: https://github.com/latonv/MillerRabinPrimality Adapted by Andreas Tsevas See attached license below: MIT License Copyright (c) Laton Vermette (https://latonv.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // Note to myself, Naviary: ---------------------------------------------------------------------- // Anything above 341550071728321 has an extremely low probability of returning false positives. // As long as both players use the same seeded RNG, then this will never break games if one // player's Huygen has different legal moves than the others. // The chance of false positives can further be reduced by modifying getAdaptiveNumRounds() to do more checks. // ----------------------------------------------------------------------------------------------- import bimath from './math/bimath.js'; /** * A type containing precalculated values needed to efficiently reduce numbers to/from their Montgomery forms * and perform Montgomery-reduced arithmetic, modulo a given `base`. */ interface MontgomeryReductionContext { /** The modulus of the reduction context */ base: bigint; /** The exponent of the power of 2 used for `r` (i.e., `r = 2^shift`) */ shift: bigint; /** The auxiliary modulus for Montgomery reduction, defined as the smallest power of two greater than `base` */ r: bigint; /** The modular inverse of `r` (mod `base`) */ rInv: bigint; /** The modular inverse of `base` (mod `r`) */ baseInv: bigint; } /** A union of types that can be resolved to a primitive bigint: `number`, `string`, or `bigint` itself. */ type BigIntResolvable = number | string | bigint; /** The available options to the primalityTest function. */ interface PrimalityTestOptions { /** * A positive integer specifying the number of random bases to test against. * If none is provided, a reasonable number of rounds will be chosen automatically to balance speed and accuracy. */ numRounds?: number; /** * An array of integers (or string representations thereof) to use as the * bases for Miller-Rabin testing. If this option is specified, the `numRounds` option will be ignored, * and the maximum number of testing rounds will equal `bases.length` (one round for each given base). * * Every base provided must lie within the range [2, n-2] (inclusive) or a RangeError will be thrown. * If `bases` is specified but is not an array, a TypeError will be thrown. */ bases?: BigIntResolvable[]; /** * Whether to calculate and return a divisor of `n` in certain cases where this is possible (not guaranteed). * Set this to false to avoid extra calculations if a divisor is not needed. Defaults to `true`. */ findDivisor?: boolean; } // Some useful BigInt constants const ZERO = 0n; const ONE = 1n; const TWO = 2n; const FOUR = 4n; const LIMIT_DETERMINISM = 2n ** 64n; const LOWER_LIMIT_MONTGOMMERY = 10n ** 30n; const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); // Useful int constants // See https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Testing_against_small_sets_of_bases // and: https://oeis.org/A014233 // and longer base lists: https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Deterministic_variants_of_the_test const LIMIT_2 = 2047; const LIMIT_2_3 = 1373653; const LIMIT_2_3_5 = 25326001; const LIMIT_2_3_5_7 = 3215031751; const LIMIT_2_3_5_7_11 = 2152302898747; const LIMIT_2_3_5_7_11_13 = 3474749660383; const LIMIT_2_3_5_7_11_13_17 = 341550071728321; const SAFE_SQRT = Math.sqrt(Number.MAX_SAFE_INTEGER); // Bases for deterministic Miller-Rabin // See https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test#Testing_against_small_sets_of_bases // and: https://oeis.org/A014233 // and: https://miller-rabin.appspot.com/ const INT_BASES = [2, 3, 5, 7, 11, 13, 17, 19, 23] as const; const BIGINT_BASES = [2n, 325n, 9375n, 28178n, 450775n, 9780504n, 1795265022n] as const; /** * Calculates the inverse of `2^exp` modulo the given odd `base`. * @param exp The exponent of the power of 2 that should be inverted (_not_ the power of 2 itself!) * @param base The modulus to invert with respect to * @returns The modular inverse of `2^exp` modulo `base` */ function invertPowerOfTwo(exp: number, base: bigint): bigint { // Penk's rshift inversion method, but restricted to powers of 2 and odd bases (which is all we require for Miller-Rabin) // Just start from 1 and repeatedly halve, adding the base whenever necessary to remain even. let inv = ONE; for (let i = 0; i < exp; i++) { if (inv & ONE) inv += base; inv >>= ONE; } return inv; } /** * Calculates the multiplicity of 2 in the prime factorization of `n` -- i.e., how many factors of 2 `n` contains. * So if `n = 2^k * d` and `d` is odd, the returned value would be `k`. * @param n Any number * @returns The multiplicity of 2 in the prime factorization of `n` */ function twoMultiplicity(n: bigint): bigint { if (n === ZERO) return ZERO; let m = ZERO; while (true) { // Since n is not 0, it must have a leading 1 bit, so this is safe if (n & (ONE << m)) return m; // Bail out when we reach the least significant 1 bit m++; } } /** * Produces a string of random bits with the specified length. * Mainly useful as input to BigInt constructors that take digit strings of arbitrary length. * @param numBits How many random bits to return. * @returns A string of `numBits` random bits. */ function getRandomBitString(numBits: number): string { let bits = ''; while (bits.length < numBits) { bits += Math.random().toString(2).substring(2, 50); } return bits.substring(0, numBits); } /** * Produces a Montgomery reduction context that can be used to define and operate on numbers in Montgomery form * for the given base. * @param base The modulus of the reduction context. Must be an odd number. * @returns A Montgomery reduction context for the given base. */ function getReductionContext(base: bigint): MontgomeryReductionContext { if (!(base & ONE)) throw new Error(`base must be odd`); // Select the auxiliary modulus r to be the smallest power of two greater than the base modulus const numBits = bimath.bitLength_bisection(base); const littleShift = numBits; const shift = BigInt(littleShift); const r = ONE << shift; // Calculate the modular inverses of r (mod base) and base (mod r) const rInv = invertPowerOfTwo(littleShift, base); const baseInv = r - (((rInv * r - ONE) / base) % r); // From base*baseInv + r*rInv = 1 (mod r) return { base, shift, r, rInv, baseInv }; } /** * Convert the given number into its Montgomery form, according to the given Montgomery reduction context. * @param n Any number * @param ctx The Montgomery reduction context to reduce into * @returns The Montgomery form of `n` */ function montgomeryReduce(n: bigint, ctx: MontgomeryReductionContext): bigint { return (n << ctx.shift) % ctx.base; } // /** // * Converts the given number _out_ of Montgomery form, according to the given Montgomery reduction context. // * // * @param {bigint} n A number in Montgomery form // * @param {MontgomeryReductionContext} ctx The Montgomery reduction context to reduce out of // * @returns {bigint} The (no longer Montgomery-reduced) number whose Montgomery form was `n` // */ // function invMontgomeryReduce(n, ctx) { // return (n * ctx.rInv) % ctx.base // } /** * Squares a number in Montgomery form. * @param n A number in Montgomery form * @param ctx The Montgomery reduction context to square within * @returns The Montgomery-reduced square of `n` */ function montgomerySqr(n: bigint, ctx: MontgomeryReductionContext): bigint { return montgomeryMul(n, n, ctx); } /** * Multiplies two numbers in Montgomery form. * @param a A number in Montgomery form * @param b A number in Montgomery form * @param ctx The Montgomery reduction context to multiply within * @returns The Montgomery-reduced product of `a` and `b` */ function montgomeryMul(a: bigint, b: bigint, ctx: MontgomeryReductionContext): bigint { if (a === ZERO || b === ZERO) return ZERO; const rm1 = ctx.r - ONE; const unredProduct = a * b; const t = (((unredProduct & rm1) * ctx.baseInv) & rm1) * ctx.base; let product = (unredProduct - t) >> ctx.shift; if (product >= ctx.base) product -= ctx.base; else if (product < ZERO) product += ctx.base; return product; } /** * Calculates `n` to the power of `exp` in Montgomery form. * While `n` must be in Montgomery form, `exp` should not. * @param n A number in Montgomery form; the base of the exponentiation * @param exp Any number (_not_ in Montgomery form) * @param ctx The Montgomery reduction context to exponentiate within * @returns The Montgomery-reduced result of taking `n` to exponent `exp` */ function montgomeryPow(n: bigint, exp: bigint, ctx: MontgomeryReductionContext): bigint { // Exponentiation by squaring const expLen = BigInt(bimath.bitLength_bisection(exp)); let result = montgomeryReduce(ONE, ctx); for (let i = ZERO, x = n; i < expLen; ++i, x = montgomerySqr(x, ctx)) { if (exp & (ONE << i)) result = montgomeryMul(result, x, ctx); } return result; } /** A record class to hold the result of primality testing. */ // class PrimalityResult { // /** // * Constructs a result object from the given options // * @param {PrimalityResultOptions} options // */ // constructor({ probablePrime }) { // this.probablePrime = probablePrime // } // } /** * Ensures that all bases in the given array are valid for use in Miller-Rabin tests on the number `n = nSub + 1`. * A base is valid if it is an integer in the range [2, n-2]. * * If `bases` is null or undefined, it is ignored and null is returned. * If `bases` is an array of valid bases, they will be returned as a new array, all coerced to BigInts. * Otherwise, a RangeError will be thrown if any of the bases are outside the valid range, or a TypeError will * be thrown if `bases` is neither an array nor null/undefined. * * @param bases The array of bases to validate * @param nSub One less than the number being primality tested * @returns An array of BigInts provided all bases were valid, or null if the input was null */ function validateBases( bases: BigIntResolvable[] | null | undefined, nSub: bigint, ): bigint[] | null { if (!bases) return null; if (!Array.isArray(bases)) throw new TypeError(`invalid bases option (must be an array)`); // Ensure all bases are valid BigInts within [2, n-2] return bases.map((b) => { if (typeof b !== 'bigint') b = BigInt(b); if (!(b >= TWO) || !(b < nSub)) throw new RangeError(`invalid base (must be in the range [2, n-2]): ${b}`); return b; }); } /** * Computes (p1 * p2) mod modulus for numbers * @param p1 - base * @param p2 - base * @param modulus - modulus * @returns (p1 * p2) % modulus */ function modProductNumber(p1: number, p2: number, modulus: number): number { if (p1 > SAFE_SQRT || p2 > SAFE_SQRT) return Number((BigInt(p1) * BigInt(p2)) % BigInt(modulus)); else return (p1 * p2) % modulus; } /** * Computes (base ^ 2) mod modulus for numbers * @param base - base * @param modulus - modulus * @returns (base ** 2) % modulus */ function modSquaredNumber(base: number, modulus: number): number { if (base > SAFE_SQRT) return Number(BigInt(base) ** TWO % BigInt(modulus)); else return base ** 2 % modulus; } /** * Computes (base ^ exponent) mod modulus for numbers, avoiding recursion because of large exponent * @param base - base * @param exponent - exponent * @param modulus - modulus * @returns (base ** exponent) % modulus */ function modPowNumber(base: number, exponent: number, modulus: number): number { let accumulator = 1; while (exponent !== 0) { if (exponent % 2 === 0) { exponent = exponent / 2; base = modSquaredNumber(base, modulus); } else { exponent = exponent - 1; accumulator = modProductNumber(base, accumulator, modulus); } } return accumulator; } /** * Computes (base ^ exponent) mod modulus for BigInts, avoiding recursion because of large exponent * @param base - base * @param exponent - exponent * @param modulus - modulus * @returns (base ** exponent) % modulus */ function modPowBigint(base: bigint, exponent: bigint, modulus: bigint): bigint { let accumulator = ONE; while (exponent !== ZERO) { if (exponent % TWO === ZERO) { exponent = exponent / TWO; base = base ** TWO % modulus; } else { exponent = exponent - ONE; accumulator = (base * accumulator) % modulus; } } return accumulator; } /** * Runs Miller-Rabin primality tests on `n` which can be a number, string, or a bigint. * If `n` is a number/string smaller than Number.MAX_SAFE_INTEGER, then primalityTestNumber() is called. * If `n` is a bigint/string larger than Number.MAX_SAFE_INTEGER, then primalityTestBigint() is called. * @param n - A number or bigint integer to be tested for primality. * @param options - optional arguments passed along to primalityTestBigint() if necessary * @returns true if all the primality tests passed, false otherwise */ export function primalityTest(n: BigIntResolvable, options?: PrimalityTestOptions): boolean { if (typeof n === 'number') return primalityTestNumber(n); else if (typeof n === 'string') n = BigInt(n); if (n < MAX_SAFE_INTEGER_BIGINT) return primalityTestNumber(Number(n)); return primalityTestBigint(n, options); } /** * Runs deterministic Miller-Rabin primality test on number `n` * @param n - A number be tested for primality. * @returns true if all the primality tests passed, false otherwise */ function primalityTestNumber(n: number): boolean { let bases: number[]; // Handle some small special cases if (n < 2) return false; // n = 0 or 1 else if (n < 4) return true; // n = 2 or 3 else if (n % 2 === 0) return false; // Quick short-circuit for other even n else if (n < LIMIT_2) bases = INT_BASES.slice(0, 1); else if (n < LIMIT_2_3) bases = INT_BASES.slice(0, 2); else if (n < LIMIT_2_3_5) bases = INT_BASES.slice(0, 3); else if (n < LIMIT_2_3_5_7) bases = INT_BASES.slice(0, 4); else if (n < LIMIT_2_3_5_7_11) bases = INT_BASES.slice(0, 5); else if (n < LIMIT_2_3_5_7_11_13) bases = INT_BASES.slice(0, 6); else if (n < LIMIT_2_3_5_7_11_13_17) bases = INT_BASES.slice(0, 7); else bases = INT_BASES.slice(0, 9); const nSub = n - 1; let r = 0; let d = nSub; while (d % 2 === 0) { d = d / 2; r += 1; } for (let round = 0; round < bases.length; round++) { const base = bases[round]!; // Normal Miller-Rabin method => FAST for smaller numbers! const modularpower = modPowNumber(base, d, n); if (modularpower !== 1) { for (let i = 0, x = modularpower; x !== nSub; i += 1, x = modSquaredNumber(x, n)) { if (i === r - 1) return false; } } } return true; } /** * Runs probabilistic Miller-Rabin primality tests on bigint `n` using randomly-chosen bases, to determine with high probability whether `n` is a prime number. * * @param n A Bigint integer to be tested for primality. * @param options An object specifying the `numRounds` and/or `findDivisor` options. * - `numRounds` is a positive integer specifying the number of random bases to test against. * If none is provided, a reasonable number of rounds will be chosen automatically to balance speed and accuracy. * - `bases` is an array of integers to use as the bases for Miller-Rabin testing. If this option * is specified, the `numRounds` option will be ignored, and the maximum number of testing rounds will equal `bases.length` (one round * for each given base). Every base provided must lie within the range [2, n-2] (inclusive) or a RangeError will be thrown. * If `bases` is specified but is not an array, a TypeError will be thrown. * - `findDivisor` is a boolean specifying whether to calculate and return a divisor of `n` in certain cases where this is * easily possible (not guaranteed). Set this to false to avoid extra calculations if a divisor is not needed. Defaults to `true`. * - `useMontgomery` specifies whether the Montgomery reduction context for faster modular exponentiation should be used. * If left undefined, it is set automatically (recommended). * @returns true if all the primality tests passed, false otherwise */ function primalityTestBigint( n: bigint, options?: PrimalityTestOptions & { useMontgomery?: boolean }, ): boolean { // eslint-disable-next-line prefer-const let { numRounds, bases, findDivisor = true, useMontgomery } = options || {}; // Handle some small special cases if (n < TWO) return false; // n = 0 or 1 else if (n < FOUR) return true; // n = 2 or 3 else if (!(n & ONE)) return false; // Quick short-circuit for other even n else if (n < LIMIT_DETERMINISM) bases = BIGINT_BASES.slice(0, BIGINT_BASES.length); const nBits = bimath.bitLength_bisection(n); const nSub = n - ONE; // Represent n-1 as d * 2^r, with d odd const r = twoMultiplicity(nSub); // Multiplicity of prime factor 2 in the prime factorization of n-1 const d = nSub >> r; // The result of factoring out all powers of 2 from n-1 // Either use the user-provided list of bases to test against, or determine how many random bases to test const validBases = validateBases(bases, nSub); if (validBases !== null) numRounds = validBases.length; else if (!numRounds || numRounds < 1) { // If the number of testing rounds was not provided, pick a reasonable one based on the size of n // Larger n have a vanishingly small chance to be falsely labelled probable primes, so we can balance speed and accuracy accordingly numRounds = getAdaptiveNumRounds(nBits); } let baseIndex = 0; // Only relevant if the user specified a list of bases to use // if useMontgomery is not specified, it will be set according to the cutoff at LOWER_LIMIT_MONTGOMMERY if (useMontgomery === undefined) { if (n < LOWER_LIMIT_MONTGOMMERY) useMontgomery = false; else useMontgomery = true; } if (useMontgomery) { // Faster for larger numbers (like above 1e30) // Convert into a Montgomery reduction context for faster modular exponentiation const reductionContext = getReductionContext(n); const oneReduced = montgomeryReduce(ONE, reductionContext); // The number 1 in the reduction context const nSubReduced = montgomeryReduce(nSub, reductionContext); // The number n-1 in the reduction context for (let round = 0; round < numRounds; round++) { let base: bigint; if (validBases !== null) { // Use the next user-specified base base = validBases[baseIndex]!; baseIndex++; } else { // Select a random base to test do { base = BigInt('0b' + getRandomBitString(nBits)); } while (!(base >= TWO) || !(base < nSub)); // The base must lie within [2, n-2] } // Check whether the chosen base has any factors in common with n (if so, we can end early) if (findDivisor) { const gcd = bimath.GCD(n, base); if (gcd !== ONE) return false; // Found a factor of n, so no need for further primality tests } const baseReduced = montgomeryReduce(base, reductionContext); let x = montgomeryPow(baseReduced, d, reductionContext); if (x === oneReduced || x === nSubReduced) continue; // The test passed: base^d = +/-1 (mod n) // Perform the actual Miller-Rabin loop let i: bigint, y: bigint; for (i = ZERO; i < r; i++) { y = montgomerySqr(x, reductionContext); if (y === oneReduced) return false; // The test failed: base^(d*2^i) = 1 (mod n) and thus cannot be -1 for any i else if (y === nSubReduced) { // The test passed: base^(d*2^i) = -1 (mod n) for the current i // So n is a strong probable prime to this base (though n may still be composite) break; } x = y; } // No value of i satisfied base^(d*2^i) = +/-1 (mod n) // So this base is a witness to the guaranteed compositeness of n if (i === r) return false; } return true; } else { // Use Miller-Robin method (faster for smaller numbers, like below 1e30) for (let round = 0; round < numRounds; round++) { let base: bigint; if (validBases !== null) { // Use the next user-specified base base = validBases[baseIndex]!; baseIndex++; } else { // Select a random base to test do { base = BigInt('0b' + getRandomBitString(nBits)); } while (!(base >= TWO) || !(base < nSub)); // The base must lie within [2, n-2] } // Check whether the chosen base has any factors in common with n (if so, we can end early) if (findDivisor) { const gcd = bimath.GCD(n, base); if (gcd !== ONE) return false; // Found a factor of n, so no need for further primality tests } // normal Miller-Rabin const modularpower = modPowBigint(base, d, n); if (modularpower !== ONE) { for (let i = ZERO, x = modularpower; x !== nSub; i += ONE, x = x ** TWO % n) { if (i === r - ONE) return false; } } } return true; } } /** * Determines an appropriate number of Miller-Rabin testing rounds to perform based on the size of the * input number being tested. Larger numbers generally require fewer rounds to maintain a given level * of accuracy. * @param inputBits The number of bits in the input number. * @returns How many rounds of testing to perform. */ function getAdaptiveNumRounds(inputBits: number): number { if (inputBits > 1000) return 2; else if (inputBits > 500) return 3; else if (inputBits > 250) return 4; else if (inputBits > 150) return 5; else return 6; } //////////////////////////////////////////////////////////////////////////////////////////////////////// // Everything below this line is only for testing purposes //////////////////////////////////////////////////////////////////////////////////////////////////////// // Get the mathjs module via "npm install mathjs" /* let mathjs = require('mathjs'); let prime1 = 11; let prime2 = BigInt("34260522533194312141699016768017376046579370858274371908475849"); let prime3 = BigInt("24609615439855545007865829059894825853255339682863740988001"); let prime4 = BigInt("10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000267"); let largecomposite = prime2*prime3; function run_basic_tests(){ console.log("prime1 is prime? " + primalityTest(prime1)); console.log("prime2 is prime? " + primalityTest(prime2)); console.log("prime3 is prime? " + primalityTest(prime3)); console.log("prime4 is prime? " + primalityTest(prime4)); console.log("Product of prime2 and prime3 is prime? " + primalityTest(largecomposite)); console.log("Stupidly large composite is prime? " + primalityTest(BigInt("10") ** BigInt("1000") + BigInt("13"))); } run_basic_tests(); function test_for_errors(){ let erroramount = 0; let N_TESTS = 1000; for (let i = 0; i< N_TESTS; i++){ if (primalityTest(largecomposite)) erroramount++; } console.log("Consistency test. Number of false positives after " + N_TESTS + " tests: " + erroramount); } test_for_errors(largecomposite); function speed_test(){ let N_START = 10n**10n; let N_STEPS = 10n**5n; let timer = Date.now(); for (let i = N_START; i< N_START + N_STEPS; i+= 1n){ primalityTest(i, {useMontgomery: false}); } console.log(`Speed test. Time ellapsed: ${(Date.now()-timer)/1000}s`); } speed_test(); function mathjs_speed_test(){ let N_START = 10**10; let N_STEPS = 10**5; let timer = Date.now(); for (let i = N_START; i< N_START + N_STEPS; i+= 1){ mathjs.isPrime(i); } console.log(`Mathjs speed test. Time ellapsed: ${(Date.now()-timer)/1000}s`); } mathjs_speed_test(); // Test this program for correctness with mathjs library function test_program(){ let i = 10**12 + 1; while(true) { let isprime_thisprogram = primalityTest(i); let isprime_mathjs = mathjs.isPrime(i); if (isprime_thisprogram != isprime_mathjs){ console.log(`Program fails for number ${i}`); console.log(isprime_thisprogram); console.log(isprime_mathjs); break; } else if(i % 10000 == 0){ console.log(`Numbers up to ${i} tested`); } i += 1; } } test_program(); */ ================================================ FILE: src/shared/util/jsutil.ts ================================================ // src/shared/util/jsutil.ts /** * This scripts contains utility methods for working with javascript objects. */ import bimath from './math/bimath.js'; /** * Deep copies an entire object, no matter how deep its nested. * No properties will contain references to the source object. * Use this instead of structuredClone() because of browser support, * or when that throws an error due to functions contained within the src. * * SLOW. Avoid using for very massive objects. */ function deepCopyObject(src: T): T { if (typeof src !== 'object' || src === null) return src; // Check for Maps if (src instanceof Map) { // Create a new Map instance const copy = new Map(); // Iterate over the original map's entries for (const [key, value] of src.entries()) { // Deep copy both the key and the value before setting them in the new map copy.set(deepCopyObject(key), deepCopyObject(value)); } return copy as T; // Return the new Map with deep copied entries } // Check for Sets if (src instanceof Set) { // Create a new Set instance const copy = new Set(); // Iterate over the original set's values for (const value of src) { // Deep copy the value before adding it to the new set copy.add(deepCopyObject(value)); } return copy as T; // Return the new Set with deep copied values } // Check for TypedArrays (which are ArrayBuffer views and have slice) if (ArrayBuffer.isView(src) && typeof (src as any).slice === 'function') { return (src as any).slice() as T; // Use slice for TypedArray copy } // Handle remaining arrays and objects const copy: any = Array.isArray(src) ? [] : {}; // Create an empty array or object for (const key in src) { const value = src[key]; copy[key] = deepCopyObject(value); // Recursively copy each property } return copy as T; // Return the copied object } /** * Deep copies a Float32Array. */ function copyFloat32Array(src: Float32Array): Float32Array { if (!src || !(src instanceof Float32Array)) { throw new Error('Invalid input: must be a Float32Array'); } const copy = new Float32Array(src.length); for (let i = 0; i < src.length; i++) { copy[i]! = src[i]!; } return copy; } /** * Searches an organized array and returns an object telling * you the index the element could be added at for the array to remain * organized, and whether the element was already found in the array. * @param sortedArray - The array sorted in ascending order * @param value - The value to find in the array. * @returns An object telling you whether the value was found, and the index of that value, or where it can be inserted to remain organized. */ function binarySearch(sortedArray: number[], value: number): { found: boolean; index: number } { let left = 0; let right = sortedArray.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); const midValue = sortedArray[mid]!; if (value < midValue) right = mid - 1; else if (value > midValue) left = mid + 1; else return { found: true, index: mid }; } // The left is the correct index to insert at, while retaining order! return { found: false, index: left }; } /** * Uses binary search to quickly find and insert the given number in the * organized array. * * MUST NOT ALREADY CONTAIN THE VALUE!! * @param sortedArray - The array to search, which must be sorted in ascending order. * @param value - The value add in the correct place, retaining order. * @returns The new array with the sorted element. */ function addElementToOrganizedArray(sortedArray: number[], value: number): number[] { const { found, index } = binarySearch(sortedArray, value); if (found) throw Error( `Cannot add element to sorted array when it already contains the value! ${value}. List: ${JSON.stringify(sortedArray)}`, ); sortedArray.splice(index, 0, value); return sortedArray; } /** * Calculates the index in the given organized array at which you could insert * the point and the array would still be organized. * @param sortedArray - An array of numbers organized in ascending order. * @param point - The point in the array to find the index for. * @returns The index */ function findIndexOfPointInOrganizedArray(sortedArray: number[], point: number): number { return binarySearch(sortedArray, point).index; } /** * Copies the properties from one object to another, * without overwriting the existing properties on the destination object, * UNLESS the destination object has a matching property name. * @param objSrc - The source object * @param objDest - The destination object */ function copyPropertiesToObject(objSrc: Record, objDest: Record): void { for (const [key, value] of Object.entries(objSrc)) { objDest[key] = value; } } /** * O(1) method of checking if an object/dict is empty * I think??? I may be wrong. I think before the first iteration of * a for-in loop the program still has to calculate the keys... */ function isEmpty(obj: object): boolean { for (const prop in obj) { if (Object.prototype.hasOwnProperty.call(obj, prop)) return false; } return true; } /** * Returns a new object with the keys being the values of the provided object, and the values being the keys. * THE VALUES WILL ALWAYS BE STRINGS. This is because the keys of an object are always strings. */ function invertObj(obj: Record): Record { const inv: Record = {}; for (const key in obj) { inv[obj[key]!] = key; } return inv; } /** * Estimates the size, in memory, of ANY object, no matter how deep it's nested, * and returns that number in a human-readable string. * * This takes into account added overhead from each object/array created, * as those have extra prototype methods, etc, adding more memory. It also * attempts to correctly estimate the size of TypedArrays, ArrayBuffers, Maps, and Sets. * * @author Gemini 2.5 Pro */ function estimateMemorySizeOf(obj: any): string { const visited = new Set(); // Use a Set to track visited objects to handle cycles and prevent double-counting. // --- Helper Functions --- function roughSizeOfObject(value: any): number { let bytes = 0; // --- Primitive types --- if (typeof value === 'boolean') bytes = 4; else if (typeof value === 'string') bytes = value.length * 2; // Each char is 2 bytes in JS strings (UTF-16) else if (typeof value === 'number') bytes = 8; // 64-bit float else if (typeof value === 'symbol') bytes = (value.description?.length ?? 0) * 2 + 8; // Description + internal overhead else if (typeof value === 'bigint') bytes = bimath.estimateBigIntSize(value); // Precise BigInt estimator else if (value === null || typeof value === 'undefined') bytes = 0; // Very small else if (typeof value === 'function') bytes = value.toString().length * 2 + 100; // Very rough guess // --- Object types --- else if (typeof value === 'object') { // --- Handle circular references and already visited objects --- if (visited.has(value)) return 0; visited.add(value); // --- Specific object types --- // ArrayBuffer: The raw data store if (value instanceof ArrayBuffer) { bytes = value.byteLength + 64; // byteLength + object overhead } // TypedArray views (Int8Array, Float32Array, etc.) else if (ArrayBuffer.isView(value)) { bytes = value.byteLength + 64; // Data size + view object overhead // Ensure the underlying buffer is also marked as visited if not already if (value.buffer && !visited.has(value.buffer)) { visited.add(value.buffer); // Optionally add buffer overhead ONCE if buffer itself wasn't visited // bytes += 64; // Depends on desired accuracy for shared buffer overhead. } } // Date objects else if (value instanceof Date) bytes = 8 + 40; // Internal number + object overhead // RegExp objects else if (value instanceof RegExp) bytes = value.source.length * 2 + 40; // Source string + object overhead // Map objects else if (value instanceof Map) { bytes = 64; // Overhead for the Map object itself for (const [key, val] of value.entries()) { bytes += roughSizeOfObject(key); bytes += roughSizeOfObject(val); bytes += 16; // Overhead per entry (approx) } } // Set objects else if (value instanceof Set) { bytes = 64; // Overhead for the Set object itself for (const val of value.values()) { bytes += roughSizeOfObject(val); bytes += 16; // Overhead per entry (approx) } } // --- Generic objects and arrays --- else { const isArray = Array.isArray(value); // Overhead for object/array itself (pointers, length, prototype) bytes = isArray ? 40 : 40; for (const key in value) { // Only count own properties if (!Object.hasOwnProperty.call(value, key)) continue; // Size of the key (property name or array index) if (!isArray || isNaN(parseInt(key, 10))) { bytes += key.length * 2; // Key string size } // Reference pointer size (approx) bytes += 8; // Assumed pointer/reference overhead // Size of the value (recursive call) bytes += roughSizeOfObject(value[key]); } } } return bytes; } // Turns the number into a human-readable string function formatByteSize(bytes: number): string { if (bytes < 1024) return bytes + ' bytes'; else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } // --- Main execution --- const totalBytes = roughSizeOfObject(obj); visited.clear(); // Clean up the visited set return formatByteSize(totalBytes); } /** * A "replacer" for JSON.stringify()'ing with custom behavior, * allowing us to stringify special objects like BigInts, Maps and TypedArrays. * Use {@link parseReviver} to parse back. */ function stringifyReplacer(key: string, value: any): any { // Stringify BigInts if (typeof value === 'bigint') return { $$type: 'BigInt', value: value.toString(), // Convert BigInt to a string }; // Stringify Maps if (value instanceof Map) return { $$type: 'Map', value: [...value], }; // Stringify Sets if (value instanceof Set) return { $$type: 'Set', value: [...value], // Convert Set elements to an array }; // Stringify TypedArrays for (const [name, type] of Object.entries(FixedArrayInfo)) { if (value instanceof type) return { $$type: name, value: [...value], }; } return value; } /** TypedArray constructors and their names. */ const FixedArrayInfo = { Float32Array: Float32Array, Float64Array: Float64Array, Int8Array: Int8Array, Int16Array: Int16Array, Int32Array: Int32Array, Uint8Array: Uint8Array, Uint16Array: Uint16Array, Uint32Array: Uint32Array, } as const; /** Type representing any of the TypedArray constructor types listed in FixedArrayInfo. */ type FixedArrayConstructor = (typeof FixedArrayInfo)[keyof typeof FixedArrayInfo]; /** * A "reviver" for JSON.parse()'ing that will convert back from the custom stringified format to the original objects. * This allows us to parse back the special objects like Maps and TypedArrays that were stringified using {@link stringifyReplacer}. */ function parseReviver(key: string, value: any): any { if (typeof value === 'object' && value !== null) { if (value.$$type === 'BigInt') return BigInt(value.value); // Convert string back to BigInt if (value.$$type === 'Map') return new Map(value.value); // value.value should be an array of [key, value] pairs if (value.$$type === 'Set') return new Set(value.value); // value.value should be an array of elements if (value.$$type in FixedArrayInfo) { const constructor: FixedArrayConstructor = FixedArrayInfo[value.$$type as keyof typeof FixedArrayInfo]; // Get the constructor return new constructor(value.value); // value.value should be an array of numbers } } return value; } /** * Ensures any type of object is JSON stringified. Strings are left unchanged. * If there's a provided error message, it will log any ocurred error. * @param input - The input to stringify. * @param [errorMessage] - If specified, then this message will be printed if an error occurs. * @returns - The JSON stringified input or the original string if input was a string. Or, if an error ocurred, 'Error: Input could not be JSON stringified'. */ function ensureJSONString(input: any, errorMessage?: string): string { if (typeof input === 'string') return input; try { return JSON.stringify(input, stringifyReplacer); } catch (error) { // Handle cases where input cannot be stringified if (errorMessage) { // Print the error... const errText = `${errorMessage}\n${(error as Error).stack}`; console.log(errText); } return 'Error: Input could not be JSON stringified'; } } export default { binarySearch, deepCopyObject, copyFloat32Array, addElementToOrganizedArray, findIndexOfPointInOrganizedArray, copyPropertiesToObject, isEmpty, invertObj, estimateMemorySizeOf, stringifyReplacer, parseReviver, ensureJSONString, }; ================================================ FILE: src/shared/util/math/bimath.ts ================================================ // src/shared/util/math/bimath.ts /** * This module contains complex math functions * for working with bigints. */ // Constants ========================================================= const ZERO: bigint = 0n; const ONE: bigint = 1n; // Mathematical Operations =========================================== /** * Calculates the absolute value of a bigint * @param bigint - The BigInt * @returns The absolute value */ function abs(bigint: bigint): bigint { return bigint < ZERO ? -bigint : bigint; } /** * Estimates the number of base-10 digits in a bigint, excluding the sign. * Accurate most of the time. 100% of the time within 1 digit. * @param bigint - The BigInt to count digits for * @returns The number of base-10 digits (excluding sign) */ function countDigits(bigint: bigint): number { // Make it positive for digit counting const abs_bigint = abs(bigint); // Use bitLength for efficiency const bitLen = bitLength_bisection(abs_bigint); // Convert bit length to decimal digits: log10(2^bitLen) = bitLen * log10(2) // Use Math.floor and add 1 for high accuracy, sacrificing exactness. return Math.floor(bitLen * Math.log10(2)) + 1; } // Big Length Algorithms ============================================================= // Global state for the bisection algorithm so it's not re-computed every call const testersCoeff: number[] = []; const testersBigCoeff: bigint[] = []; const testers: bigint[] = []; let testersN = 0; /** * Calculates the bit length of a bigint using a highly optimized dynamic bisection algorithm. * Complexity O(log n), where n is the number of bits. * Algorithm pulled from https://stackoverflow.com/a/76616288 */ function bitLength_bisection(x: bigint): number { if (x === ZERO) return 0; if (x < ZERO) x = -x; let k = 0; while (true) { if (testersN === k) { testersCoeff.push(32 << testersN); testersBigCoeff.push(BigInt(testersCoeff[testersN]!)); testers.push(1n << testersBigCoeff[testersN]!); testersN++; } if (x < testers[k]!) break; k++; } if (!k) return 32 - Math.clz32(Number(x)); // Determine length by bisection k--; let i = testersCoeff[k]!; let a = x >> testersBigCoeff[k]!; while (k--) { const b = a >> testersBigCoeff[k]!; if (b) { i += testersCoeff[k]!; a = b; } } return i + 32 - Math.clz32(Number(a)); } /** * Estimate the memory footprint of a BigInt in bytes, assuming a 64‑bit JavaScript engine * (e.g. V8 in Chrome/Node.js or JavaScriptCore in Safari). * * On a 64‑bit build, each BigInt is represented as a small heap object: * - Two pointer‑sized fields (object header) * - A sequence of 64‑bit “words” holding the integer’s bits, rounded up * * Total size = headerBytes + dataBytes * @param bi - The BigInt to measure. * @returns The estimated number of bytes occupied by the bigint in memory. */ function estimateBigIntSize(bi: bigint): number { // Compute bit length (number of binary digits) const bitLen = bitLength_bisection(bi); // In a 64‑bit engine, pointerSize = 8 bytes const pointerSize = 8; // Two pointers for the BigInt object header const headerBytes = pointerSize * 2; // Number of 64‑bit chunks needed to store the bits const chunkCount = Math.ceil(bitLen / (pointerSize * 8)); const dataBytes = pointerSize * chunkCount; return headerBytes + dataBytes; } // /** // * Performs integer division of two BigInts, rounding up towards positive infinity. // * @param a - The dividend. // * @param b - The divisor (must be a positive BigInt). // * @returns The result of the division, rounded up. // */ // function roundUpDiv(a: bigint, b: bigint): bigint { // return a / b + ((a % b) * b > ZERO ? ONE : ZERO); // } /** * Computes the positive modulus of two BigInts. * @param a - The dividend. * @param b - The divisor (must be a positive BigInt). * @returns The positive remainder of the division as a BigInt. */ function posMod(a: bigint, b: bigint): bigint { return ((a % b) + b) % b; } /** Finds the smaller of two BigInts. */ function min(a: bigint, b: bigint): bigint { return a < b ? a : b; } /** Finds the larger of two BigInts. */ function max(a: bigint, b: bigint): bigint { return a > b ? a : b; } /** * Compares two BigInts. * @param a The first BigInt. * @param b The second BigInt. * @returns -1 if a < b, 0 if a === b, and 1 if a > b. */ function compare(a: bigint, b: bigint): -1 | 0 | 1 { return a < b ? -1 : a > b ? 1 : 0; } /** Clamps a BigInt value between an inclusive minimum and maximum. */ function clamp(value: bigint, min: bigint, max: bigint): bigint { return value < min ? min : value > max ? max : value; } // Number-Theoretic Algorithms ----------------------------------------------------------------------------------------------- /** * Calculates the gcd of two bigints using the binary GCD (or Stein's) algorithm. * This is faster than the Euclidean algorithm, especially for very large numbers. */ function GCD(a: bigint, b: bigint): bigint { // We must work with positive numbers a = abs(a); b = abs(b); if (a === b) return a; if (a === ZERO) return b; if (b === ZERO) return a; // Strip out any shared factors of two beforehand (to be re-added at the end) let sharedTwoFactors = ZERO; while (!((a & ONE) | (b & ONE))) { sharedTwoFactors++; a >>= ONE; b >>= ONE; } while (a !== b && b > ONE) { // Any remaining factors of two in either number are not important to the gcd and can be shifted away while (!(a & ONE)) a >>= ONE; while (!(b & ONE)) b >>= ONE; // Standard Euclidean algorithm, maintaining a > b and avoiding division if (b > a) [a, b] = [b, a]; else if (a === b) break; a -= b; } // b is the gcd, after re-applying the shared factors of 2 removed earlier return b << sharedTwoFactors; } // /** // * Calculates the least common multiple (LCM) between all BigInts in an array. // * @param array An array of BigInts. // * @returns The LCM of the numbers in the array. // */ // function LCM(array: bigint[]): bigint { // if (array.length === 0) // throw new Error('Array must contain at least one number to calculate the LCM.'); // let answer: bigint = array[0]!; // for (let i = 1; i < array.length; i++) { // const currentNumber = array[i]!; // if (currentNumber === ZERO || answer === ZERO) answer = ZERO; // else answer = abs(currentNumber * answer) / GCD(currentNumber, answer); // } // return answer; // } // Displat Formatting ------------------------------------------------------------------------- /** * Formats a bigint in scientific notation with the given number of significant figures. * e.g., formatBigIntExponential(123456789n, 3) => "1.23e8" */ function formatBigIntExponential(bigint: bigint, precision: number): string { const isNegative = bigint < 0n; const absString: string = abs(bigint).toString(); const exponent: number = absString.length - 1; const mantissaDigits: string = absString.substring(0, precision); let mantissa: string; if (mantissaDigits.length > 1) { mantissa = mantissaDigits[0] + '.' + mantissaDigits.substring(1); } else { mantissa = mantissaDigits; } return `${isNegative ? '-' : ''}${mantissa}e${exponent}`; } // Exports ============================================================ export default { // Mathematical Operations abs, countDigits, bitLength_bisection, // Big Length Algorithms estimateBigIntSize, // roundUpDiv, posMod, min, max, compare, clamp, // Number-Theoretic Algorithms GCD, // Display Formatting formatBigIntExponential, }; ================================================ FILE: src/shared/util/math/bounds.ts ================================================ // src/shared/util/math/bounds.ts /** * This script contains methods for constructing and operating on bounding boxes. */ import type { BDCoords, Coords, DoubleCoords } from '../../chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bimath from './bimath.js'; // Types ------------------------------------------------------------------------- /** A arbitrarily large rectangle object with properties for the coordinates of its sides. */ interface BoundingBox { /** The x-coordinate of the left side of the box. */ left: bigint; /** The x-coordinate of the right side of the box. */ right: bigint; /** The y-coordinate of the bottom side of the box. */ bottom: bigint; /** The y-coordinate of the top side of the box. */ top: bigint; } /** * A {@link BoundingBox} that may be unbounded in one or more directions. * `null` is used as a placeholder for -infinity or infinity. */ interface UnboundedRectangle { /** The x-coordinate of the left side of the box. */ left: bigint | null; /** The x-coordinate of the right side of the box. */ right: bigint | null; /** The y-coordinate of the bottom side of the box. */ bottom: bigint | null; /** The y-coordinate of the top side of the box. */ top: bigint | null; } /** A rectangle object with properties for the coordinates of its sides, but using BigDecimal * instead of bigints for arbitrary deciaml precision. */ interface BoundingBoxBD { /** The x-coordinate of the left side of the box. */ left: BigDecimal; /** The x-coordinate of the right side of the box. */ right: BigDecimal; /** The y-coordinate of the bottom side of the box. */ bottom: BigDecimal; /** The y-coordinate of the top side of the box. */ top: BigDecimal; } /** A rectangle object with properties for the coordinates of its sides, but using numbers instead of bigints. */ interface DoubleBoundingBox { /** The x-coordinate of the left side of the box. */ left: number; /** The x-coordinate of the right side of the box. */ right: number; /** The y-coordinate of the bottom side of the box. */ bottom: number; /** The y-coordinate of the top side of the box. */ top: number; } // Constants ----------------------------------------- const TWO = bd.fromNumber(2.0); // Construction -------------------------------------------------------- /** * Calculates the minimum bounding box that contains all the provided coordinates. */ function getBoxFromCoordsList(coordsList: Coords[]): BoundingBox { // Initialize the bounding box using the first coordinate const firstPiece = coordsList[0]!; const box: BoundingBox = { left: firstPiece[0], right: firstPiece[0], bottom: firstPiece[1], top: firstPiece[1], }; // Expands the bounding box to include every coordinate for (const coord of coordsList) { expandBoxToContainSquare(box, coord); } return box; } function castDoubleBoundingBoxToBigDecimal(box: DoubleBoundingBox): BoundingBoxBD { return { left: bd.fromNumber(box.left), right: bd.fromNumber(box.right), bottom: bd.fromNumber(box.bottom), top: bd.fromNumber(box.top), }; } function castBoundingBoxToBigDecimal(box: BoundingBox): BoundingBoxBD { return { left: bd.fromBigInt(box.left), right: bd.fromBigInt(box.right), bottom: bd.fromBigInt(box.bottom), top: bd.fromBigInt(box.top), }; } // function castBDBoundingBoxToBigint(box: BoundingBoxBD): BoundingBox { // return { // left: bd.toBigInt(box.left), // right: bd.toBigInt(box.right), // bottom: bd.toBigInt(box.bottom), // top: bd.toBigInt(box.top) // }; // } /** * Expands the bounding box to include the provided coordinates, if it doesn't already. * DESTRUCTIVE. Modifies the original box. */ function expandBoxToContainSquare(box: BoundingBox, coord: Coords): void { if (coord[0] < box.left) box.left = coord[0]; else if (coord[0] > box.right) box.right = coord[0]; if (coord[1] < box.bottom) box.bottom = coord[1]; else if (coord[1] > box.top) box.top = coord[1]; } function expandBDBoxToContainSquare(box: BoundingBoxBD, coord: BDCoords): void { if (bd.compare(coord[0], box.left) < 0) box.left = coord[0]; else if (bd.compare(coord[0], box.right) > 0) box.right = coord[0]; if (bd.compare(coord[1], box.bottom) < 0) box.bottom = coord[1]; else if (bd.compare(coord[1], box.top) > 0) box.top = coord[1]; } /** * Returns the mimimum bounding box that contains both of the provided boxes. */ function mergeBoundingBoxBDs(box1: BoundingBoxBD, box2: BoundingBoxBD): BoundingBoxBD { return { left: bd.min(box1.left, box2.left), right: bd.max(box1.right, box2.right), bottom: bd.min(box1.bottom, box2.bottom), top: bd.max(box1.top, box2.top), }; } /** * Returns the mimimum bounding box that contains both of the provided boxes. */ function mergeBoundingBoxDoubles(box1: BoundingBox, box2: BoundingBox): BoundingBox { return { left: bimath.min(box1.left, box2.left), right: bimath.max(box1.right, box2.right), bottom: bimath.min(box1.bottom, box2.bottom), top: bimath.max(box1.top, box2.top), }; } /** * Translates a bounding box by the given coordinates. * Non-mutating. */ function translateBoundingBox(box: BoundingBox, translation: Coords): BoundingBox { return { left: box.left + translation[0], right: box.right + translation[0], bottom: box.bottom + translation[1], top: box.top + translation[1], }; } /** * Returns a new {@link DoubleBoundingBox} with each edge clamped so it * does not extend beyond the corresponding edge of `clampTo`. * Non-mutating. */ function clampDoubleBoundingBox( box: DoubleBoundingBox, clampTo: DoubleBoundingBox, ): DoubleBoundingBox { return { left: Math.max(box.left, clampTo.left), right: Math.min(box.right, clampTo.right), bottom: Math.max(box.bottom, clampTo.bottom), top: Math.min(box.top, clampTo.top), }; } // Operations ----------------------------------------------------------------------- /** * Determines if one bounding box (`innerBox`) is entirely contained within another bounding box (`outerBox`). * No overlaps allowed, but edges can touch. */ function boxContainsBox( outerBox: BoundingBox | UnboundedRectangle, innerBox: BoundingBox, ): boolean { if (outerBox.left !== null && innerBox.left < outerBox.left) return false; if (outerBox.right !== null && innerBox.right > outerBox.right) return false; if (outerBox.bottom !== null && innerBox.bottom < outerBox.bottom) return false; if (outerBox.top !== null && innerBox.top > outerBox.top) return false; return true; } /** * Determines if one bounding box (`innerBox`) is entirely contained within another bounding box (`outerBox`). * No overlaps allowed, but edges can touch. */ function boxContainsBoxBD(outerBox: BoundingBoxBD, innerBox: BoundingBoxBD): boolean { if (bd.compare(innerBox.left, outerBox.left) < 0) return false; if (bd.compare(innerBox.right, outerBox.right) > 0) return false; if (bd.compare(innerBox.bottom, outerBox.bottom) < 0) return false; if (bd.compare(innerBox.top, outerBox.top) > 0) return false; return true; } /** * Determines if two bounding boxes have zero overlap. * They are allowed to touch sides without overlapping. */ function areBoxesDisjoint(box1: DoubleBoundingBox, box2: DoubleBoundingBox): boolean { if (box1.right <= box2.left) return true; if (box1.left >= box2.right) return true; if (box1.top <= box2.bottom) return true; if (box1.bottom >= box2.top) return true; return false; } /** * Returns true if the provided box contains the square coordinate. */ function boxContainsSquare(box: BoundingBox | UnboundedRectangle, square: Coords): boolean { if (box.left !== null && square[0] < box.left) return false; if (box.right !== null && square[0] > box.right) return false; if (box.bottom !== null && square[1] < box.bottom) return false; if (box.top !== null && square[1] > box.top) return false; return true; } /** * Returns true if the provided bigdecimal box contains the square coordinate. */ function boxContainsSquareBD(box: BoundingBoxBD, square: BDCoords): boolean { if (bd.compare(square[0], box.left) < 0) return false; if (bd.compare(square[0], box.right) > 0) return false; if (bd.compare(square[1], box.bottom) < 0) return false; if (bd.compare(square[1], box.top) > 0) return false; return true; } /** * Returns true if the provided double box contains the square coordinate. */ function boxContainsSquareDouble(box: DoubleBoundingBox, square: DoubleCoords): boolean { if (square[0] < box.left) return false; if (square[0] > box.right) return false; if (square[1] < box.bottom) return false; if (square[1] > box.top) return false; return true; } /** * Calculates the center of a bounding box. */ function calcCenterOfBoundingBox(box: BoundingBoxBD): BDCoords { const xSum = bd.add(box.left, box.right); const ySum = bd.add(box.bottom, box.top); return [bd.divide(xSum, TWO), bd.divide(ySum, TWO)]; } // Debugging -------------------------------------------------------- /** [DEBUG] Prints a box of BigDecimal floating point edges, with their exact representations. SLOW. */ function printBDBox(box: BoundingBoxBD): void { // console.log(`Box: left=${bd.toNumber(box.left)}, right=${bd.toNumber(box.right)}, bottom=${bd.toNumber(box.bottom)}, top=${bd.toNumber(box.top)}`); console.log( `Box: left=${bd.toExactString(box.left)}, right=${bd.toExactString(box.right)}, bottom=${bd.toExactString(box.bottom)}, top=${bd.toExactString(box.top)}`, ); } // Exports ---------------------------------------------------------- export default { // Construction getBoxFromCoordsList, castDoubleBoundingBoxToBigDecimal, castBoundingBoxToBigDecimal, // castBDBoundingBoxToBigint, expandBoxToContainSquare, expandBDBoxToContainSquare, mergeBoundingBoxBDs, mergeBoundingBoxDoubles, translateBoundingBox, clampDoubleBoundingBox, // Operations boxContainsBox, boxContainsBoxBD, areBoxesDisjoint, boxContainsSquare, boxContainsSquareBD, boxContainsSquareDouble, calcCenterOfBoundingBox, // Debugging printBDBox, }; export type { BoundingBox, UnboundedRectangle, BoundingBoxBD, DoubleBoundingBox }; ================================================ FILE: src/shared/util/math/geometry.ts ================================================ // src/shared/util/math/geometry.ts /** * This script contains methods for performing geometric calculations, * such as calculating intersections, and distances. */ import type { BoundingBox, BoundingBoxBD } from './bounds.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bounds from './bounds.js'; import bimath from './bimath.js'; import bdcoords from '../../chess/util/bdcoords.js'; import coordutil, { BDCoords, Coords } from '../../chess/util/coordutil.js'; import vectors, { LineCoefficients, LineCoefficientsBD, Ray, Vec2 } from './vectors.js'; // ================================ Type Definitions ================================= /** The form of the intersection points returned by {@link findLineBoxIntersectionsBD}. */ type IntersectionPoint = { /** The actual intersection point */ coords: BDCoords; /** * True if the dot product of the direction vector and the vector to the intersection point is positive. * This tells us if the intersection is in the direction of the vector, or the opposite way. */ positiveDotProduct: boolean; }; /** The simplest form of a ray. */ type BaseRay = { start: Coords; vector: Vec2 }; // ======================================= Constants ======================================= const ZERO = bd.fromBigInt(0n); // ============================== Fundamental Intersection Functions ============================== /** * Finds the intersection of two lines in general form. * [x, y] or undefined if there is no intersection (or infinite intersections). * * PERFECT INTEGER PRECISION. If the intersection lies on a perfect integer point, * there will be no floating point innaccuracies. * If however the intersection lies on a non-integer point, and the BigDecimal * can't represent it perfectly in binary, there will be floating point innaccuracy. */ function calcIntersectionPointOfLines( A1: bigint, B1: bigint, C1: bigint, A2: bigint, B2: bigint, C2: bigint, ): BDCoords | undefined { const determinant = A1 * B2 - A2 * B1; if (determinant === 0n) return undefined; // Lines are parallel or identical const determinantBD = bd.fromBigInt(determinant); function determineAxis(dividend: bigint): BigDecimal { const dividendBD = bd.fromBigInt(dividend); return bd.divide(dividendBD, determinantBD); } // Calculate the intersection point const x = determineAxis(C2 * B1 - C1 * B2); const y = determineAxis(A2 * C1 - A1 * C2); return [x, y]; } /** * {@link calcIntersectionPointOfLines}, but for BigDecimal lines (requiring decimal precision). */ function calcIntersectionPointOfLinesBD( A1: BigDecimal, B1: BigDecimal, C1: BigDecimal, A2: BigDecimal, B2: BigDecimal, C2: BigDecimal, ): BDCoords | undefined { const determinant = bd.subtract(bd.multiply(A1, B2), bd.multiply(A2, B1)); if (bd.areEqual(determinant, ZERO)) return undefined; // Lines are parallel or identical function determineAxis(dividend: BigDecimal): BigDecimal { return bd.divide(dividend, determinant); } // Calculate the intersection point const x = determineAxis(bd.subtract(bd.multiply(C2, B1), bd.multiply(C1, B2))); const y = determineAxis(bd.subtract(bd.multiply(A2, C1), bd.multiply(A1, C2))); return [x, y]; } /** * Calculates the intersection point of a NON-VERTICAL line with a vertical one! */ function intersectLineAndVerticalLine(A1: bigint, B1: bigint, C1: bigint, x: bigint): BDCoords { // The known coordinate is x, its coefficient is A1. // We are solving for y, its coefficient is B1. const intersectionY = solveForUnknownAxis(A1, B1, C1, x); const intersectionX = bd.fromBigInt(x); return [intersectionX, intersectionY]; } /** * {@link intersectLineAndVerticalLine}, but for BigDecimal coefficients and known value. */ function intersectLineAndVerticalLineBD( A1: BigDecimal, B1: BigDecimal, C1: BigDecimal, x: BigDecimal, ): BDCoords { // The known coordinate is x, its coefficient is A1. // We are solving for y, its coefficient is B1. const intersectionY = solveForUnknownAxisBD(A1, B1, C1, x); const intersectionX = x; return [intersectionX, intersectionY]; } /** * Calculates the intersection point of a NON-HORIZONTAL line with a horizontal one! * */ function intersectLineAndHorizontalLine(A1: bigint, B1: bigint, C1: bigint, y: bigint): BDCoords { // The known coordinate is y, its coefficient is B1. // We are solving for x, its coefficient is A1. const intersectionX = solveForUnknownAxis(B1, A1, C1, y); const intersectionY = bd.fromBigInt(y); return [intersectionX, intersectionY]; } /** * {@link intersectLineAndHorizontalLine}, but for BigDecimal coefficients and known value. */ function intersectLineAndHorizontalLineBD( A1: BigDecimal, B1: BigDecimal, C1: BigDecimal, y: BigDecimal, ): BDCoords { // The known coordinate is y, its coefficient is B1. // We are solving for x, its coefficient is A1. const intersectionX = solveForUnknownAxisBD(B1, A1, C1, y); const intersectionY = y; return [intersectionX, intersectionY]; } /** * [Helper] Solves for one coordinate of a line (Ax + By + C = 0) when the other is known. * Generalizes the formula: unknown = -(knownCoeff * knownVal + C) / unknownCoeff * @param knownAxisCoeff - The coefficient (A or B) corresponding to the known coordinate. * @param unknownAxisCoeff - The coefficient (A or B) for the coordinate we are solving for. * @param C - The C coefficient of the line. * @param knownValue - The value of the known coordinate (e.g., the 'x' of a vertical line). * @returns The calculated value of the unknown coordinate as a BigDecimal. */ function solveForUnknownAxis( knownAxisCoeff: bigint, unknownAxisCoeff: bigint, C: bigint, knownValue: bigint, ): BigDecimal { // This should not happen if the "non-vertical" or "non-horizontal" constraints are met. if (unknownAxisCoeff === 0n) throw new Error('Cannot solve for axis, as the divisor (unknownAxisCoeff) is zero.'); // Calculate the numerator using perfect BigInt arithmetic. const numerator = -(knownAxisCoeff * knownValue + C); // Convert to BigDecimal and perform the single, final division. return bd.divide(bd.fromBigInt(numerator), bd.fromBigInt(unknownAxisCoeff)); } /** * {@link solveForUnknownAxis}, but for BigDecimal coefficients and known value. */ function solveForUnknownAxisBD( knownAxisCoeff: BigDecimal, unknownAxisCoeff: BigDecimal, C: BigDecimal, knownValue: BigDecimal, ): BigDecimal { // This should not happen if the "non-vertical" or "non-horizontal" constraints are met. if (bd.areEqual(unknownAxisCoeff, ZERO)) throw new Error('Cannot solve for axis, as the divisor (unknownAxisCoeff) is zero.'); // Calculate the numerator const numerator = bd.negate(bd.add(bd.multiply(knownAxisCoeff, knownValue), C)); // Perform the single, final division. return bd.divide(numerator, unknownAxisCoeff); } // ================================= Composite Geometric Operations ================================= /** * Calculates the intersection point of two line SEGMENTS (not rays or infinite lines). * Returns undefined if there is none, or there's infinite (colinear). * * THE REASON WE TAKE THE COEFFICIENTS as arguments instead of calculating them * on the fly, is because the start and end segment points MAY HAVE FLOATING POINT IMPRECISION, * which would bleed into coefficient imprecision, thus imprecise intersection points. * By accepting the coefficients as arguments, they retain maximum precision. * @param line1Coefficients Coefficients [A,B,C] of segment 1's infinite line * @param s1p1 Start point of segment 1 * @param s1p2 End point of segment 1 * @param line2Coefficients Coefficients [A,B,C] of segment 2's infinite line * @param s2p1 Start point of segment 2 * @returns The intersection Coords if they intersect, otherwise undefined. */ function intersectLineSegments( line1Coefficients: LineCoefficients, s1p1: BDCoords, s1p2: BDCoords, line2Coefficients: LineCoefficients, s2p1: BDCoords, s2p2: BDCoords, ): BDCoords | undefined { // 1. Calculate intersection of the infinite lines const intersectionPoint: BDCoords | undefined = calcIntersectionPointOfLines( ...line1Coefficients, ...line2Coefficients, ); if (!intersectionPoint) return undefined; // Lines are parallel or collinear. // 2. Check if the intersection point lies on both segments if ( isPointOnSegment(intersectionPoint, s1p1, s1p2) && isPointOnSegment(intersectionPoint, s2p1, s2p2) ) return intersectionPoint; return undefined; // Intersection point is not on one or both segments } /** * Calculates the intersection point of an infinite line (in general form) and a line segment. * Returns undefined if there is no intersection, the intersection point lies * outside the segment, or if the line and segment are collinear/parallel. * @param lineCoefficients The coefficients [A,B,C] of the infinite line. * @param segmentCoefficients The coefficients [A,B,C] of the line containing the segment. * @param segP1 Start point of the segment * @param segP2 End point of the segment * @returns The intersection Coords if they intersect ON the segment, otherwise undefined. */ function intersectLineAndSegment( lineCoefficients: LineCoefficientsBD, segmentCoefficients: LineCoefficients, segP1: BDCoords, segP2: BDCoords, ): BDCoords | undefined { // 1. Convert the segment coefficients to BigDecimal const segmentCoefficientsBD = vectors.convertCoeficcientsToBD(segmentCoefficients); // 2. Calculate intersection of the two infinite lines // Uses the provided function calcIntersectionPointOfLines const intersectionPoint = calcIntersectionPointOfLinesBD( ...lineCoefficients, ...segmentCoefficientsBD, ); // 3. Handle no intersection (parallel) or collinear lines. // calcIntersectionPointOfLines returns undefined if determinant is 0. if (intersectionPoint === undefined) return undefined; // 4. Check if the calculated intersection point lies on the actual segment // The point is guaranteed to be collinear with the segment if an intersection was found. if (isPointOnSegment(intersectionPoint, segP1, segP2)) return intersectionPoint; // Intersection point is within the segment bounds // 5. The intersection point exists but is outside the segment bounds return undefined; } /** * Calculates the intersection point of an infinite ray and a line segment. * Returns undefined if there is no intersection, the intersection point lies * outside the segment, the intersection point lies "behind" the ray's start, * or if the ray's line and segment's line are collinear/parallel without a * valid single intersection point on both. * @param ray The ray, defined by a starting point and a direction vector. * @param segP1 Start point of the segment. PERFECT integer. * @param segP2 End point of the segment. PERFECT integer. * @returns The intersection Coords if they intersect ON the segment and ON the ray, otherwise undefined. */ function intersectRayAndSegment(ray: Ray, segP1: Coords, segP2: Coords): BDCoords | undefined { // 1. Get general form for the infinite line containing the segment. // PERFECT integers => No floating point imprecision. const segmentCoeffs = vectors.getLineGeneralFormFrom2Coords(segP1, segP2); // 2. Calculate intersection of the two infinite lines. const intersectionPoint = calcIntersectionPointOfLines(...ray.line, ...segmentCoeffs); // 3. Handle no unique intersection (parallel or collinear lines). // Be sure to capture the case if the ray starts at one of the segment's endpoints. if (!intersectionPoint) { // First check if the ray's start lies on the start/end poit of the segment. const rayStartIsP1 = coordutil.areCoordsEqual(ray.start, segP1); const rayStartIsP2 = coordutil.areCoordsEqual(ray.start, segP2); if (rayStartIsP1 || rayStartIsP2) { // Collinear, and ray starts at one of the segment's endpoints // This means the lines must be collinear, so we need to check if // the ray's direction vector points away from the segment's opposite end (1 intersection), // because if it pointed towards the segment's opposite end, it would have infinite intersections. if (rayStartIsP1) return getCollinearIntersection(segP2); else if (rayStartIsP2) return getCollinearIntersection(segP1); } return undefined; // Parallel, not collinear, zero intersections. } function getCollinearIntersection(oppositePoint: Coords): BDCoords | undefined { const vectorToOppositePoint = vectors.calculateVectorFromPoints(ray.start, oppositePoint); const dotProd = vectors.dotProduct(ray.vector, vectorToOppositePoint); if (dotProd > 0) return undefined; // The ray points towards the opposite end of the segment, so no unique intersection. else return bdcoords.FromCoords(ray.start); // The intersection point is the ray's start. } // 4. Check if the calculated intersection point lies on the actual segment. if ( !isPointOnSegment(intersectionPoint, bdcoords.FromCoords(segP1), bdcoords.FromCoords(segP2)) ) return undefined; // Intersection point is not within the segment bounds. // 5. Check if the intersection point lies on the ray (not "behind" its start). // Calculate vector from ray start to intersection. const rayStartBD = bdcoords.FromCoords(ray.start); const vectorToIntersection = vectors.calculateVectorFromBDPoints(rayStartBD, intersectionPoint); // Calculate dot product of ray's direction vector and the vector to the intersection. const rayVecBD = bdcoords.FromCoords(ray.vector); const dotProd = vectors.dotProductBD(rayVecBD, vectorToIntersection); if (bd.compare(dotProd, ZERO) < 0) return undefined; // Dot product is negative, meaning the intersection point is behind the ray's start. // 6. If all checks pass, the intersection point is valid for both ray and segment. return intersectionPoint; } /** * Calculates the intersection point of two rays. * Returns the intersection coordinates if the rays intersect at a single point * that lies on both rays (i.e., not "behind" the starting point of either ray). * Returns undefined if they are parallel, collinear (resulting in no unique * intersection or infinite intersections), or if the intersection point of * their containing lines falls outside of one or both rays. * * @param ray1 The first ray. * @param ray2 The second ray. * @returns The intersection Coords if they intersect on both rays, otherwise undefined. */ function intersectRays(ray1: Ray, ray2: Ray): BDCoords | undefined { // 1. Calculate the intersection point of the infinite lines containing the rays. const intersectionPoint = calcIntersectionPointOfLines(...ray1.line, ...ray2.line); // 2. If the lines are parallel or collinear, they don't have a unique intersection point. // calcIntersectionPointOfLines returns undefined in this case. if (!intersectionPoint) return undefined; // This covers parallel lines and collinear lines (infinite intersections or no intersection). // 3. Check if the intersection point lies on the first ray. // This is done by checking if the vector from the ray's start to the intersection point // points in the same general direction as the ray's own direction vector. // The dot product will be non-negative (>= 0) if this is true. // Vector from ray1's start to the intersection point const vectorToIntersection1 = vectors.calculateVectorFromBDPoints( bdcoords.FromCoords(ray1.start), intersectionPoint, ); // Dot product of ray1's direction vector and vectorToIntersection1 const dotProd1 = vectors.dotProductBD(bdcoords.FromCoords(ray1.vector), vectorToIntersection1); if (bd.compare(dotProd1, ZERO) < 0) return undefined; // The intersection point is "behind" the start of ray1. // 4. Check if the intersection point lies on the second ray (similarly). const vectorToIntersection2 = vectors.calculateVectorFromBDPoints( bdcoords.FromCoords(ray2.start), intersectionPoint, ); const dotProd2 = vectors.dotProductBD(bdcoords.FromCoords(ray2.vector), vectorToIntersection2); if (bd.compare(dotProd2, ZERO) < 0) return undefined; // The intersection point is "behind" the start of ray2. // 5. If both checks pass, the intersection point is on both rays. return intersectionPoint; } /** * Checks if a point lies on a given line segment. * ASSUMES THE POINT IS COLINEAR with the segment's endpoints if checking after finding an intersection of their lines. * @param point The point to check. * @param segStart The starting point of the segment. * @param segEnd The ending point of the segment. * @returns True if the point is on the segment, false otherwise. */ function isPointOnSegment(point: BDCoords, segStart: BDCoords, segEnd: BDCoords): boolean { const minSegX = bd.min(segStart[0], segEnd[0]); const maxSegX = bd.max(segStart[0], segEnd[0]); const minSegY = bd.min(segStart[1], segEnd[1]); const maxSegY = bd.max(segStart[1], segEnd[1]); // Check if point is within the bounding box of the segment const withinX = bd.compare(point[0], minSegX) >= 0 && bd.compare(point[0], maxSegX) <= 0; const withinY = bd.compare(point[1], minSegY) >= 0 && bd.compare(point[1], maxSegY) <= 0; return withinX && withinY; } // ============================== High-Level Algorithms ============================== /** * Returns the point on the line SEGMENT that is nearest to the given point. * * @param segP1 - The starting point of the line segment. * @param segP2 - The ending point of the line segment. * @param point - The point to find the nearest point on the line segment to. * @returns An object containing the properties `coords`, which is the closest point on the segment, * and `distance` to that point. */ function closestPointOnLineSegment( segmentCoeffs: LineCoefficients, segP1: BDCoords, segP2: BDCoords, point: BDCoords, ): { coords: BDCoords; distance: BigDecimal } { const perpendicularCoeffs = vectors.getPerpendicularLine(segmentCoeffs, point); // Find the intersection of the perpendicular line with the line containing the segment. let closestPoint: BDCoords | undefined = intersectLineAndSegment( perpendicularCoeffs, segmentCoeffs, segP1, segP2, ); // If the intersection is undefined, it means it lies outside the segment. // So we need to figure out which segment point its CLOSEST to. if (closestPoint === undefined) { const distToP1 = vectors.chebyshevDistanceBD(point, segP1); const distToP2 = vectors.chebyshevDistanceBD(point, segP2); if (bd.compare(distToP1, distToP2) < 0) closestPoint = segP1; // p1 is closer else closestPoint = segP2; // p2 is closer } // Calculate the distance from the original point to the closest point on the segment. const distance = vectors.euclideanDistanceBD(closestPoint, point); return { coords: closestPoint, distance, }; } /** * Finds the two corners of a bounding box that define its cross-sectional width * when viewed from the direction of a given vector. * * If the vector is vertical, then as if we were looking at the box from below, * we would return its left/right-most points. */ function findCrossSectionalWidthPoints(vector: Vec2, boundingBox: BoundingBox): [Coords, Coords] { const { left, right, bottom, top } = boundingBox; // The normal vector is perpendicular to the viewing vector. // We can use this to find the points that are furthest apart on this line. const normal: Vec2 = vectors.getPerpendicularVector(vector); const corners: Coords[] = [ [left, top], // Top-left [right, top], // Top-right [left, bottom], // Bottom-left [right, bottom], // Bottom-right ]; // Initialize min/max with the projection of the first corner let minCorner: Coords = corners[0]!; let maxCorner: Coords = corners[0]!; let minProjection: bigint = vectors.dotProduct(minCorner, normal); let maxProjection: bigint = minProjection; // Iterate through the rest of the corners (from the second one) for (const corner of corners) { // Project the corner onto the NORMAL vector using the dot product const projection = vectors.dotProduct(corner, normal); if (bimath.compare(projection, minProjection) < 0) { minProjection = projection; minCorner = corner; } if (bimath.compare(projection, maxProjection) > 0) { maxProjection = projection; maxCorner = corner; } } return [minCorner, maxCorner]; } /** * Finds the intersection points of a line with a bounding box. * @param startCoords - The starting point of the line. * @param vector - The direction vector [dx, dy] of the line. * @param box - The bounding box to test if the line intersects. * @returns An array of intersection points as BDCoords, sorted by distance along the direction vector, * along with whether whether their dot product is positive (in the direction of the vector). */ function findLineBoxIntersections( startCoords: Coords, vector: Vec2, box: BoundingBox, log = false, ): IntersectionPoint[] { if (log) { console.log('\nFinding line box intersections for:'); console.log('Coords:', startCoords); console.log('Vector:', vector); console.log('Box:', box); console.log('\n'); } // Cast the box to BigDecimals const boxBD = bounds.castBoundingBoxToBigDecimal(box); // Determine the coefficients of the line in general form const coeffs = vectors.getLineGeneralFormFromCoordsAndVec(startCoords, vector); // Normalize the start coords as if the vector is normalized to the first graph quadrant. const startCoordsNorm = coordutil.copyCoords(startCoords); if (vector[0] < 0n) startCoordsNorm[0] = -startCoordsNorm[0]; if (vector[1] < 0n) startCoordsNorm[1] = -startCoordsNorm[1]; const startCoordsSum = bd.fromBigInt(startCoordsNorm[0] + startCoordsNorm[1]); return findLineBoxIntersectionsBDHelper( coeffs, vector, startCoordsSum, box, boxBD, intersectLineAndVerticalLine, intersectLineAndHorizontalLine, log, ); } // Test cases // const testBox: BoundingBox = { left: -10n, right: 10n, bottom: -5n, top: 5n }; // const testCoords: Coords = [0n, 0n]; // const textVector: Vec2 = [1n, 0n]; // findLineBoxIntersections(testCoords, textVector, testBox, true); /** * Finds the intersection points of a line with BigDecimal precision with a bounding box of BigDecimal precision. * Slightly slower than {@link findLineBoxIntersections}. * @param startCoords - The starting point of the line. * @param vector - The direction vector [dx, dy] of the line. * @param boxBD - The bounding box to test if the line intersects. * @returns An array of intersection points as BDCoords, sorted by distance along the direction vector, * along with whether whether their dot product is positive (in the direction of the vector). */ function findLineBoxIntersectionsBD( startCoords: BDCoords, vector: Vec2, boxBD: BoundingBoxBD, ): IntersectionPoint[] { // Determine the coefficients of the line in general form const coeffs = vectors.getLineGeneralFormFromCoordsAndVecBD(startCoords, vector); // Normalize the start coords as if the vector is normalized to the first graph quadrant. const startCoordsNorm = normalizeIntersection(startCoords, vector); const startCoordsSum = bd.add(startCoordsNorm[0], startCoordsNorm[1]); return findLineBoxIntersectionsBDHelper( coeffs, vector, startCoordsSum, boxBD, boxBD, intersectLineAndVerticalLineBD, intersectLineAndHorizontalLineBD, ); } /** * Helper for findLineBoxIntersections to normalize an intersection point, * as if the vector were normalized to the first graph quadrant. */ function normalizeIntersection(intersection: BDCoords, vector: Vec2): BDCoords { const normalizedIntersection = coordutil.copyBDCoords(intersection); if (vector[0] < 0n) normalizedIntersection[0] = bd.negate(normalizedIntersection[0]); if (vector[1] < 0n) normalizedIntersection[1] = bd.negate(normalizedIntersection[1]); return normalizedIntersection; } /** [Helper] Shared logic for finding line-box intersections, whether the inputs are integers or BigDecimals. */ function findLineBoxIntersectionsBDHelper( coeffs: [T, T, T], vector: Vec2, startCoordsSum: BigDecimal, box: { left: T; right: T; bottom: T; top: T }, boxBD: BoundingBoxBD, vertIntectFunc: (_A1: T, _B1: T, _C1: T, _x: T) => BDCoords, horzIntsectFunc: (_A1: T, _B1: T, _C1: T, _y: T) => BDCoords, log = false, ): { coords: BDCoords; positiveDotProduct: boolean }[] { // Check for intersections with each of the four box edges const intersections: BDCoords[] = []; // Check vertical edges (where x is constant: x = left or x = right) if (vector[0] !== 0n) { // A non-zero dx means the line is not vertical and can intersect vertical edges. const intersectionLeft = vertIntectFunc(...coeffs, box.left); const intersectionRight = vertIntectFunc(...coeffs, box.right); // Now check if the intersection points actually lie ON the segments of the edges. if ( bd.compare(intersectionLeft[1], boxBD.bottom) >= 0 && bd.compare(intersectionLeft[1], boxBD.top) <= 0 ) intersections.push(intersectionLeft); // Valid intersection on left edge if ( bd.compare(intersectionRight[1], boxBD.bottom) >= 0 && bd.compare(intersectionRight[1], boxBD.top) <= 0 ) intersections.push(intersectionRight); // Valid intersection on right edge } // Check horizontal edges (where y is constant: y = bottom or y = top) if (vector[1] !== 0n) { // A non-zero dy means the line is not horizontal and can intersect horizontal edges. const intersectionBottom = horzIntsectFunc(...coeffs, box.bottom); const intersectionTop = horzIntsectFunc(...coeffs, box.top); // Now check if the intersection points actually lie ON the segments of the edges. if ( bd.compare(intersectionBottom[0], boxBD.left) >= 0 && bd.compare(intersectionBottom[0], boxBD.right) <= 0 ) intersections.push(intersectionBottom); // Valid intersection on bottom edge if ( bd.compare(intersectionTop[0], boxBD.left) >= 0 && bd.compare(intersectionTop[0], boxBD.right) <= 0 ) intersections.push(intersectionTop); // Valid intersection on top edge } // 4. De-duplicate and Sort the valid intersection points // De-duplicate points const unique_intersections = intersections.filter( (v, i, a) => a.findIndex((t) => coordutil.areBDCoordsEqual(v, t)) === i, ); const intersectionsWithPositiveDotProduct = unique_intersections.map((intersection) => { // Normalize the intersection as if the vector is normalized. const norm = normalizeIntersection(intersection, vector); const sum = bd.add(norm[0], norm[1]); // If the sum is greater than the startCoords sum, the dot product is positive. const positiveDotProduct = bd.compare(sum, startCoordsSum) >= 0; return { coords: intersection, positiveDotProduct, }; }); // Sort by distance along the direction vector intersectionsWithPositiveDotProduct.sort((a, b) => { // Normalize the intersection as if the vector is normalized. const normA = normalizeIntersection(a.coords, vector); const normB = normalizeIntersection(b.coords, vector); const ASum = bd.add(normA[0], normA[1]); const BSum = bd.add(normB[0], normB[1]); // Whichever is greater is further along the direction vector. return bd.compare(ASum, BSum); }); if (log) { for (const i of intersectionsWithPositiveDotProduct) { console.log('Coordinates of intersection:', coordutil.stringifyBDCoords(i.coords)); console.log('Positive dot product?', i.positiveDotProduct); } } return intersectionsWithPositiveDotProduct; } // ============================== Miscellaneous Utilities ============================== /** * Rounds the given point to the nearest grid point multiple of the provided gridSize. * * For example, a point of [5200,1100] and gridSize of 10000 would yield [10000,0] */ function roundPointToNearestGridpoint(point: BDCoords, gridSize: bigint): Coords { // point: [x,y] gridSize is width of cells, typically 10,000 // Incurs rounding, but honestly this doesn't need to be exact because it's for graphics. const pointBigInt: Coords = bdcoords.coordsToBigInt(point); // To round bigints, we add half the gridSize before dividing by it. function roundBigintNearestMultiple(value: bigint, multiple: bigint): bigint { const halfMultiple = multiple / 2n; // Assumes multiple is positive and divisible by 2. // For positives, add half and truncate. if (value >= 0n) return ((value + halfMultiple) / multiple) * multiple; // For negatives, subtract half and truncate. else return ((value - halfMultiple) / multiple) * multiple; } const nearestX = roundBigintNearestMultiple(pointBigInt[0], gridSize); const nearestY = roundBigintNearestMultiple(pointBigInt[1], gridSize); return [nearestX, nearestY]; } // ================================= Exports ================================= export default { // Fundamental Intersection Functions calcIntersectionPointOfLines, calcIntersectionPointOfLinesBD, // Composite Intersection Functions intersectLineSegments, intersectLineAndSegment, intersectRayAndSegment, intersectRays, // High-Level Algorithms closestPointOnLineSegment, findCrossSectionalWidthPoints, findLineBoxIntersections, findLineBoxIntersectionsBD, // Miscellaneous Utilities roundPointToNearestGridpoint, }; export type { IntersectionPoint, BaseRay }; ================================================ FILE: src/shared/util/math/math.ts ================================================ // src/shared/util/math/math.ts /** * This script contains extra general math operations. * * Most of the stuff in here were moved to either bounds.ts, vectors.ts, or geometry.ts. */ // Types ------------------------------------------------------ /** A color in a length-4 array: `[r,g,b,a]` */ type Color = [number, number, number, number]; // Operations ----------------------------------------------------------- /** * Clamps a value between a minimum and a maximum value. */ function clamp(value: number, min: number, max: number): number { return value < min ? min : value > max ? max : value; } /** * Computes the positive modulus of two numbers. * @param a - The dividend. * @param b - The divisor. * @returns The positive remainder of the division. */ function posMod(a: number, b: number): number { return a - Math.floor(a / b) * b; } // Easing Functions --------------------------------------------------- /** * Applies an ease-in-out interpolation. * @param t - The interpolation factor (0 to 1). */ function easeInOut(t: number): number { return -0.5 * Math.cos(Math.PI * t) + 0.5; } /** * Applies an ease-in interpolation. * @param t - The interpolation factor (0 to 1). */ function easeIn(t: number): number { return t * t; } /** * Applies an ease-out interpolation. * @param t - The interpolation factor (0 to 1). */ function easeOut(t: number): number { return t * (2 - t); } // Other ------------------------------------------------------------- /** Returns a value smoothly oscillating between a min and max. */ function getSineWaveVariation(time: number, min: number, max: number): number { return min + (Math.sin(time) * 0.5 + 0.5) * (max - min); } // Exports ----------------------------------------------------- export default { // Operations clamp, posMod, // Easing Functions easeInOut, easeIn, easeOut, // Other getSineWaveVariation, }; export type { Color }; ================================================ FILE: src/shared/util/math/vectors.ts ================================================ // src/shared/util/math/vectors.ts /** * This script contains methods for performing vector calculations, * such as calculating angles, distances, and other operations. */ import type { BDCoords, Coords, DoubleCoords } from '../../chess/util/coordutil.js'; import bd, { BigDecimal } from '@naviary/bigdecimal'; import bimath from './bimath.js'; import bdcoords from '../../chess/util/bdcoords.js'; // Types ---------------------------------------------------------------------- /** A length-2 number array. Commonly used for storing directions. */ type Vec2 = [bigint, bigint]; /** * A pair of x & y vectors, represented in a string, separated by a `,`. * * This is often used as the key for a slide direction in an object. */ type Vec2Key = `${bigint},${bigint}`; /** A length-3 number array. Commonly used for storing positional and scale transformations. */ type Vec3 = [number, number, number]; /** * A maethematical ray, starting from a single point * and going out to infinity in one direction. */ type Ray = { start: Coords; vector: Vec2; /** The line in general form (A, B, C coefficients) */ line: LineCoefficients; }; /** * Coefficients A, B, C, of a line in general form. * These can be bigints because all lines, rays, and segment * points inside the game are integers. */ type LineCoefficients = [bigint, bigint, bigint]; /** * {@link LineCoefficients} but for BigDecimal lines (requiring decimal precision). */ type LineCoefficientsBD = [BigDecimal, BigDecimal, BigDecimal]; // Constants ---------------------------------------------------------------------- // prettier-ignore /** All positive/absolute orthogonal vectors. */ const VECTORS_ORTHOGONAL: Coords[] = [[1n,0n],[0n,1n]]; // prettier-ignore /** All positive/absolute diagonal vectors. */ const VECTORS_DIAGONAL: Coords[] = [[1n,1n],[1n,-1n]]; // prettier-ignore /** The positive/absolute knightrider hippogonals. */ const VECTORS_HIPPOGONAL: Coords[] = [[1n,2n],[1n,-2n],[2n,1n],[2n,-1n]]; // Construction ---------------------------------------------------------------------- /** * Returns the key string of the coordinates: [dx,dy] => 'dx,dy' */ function getKeyFromVec2(vec2: Vec2): Vec2Key { return `${vec2[0]},${vec2[1]}`; } /** * Returns the vector from the Vec2Key: 'dx,dy' => [dx,dy] */ function getVec2FromKey(vec2Key: Vec2Key): Vec2 { return vec2Key.split(',').map(BigInt) as Vec2; } /** * Converts a bigint vector to javascript doubles. */ function convertVectorToDoubles(vec2: Vec2): DoubleCoords { return [Number(vec2[0]), Number(vec2[1])]; } /** * Calculates the general form coefficients (A, B, C) of a line given a point and a direction vector. */ function getLineGeneralFormFromCoordsAndVec(coords: Coords, vector: Vec2): LineCoefficients { // General form: Ax + By + C = 0 const A = vector[1]; const B = -vector[0]; const C = vector[0] * coords[1] - vector[1] * coords[0]; return [A, B, C]; } /** * {@link getLineGeneralFormFromCoordsAndVec} but for BigDecimal coordinates. */ function getLineGeneralFormFromCoordsAndVecBD(coords: BDCoords, vector: Vec2): LineCoefficientsBD { const vectorBD = bdcoords.FromCoords(vector); // General form: Ax + By + C = 0 const A: BigDecimal = bd.clone(vectorBD[1]); const B: BigDecimal = bd.negate(vectorBD[0]); // vector[0] * coords[1] - vector[1] * coords[0] const C: BigDecimal = bd.subtract( bd.multiply(vectorBD[0], coords[1]), bd.multiply(vectorBD[1], coords[0]), ); return [A, B, C]; } /** * Calculates the general form of a line (Ax + By + C = 0) given two points on the line. * Handles both regular and vertical lines. */ function getLineGeneralFormFrom2Coords(coords1: Coords, coords2: Coords): LineCoefficients { // Handle the case of a vertical line (infinite slope) // The line equation is x = x1, which in general form is: 1*x + 0*y - x1 = 0 if (coords1[0] === coords2[0]) return [1n, 0n, -coords1[0]]; // // Calculate the slope (m) // const m = (coords2[1] - coords1[1]) / (coords2[0] - coords1[0]); // // General form coefficients: A = m, B = -1, and C = y1 - m * x1 // const A = m; // const B = -1n; // const C = coords1[1] - m * coords1[0]; // To avoid division and floating-point/truncation issues, we use the cross-multiplication method. // The equation (y - y1)(x2 - x1) = (x - x1)(y2 - y1) is rearranged to Ax + By + C = 0. const A = coords2[1] - coords1[1]; // y2 - y1 const B = coords1[0] - coords2[0]; // x1 - x2 const C = coords2[0] * coords1[1] - coords1[0] * coords2[1]; // x2*y1 - x1*y2 return [A, B, C]; } // /** // * {@link getLineGeneralFormFrom2Coords} but for BigDecimal coordinates. // */ // function getLineGeneralFormFrom2CoordsBD(coords1: BDCoords, coords2: BDCoords): LineCoefficientsBD { // // Handle the case of a vertical line (infinite slope) // // The line equation is x = x1, which in general form is: 1*x + 0*y - x1 = 0 // if (bd.areEqual(coords1[0], coords2[0])) return [ONE, ZERO, bd.negate(coords1[0])]; // // // To avoid division and floating-point/truncation issues, we use the cross-multiplication method. // // The equation (y - y1)(x2 - x1) = (x - x1)(y2 - y1) is rearranged to Ax + By + C = 0. // const A = bd.subtract(coords2[1], coords1[1]); // y2 - y1 // const B = bd.subtract(coords1[0], coords2[0]); // x1 - x2 // const C = bd.subtract(bd.multiply(coords2[0], coords1[1]), bd.multiply(coords1[0], coords2[1])); // x2*y1 - x1*y2 // // return [A, B, C]; // } /** * Upgrades bigint line coefficients [A, B, C] to BigDecimals. */ function convertCoeficcientsToBD(line: LineCoefficients): LineCoefficientsBD { return [bd.fromBigInt(line[0]), bd.fromBigInt(line[1]), bd.fromBigInt(line[2])]; } /** * Calculates the vector between 2 points. */ function calculateVectorFromPoints(start: Coords, end: Coords): Vec2 { return [end[0] - start[0], end[1] - start[1]]; } /** * Calculates the vector between 2 points. */ function calculateVectorFromBDPoints(start: BDCoords, end: BDCoords): BDCoords { return [bd.subtract(end[0], start[0]), bd.subtract(end[1], start[1])]; } /** * Calculates the C coefficient of a line in general form (Ax + By + C = 0) * given a point (coords) and a direction vector (vector). * * Step size here is unimportant, but the slope **is**. * This value will be unique for every line that *has the same slope*, but different positions. */ function getLineCFromCoordsAndVec(coords: Coords, vector: Vec2): bigint { return vector[0] * coords[1] - vector[1] * coords[0]; } // /** // * {@link getLineCFromCoordsAndVec} but for BigDecimal coordinates. // */ // function getLineCFromCoordsAndVecBD(coords: BDCoords, vector: Vec2): BigDecimal { // const vectorBD = bdcoords.FromCoords(vector); // // Coords first since they are likely higher precision. // return bd.subtract(bd.multiply(coords[1], vectorBD[0]), bd.multiply(coords[0], vectorBD[1])); // } // Operations ----------------------------------------------------------------------------- /** * Compares two lines in general form to see if they are equal/coincident. * Two lines are considered equal if their coefficients are proportional. * @param line1 - The first line in general form [A1, B1, C1] * @param line2 - The second line in general form [A2, B2, C2] * @returns true if the lines are equal, false otherwise */ function areLinesInGeneralFormEqual(line1: LineCoefficients, line2: LineCoefficients): boolean { const [A1, B1, C1] = line1; const [A2, B2, C2] = line2; // Check if the ratios of the coefficients are equal (proportional) // Avoid division by zero by checking the relationship with multiplication return A1 * B2 === A2 * B1 && A1 * C2 === A2 * C1 && B1 * C2 === B2 * C1; } /** * Calculates the X and Y components of a unit vector given an angle in radians. * @param theta - The angle in radians. * @returns A tuple containing the X and Y components, both between -1 and 1. */ function getXYComponents_FromAngle(theta: number): DoubleCoords { return [Math.cos(theta), Math.sin(theta)]; // When hypotenuse is 1.0 } /** * Computes the dot product of two 2D vectors. * WILL BE POSITIVE if they roughly point in the same direction. */ function dotProduct(v1: Vec2, v2: Vec2): bigint { return v1[0] * v2[0] + v1[1] * v2[1]; } /** * Computes the dot product of two 2D vectors. * WILL BE POSITIVE if they roughly point in the same direction. */ function dotProductBD(v1: BDCoords, v2: BDCoords): BigDecimal { return bd.add(bd.multiply(v1[0], v2[0]), bd.multiply(v1[1], v2[1])); } /** * Computes the dot product of two 2D vectors represented as doubles. * WILL BE POSITIVE if they roughly point in the same direction. */ function dotProductDoubles(v1: DoubleCoords, v2: DoubleCoords): number { return v1[0] * v2[0] + v1[1] * v2[1]; } /** * Negates the provided length-2 vector so it points in the opposite direction * * Non-mutating. Returns a new vector. */ function negateVector(vec2: Vec2): Vec2 { return [-vec2[0], -vec2[1]]; } /** * Negates the provided length-2 BigDecimal vector so it points in the opposite direction * * Non-mutating. Returns a new vector. */ function negateBDVector(vec2: BDCoords): BDCoords { return [bd.negate(vec2[0]), bd.negate(vec2[1])]; } /** * Negates the provided length-2 double vector so it points in the opposite direction * * Non-mutating. Returns a new vector. */ function negateDoubleVector(vec2: DoubleCoords): DoubleCoords { return [-vec2[0], -vec2[1]]; } /** * Returns the absolute value of the provided vector. * In the context of our game, positive vectors always point to the right, * and if they are vertical then they always point up. */ function absVector(vec2: Vec2): Vec2 { if (vec2[0] < 0n || (vec2[0] === 0n && vec2[1] < 0n)) return negateVector(vec2); else return vec2; } /** * Normalizes a vector to its smallest possible integer components while preserving its direction. */ function normalizeVector(vec2: Vec2): Vec2 { // Calculate the GCD of all the components in the vector. const gcd = bimath.GCD(vec2[0], vec2[1]); // If the GCD is 0, it means all elements were 0 if (gcd === 0n) return [0n, 0n]; // Divide each component by the GCD to get the smallest integer representation. return [vec2[0] / gcd, vec2[1] / gcd]; } /** * Normalizes a floating point arbitrarily large vector into a range * near 0-1, small enough so it can be represented with javascript numbers. * PRESERVES the ratio between the x and y components. */ function normalizeVectorBD(vec2: BDCoords): DoubleCoords { // Normalize it NEAR the range 0-1 (don't matter if it's not exact). // const targetLength = vectors.chebyshevDistanceBD(ZERO_COORDS, targetVector); const targetLength = bd.max(bd.abs(vec2[0]), bd.abs(vec2[1])); return [ bd.toNumber(bd.divideFloating(vec2[0], targetLength)), bd.toNumber(bd.divideFloating(vec2[1], targetLength)), ]; } /** * Calculates the normal (perpendicular) vector of a given 2D vector. */ function getPerpendicularVector(vec2: Vec2): Vec2 { return [-vec2[1], vec2[0]]; } /** * Calculates the line that is perpendicular to a given line and passes through a specific point. * @param lineCoeffs - The coefficients [A,B,C] of the original line. * @param point - The coordinates that the new perpendicular line must pass through. * @returns New BigDecimal coefficients for the perpendicular line. */ function getPerpendicularLine(lineCoeffs: LineCoefficients, point: BDCoords): LineCoefficientsBD { const lineCoeffsBD = convertCoeficcientsToBD(lineCoeffs); const [A1, B1] = lineCoeffsBD; // Step 1: Determine the A and B coefficients for the new line (L2). // The normal vector for L2 is (-B1, A1). const A2 = bd.negate(B1); const B2 = A1; // Step 2: Solve for the C coefficient of the new line (L2). // The equation is A2*x + B2*y + C2 = 0. // We know it must pass through point (xp, yp), so we can solve for C2: // A2*xp + B2*yp + C2 = 0 // C2 = -(A2*xp + B2*yp) // C2 = -((-B1)*xp + A1*yp) // C2 = B1*xp - A1*yp const term1 = bd.multiply(B1, point[0]); const term2 = bd.multiply(A1, point[1]); const C2 = bd.subtract(term1, term2); return [A2, B2, C2]; } /** * Converts an angle in degrees to radians */ function degreesToRadians(angleDegrees: number): number { return angleDegrees * (Math.PI / 180); } // Distance Calculation ---------------------------------------------------------------------------- /** * Returns the euclidean (hypotenuse) distance between 2 bigint points. */ function euclideanDistance(point1: Coords, point2: Coords): BigDecimal { const point1BD: BDCoords = bdcoords.FromCoords(point1); const point2BD: BDCoords = bdcoords.FromCoords(point2); return euclideanDistanceBD(point1BD, point2BD); } /** * Returns the euclidean (hypotenuse) distance between 2 BigDecimal points. */ function euclideanDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal { return bd.hypot(bd.subtract(point2[0], point1[0]), bd.subtract(point2[1], point1[1])); } /** * Returns the euclidean (hypotenuse) distance between 2 javascript double coordinates. */ function euclideanDistanceDoubles(point1: DoubleCoords, point2: DoubleCoords): number { return Math.hypot(point2[0] - point1[0], point2[1] - point1[1]); } /** * Returns the manhatten distance between 2 points. * This is the sum of the distances between the points' x distance and y distance. * This is often the distance of roads, because you can't move diagonally. */ function manhattanDistance(point1: Coords, point2: Coords): bigint { return bimath.abs(point2[0] - point1[0]) + bimath.abs(point2[1] - point1[1]); } // function manhattanDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal { // return bd.add(bd.abs(bd.subtract(point2[0], point1[0])), bd.abs(bd.subtract(point2[1], point1[1]))); // } /** * Returns the chebyshev distance between 2 points. * This is the maximum between the points' x distance and y distance. * This is often used for chess pieces, because moving * diagonally 1 is the same distance as moving orthogonally one. */ function chebyshevDistance(point1: Coords, point2: Coords): bigint { return bimath.max(bimath.abs(point2[0] - point1[0]), bimath.abs(point2[1] - point1[1])); } /** * {@link chebyshevDistance} but for BigDecimal coordinates. */ function chebyshevDistanceBD(point1: BDCoords, point2: BDCoords): BigDecimal { return bd.max( bd.abs(bd.subtract(point2[0], point1[0])), bd.abs(bd.subtract(point2[1], point1[1])), ); } /** * {@link chebyshevDistance} but for javascript numbers (doubles). */ function chebyshevDistanceDoubles(point1: DoubleCoords, point2: DoubleCoords): number { return Math.max(Math.abs(point2[0] - point1[0]), Math.abs(point2[1] - point1[1])); } // Exports ------------------------------------------------------------- export default { // Constants VECTORS_ORTHOGONAL, VECTORS_DIAGONAL, VECTORS_HIPPOGONAL, // Construction getKeyFromVec2, getVec2FromKey, convertVectorToDoubles, getLineGeneralFormFromCoordsAndVec, getLineGeneralFormFromCoordsAndVecBD, getLineGeneralFormFrom2Coords, // getLineGeneralFormFrom2CoordsBD, convertCoeficcientsToBD, calculateVectorFromPoints, calculateVectorFromBDPoints, getLineCFromCoordsAndVec, // getLineCFromCoordsAndVecBD, // Operations areLinesInGeneralFormEqual, getXYComponents_FromAngle, dotProduct, dotProductBD, dotProductDoubles, negateVector, negateBDVector, negateDoubleVector, absVector, normalizeVector, normalizeVectorBD, getPerpendicularVector, getPerpendicularLine, degreesToRadians, // Distance Calculation euclideanDistance, euclideanDistanceBD, euclideanDistanceDoubles, manhattanDistance, // manhattanDistanceBD, chebyshevDistance, chebyshevDistanceBD, chebyshevDistanceDoubles, }; export type { Vec2, Vec2Key, Vec3, Ray, LineCoefficients, LineCoefficientsBD }; ================================================ FILE: src/shared/util/timeutil.ts ================================================ // src/shared/util/timeutil.ts /** * This script contains utility methods for working with dates and timestamps. * * ZERO dependencies. */ /** * Converts minutes to milliseconds. */ function minutesToMillis(minutes: number): number { return minutes * 60 * 1000; } /** * Converts seconds to milliseconds. */ function secondsToMillis(seconds: number): number { return seconds * 1000; } /** * Converts a timestamp to an object with UTCDate and UTCTime. * This time format is used for ICN metadata notation. * @param timestamp - The timestamp in milliseconds since the Unix Epoch. * @returns An object with the properties `UTCDate` and `UTCTime`. */ function convertTimestampToUTCDateUTCTime(timestamp: number): { UTCDate: string; UTCTime: string } { const date = new Date(timestamp); const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, '0'); const day = String(date.getUTCDate()).padStart(2, '0'); const hours = String(date.getUTCHours()).padStart(2, '0'); const minutes = String(date.getUTCMinutes()).padStart(2, '0'); const seconds = String(date.getUTCSeconds()).padStart(2, '0'); const UTCDate = `${year}.${month}.${day}`; const UTCTime = `${hours}:${minutes}:${seconds}`; return { UTCDate, UTCTime }; } /** * Converts a UTCDate and optional UTCTime to a UTC timestamp in milliseconds since the Unix Epoch. * @param UTCDate - The date in the format "YYYY.MM.DD". * @param [UTCTime] The time in the format "HH:MM:SS". Defaults to "00:00:00". * @returns The UTC timestamp in milliseconds since the Unix Epoch. */ function convertUTCDateUTCTimeToTimeStamp(UTCDate: string, UTCTime: string = '00:00:00'): number { const [year, month, day] = UTCDate.split('.').map(Number) as [number, number, number]; const [hours, minutes, seconds] = UTCTime.split(':').map(Number) as [number, number, number]; const date = new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds)); return date.getTime(); } /** * Calculates the total milliseconds based on the provided options. * @param options - An object containing time units and their values. * @returns The total milliseconds calculated from the provided options. */ function getTotalMilliseconds(options: { milliseconds?: number; seconds?: number; minutes?: number; hours?: number; days?: number; weeks?: number; months?: number; years?: number; }): number { const millisecondsIn = { milliseconds: 1, seconds: 1000, minutes: 1000 * 60, hours: 1000 * 60 * 60, days: 1000 * 60 * 60 * 24, weeks: 1000 * 60 * 60 * 24 * 7, months: 1000 * 60 * 60 * 24 * 30, // Approximation, not precise years: 1000 * 60 * 60 * 24 * 365, // Approximation, not precise }; let totalMilliseconds = 0; for (const option in options) { if (millisecondsIn[option as keyof typeof millisecondsIn]) { totalMilliseconds += (options[option as keyof typeof options] || 0) * millisecondsIn[option as keyof typeof millisecondsIn]; } } return totalMilliseconds; } /** * Gets the current month in 'yyyy-mm' format. */ function getCurrentMonth(): string { const date = new Date(); const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Add 1 because getMonth() returns 0-11 return `${year}-${month}`; } /** * Gets the current day in 'yyyy-mm-dd' format. */ function getCurrentDay(): string { const date = new Date(); const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Checks if the current date is within a specified date range. * @param startMonth - The starting month of the range (1-12). * @param startDay - The starting day of the range (1-31). * @param endMonth - The ending month of the range (1-12). * @param endDay - The ending day of the range (1-31). * @returns True if the current date is within the specified range; otherwise, false. */ function isCurrentDateWithinRange( startMonth: number, startDay: number, endMonth: number, endDay: number, ): boolean { const currentDate = new Date(); const today = new Date( currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), ); // Normalized current date const startDate = new Date(currentDate.getFullYear(), startMonth - 1, startDay); const endDate = new Date(currentDate.getFullYear(), endMonth - 1, endDay); return today >= startDate && today <= endDate; } /** * Converts a timestamp (milliseconds since the UNIX epoch) to an ISO 8601 string. */ function timestampToISO(timestamp: number): string { return new Date(timestamp).toISOString(); } /** * Converts an ISO 8601 string to a timestamp in milliseconds since the UNIX epoch. */ function isoToTimestamp(isoString: string): number { return new Date(isoString).getTime(); } /** * Converts a SQLite DATETIME string (in "YYYY-MM-DD HH:MM:SS" format) to a UTC timestamp in milliseconds. * Assumes the SQLite timestamp is in UTC. * @param sqliteString - The DATETIME string from SQLite in the format "YYYY-MM-DD HH:MM:SS". * @returns The corresponding UTC timestamp in milliseconds since the UNIX epoch. */ function sqliteToTimestamp(sqliteString: string): number { const isoString = sqliteToISO(sqliteString); return Date.parse(isoString); } /** * Converts a SQLite DATETIME string (in "YYYY-MM-DD HH:MM:SS" format) to an ISO 8601 string. * Assumes the SQLite timestamp is in UTC. * @param sqliteString - The DATETIME string from SQLite in the format "YYYY-MM-DD HH:MM:SS". * @returns The corresponding ISO 8601 formatted string (e.g., "YYYY-MM-DDTHH:MM:SSZ"). */ function sqliteToISO(sqliteString: string): string { return sqliteString.replace(' ', 'T') + 'Z'; } /** * Converts an ISO 8601 string to SQLite's DATETIME format ("YYYY-MM-DD HH:MM:SS"). * @param isoString - The ISO 8601 formatted string (e.g., "YYYY-MM-DDTHH:MM:SSZ"). * @returns The corresponding SQLite DATETIME string (e.g., "YYYY-MM-DD HH:MM:SS"). */ function isoToSQLite(isoString: string): string { const date = new Date(isoString); if (isNaN(date.getTime())) throw new Error('Invalid ISO 8601 string provided.'); return date.toISOString().replace('T', ' ').split('.')[0]!; } /** * Converts a timestamp (milliseconds since the UNIX epoch) to SQLite's DATETIME format ("YYYY-MM-DD HH:MM:SS"). * The output string represents the timestamp in UTC. * @param timestamp - The timestamp in milliseconds since the UNIX epoch. * @returns The corresponding SQLite DATETIME string (e.g., "YYYY-MM-DD HH:MM:SS"). */ function timestampToSqlite(timestamp: number): string { const date = new Date(timestamp); // Check if the timestamp resulted in a valid date if (isNaN(date.getTime())) throw new Error('Invalid timestamp provided.'); // toISOString() returns in UTC format "YYYY-MM-DDTHH:MM:SS.sssZ" // We need to format it to "YYYY-MM-DD HH:MM:SS" const isoString = date.toISOString(); // Extract the date and time part, replace 'T' with space return isoString.slice(0, 19).replace('T', ' '); } export default { minutesToMillis, secondsToMillis, convertTimestampToUTCDateUTCTime, convertUTCDateUTCTimeToTimeStamp, getTotalMilliseconds, getCurrentMonth, getCurrentDay, isCurrentDateWithinRange, timestampToISO, isoToTimestamp, sqliteToTimestamp, sqliteToISO, isoToSQLite, timestampToSqlite, }; ================================================ FILE: src/shared/util/tokenConfig.ts ================================================ // src/shared/util/tokenConfig.ts /** * This script contains shared configuration constants for authentication tokens, * used by both the client and server. */ /** The expiration duration of access tokens, in milliseconds. */ const ACCESS_TOKEN_EXPIRY_MILLIS: number = 1000 * 60 * 15; // 15 minutes // const ACCESS_TOKEN_EXPIRY_MILLIS: number = 1000 * 20; // 20 seconds, for testing purposes. export default { ACCESS_TOKEN_EXPIRY_MILLIS, }; ================================================ FILE: src/shared/util/uuid.ts ================================================ // src/shared/util/uuid.ts /** * This script generates unique identifiers for us. * * ZERO dependancies. */ const BASE_36_CHARSET: string = '0123456789abcdefghijklmnopqrstuvwxyz'; const BASE_62_CHARSET: string = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** * Generates a random ID of the provided length, with the characters 0-9, a-z, and A-Z. */ function generateID_Base62(length: number): string { return generateIDWithCharset(length, BASE_62_CHARSET); } /** * Generates a random ID of the provided length, with the characters 0-9, a-z. */ function generateID_Base36(length: number): string { return generateIDWithCharset(length, BASE_36_CHARSET); } /** * Generates a random ID of the provided length using the specified character set. * @param length - The length of the desired ID * @param characters - The character set to use for generating the ID */ function generateIDWithCharset(length: number, characters: string): string { let result = ''; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } /** * Generates a **UNIQUE** ID of the provided length, with the characters 0-9 and a-z. * The provided object should contain the keys of the existing IDs. * @param length - The length of the desired ID * @param object - The object that contains keys of the existing IDs. */ function genUniqueID(length: number, object: Record): string { let id: string; do { id = generateID_Base62(length); } while (object[id] !== undefined); return id; } /** Generates a random numeric ID of the provided length, with the numbers 0-9. */ function generateNumbID(length: number): number { const zeroOne = Math.random(); const multiplier = 10 ** length; return Math.floor(zeroOne * multiplier); } /** * Converts a number from base 10 to base 62. * MUST BE POSITIVE!!! 0+ */ function base10ToBase62(num: number): string { if (!Number.isInteger(num) || num < 0) throw new Error( 'Input must be a non-negative integer when converting base 10 to base 62. Received: ' + num, ); const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; let result = ''; // Handle zero as a special case if (num === 0) return '0'; while (num > 0) { const remainder = num % 62; result = characters[remainder] + result; num = Math.floor(num / 62); } return result; } /** * Converts a number from base 62 to base 10. * MUST BE VALID BASE 62!!! */ function base62ToBase10(base62Str: string): number { if (typeof base62Str !== 'string' || base62Str.length === 0) throw new Error('Input must be a non-empty string when converting base 62 to base 10.'); const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const base = 62; let result = 0; for (let i = 0; i < base62Str.length; i++) { const char = base62Str[i]!; const value = characters.indexOf(char); if (value === -1) { throw new Error(`Invalid character '${char}' in base 62 string.`); } result = result * base + value; } return result; } export default { generateID_Base62, generateID_Base36, genUniqueID, generateNumbID, base10ToBase62, base62ToBase10, }; ================================================ FILE: src/shared/util/validators.ts ================================================ // src/shared/util/validators.ts /** * This has shared validators between client and server, * to avoid repeating email/password/username validation * and possibly missing to update things both in client and server * * TODO: * - Return list of errors instead of only one, also removes the need for the `Ok` value * - Possibly return a class (?) with a .getTranslationKey() function or add some other way to do that (then there could also be the .isValid property) */ enum PasswordValidationResult { Ok, PasswordTooShort, PasswordTooLong, PasswordIsPassword, } enum EmailValidationResult { Ok, InvalidFormat, EmailTooLong, } enum UsernameValidationResult { Ok, UsernameTooShort, UsernameTooLong, OnlyLettersAndNumbers, UsernameIsReserved, } type PasswordValidationResultTranslations = | 'js-pwd_too_short' | 'js-pwd_too_long' | 'js-pwd_not_pwd'; type EmailValidationResultTranslations = 'js-email_too_long' | 'js-email_invalid'; type UsernameValidationResultTranslations = | 'js-username_reserved' | 'js-username_tooshort' | 'js-username_length' | 'js-username_wrongenc'; const passwordErrorTranslations = new Map(); passwordErrorTranslations.set(PasswordValidationResult.PasswordTooShort, 'js-pwd_too_short'); passwordErrorTranslations.set(PasswordValidationResult.PasswordTooLong, 'js-pwd_too_long'); passwordErrorTranslations.set(PasswordValidationResult.PasswordIsPassword, 'js-pwd_not_pwd'); const emailErrorTranslations = new Map(); emailErrorTranslations.set(EmailValidationResult.EmailTooLong, 'js-email_too_long'); emailErrorTranslations.set(EmailValidationResult.InvalidFormat, 'js-email_invalid'); const usernameErrorTranslations = new Map(); usernameErrorTranslations.set(UsernameValidationResult.UsernameIsReserved, 'js-username_reserved'); usernameErrorTranslations.set(UsernameValidationResult.UsernameTooShort, 'js-username_tooshort'); usernameErrorTranslations.set(UsernameValidationResult.UsernameTooLong, 'js-username_length'); // there is no translation for js-username_toolong usernameErrorTranslations.set( UsernameValidationResult.OnlyLettersAndNumbers, 'js-username_wrongenc', ); function getPasswordErrorTranslation( err: PasswordValidationResult, ): PasswordValidationResultTranslations | undefined { return passwordErrorTranslations.get(err); } function getEmailErrorTranslation( err: EmailValidationResult, ): EmailValidationResultTranslations | undefined { return emailErrorTranslations.get(err); } function getUsernameErrorTranslation( err: UsernameValidationResult, ): UsernameValidationResultTranslations | undefined { return usernameErrorTranslations.get(err); } /** Usernames that are reserved. New members cannot use these are their name. */ // prettier-ignore const reservedUsernames: string[] = [ 'infinitechess', 'support', 'infinitechesssupport', 'administrator', 'amazon', 'amazonsupport', 'aws', 'awssupport', 'apple', 'applesupport', 'microsoft', 'microsoftsupport', 'google', 'googlesupport', 'adobe', 'adobesupport', 'youtube', 'facebook', 'tiktok', 'twitter', 'x', 'instagram', 'snapchat', 'tesla', 'elonmusk', 'meta', 'walmart', 'costco', 'valve', 'valvesupport', 'github', 'nvidia', 'amd', 'intel', 'msi', 'tsmc', 'gigabyte', 'roblox', 'minecraft', 'fortnite', 'teamfortress2', 'amongus', 'innersloth', 'henrystickmin', 'halflife', 'halflife2', 'gordonfreeman', 'epic', 'epicgames', 'epicgamessupport', 'taylorswift', 'kimkardashian', 'tomcruise', 'keanureeves', 'morganfreeman', 'willsmith', 'office', 'office365', 'usa', 'america', 'donaldtrump', 'joebiden' ]; /** * Shared logic to validate passwords * @param password The password to check * @returns `Ok` if the password is valid, otherwise another member of that enum */ function validatePassword(password: string): PasswordValidationResult { if (password.length < 6) return PasswordValidationResult.PasswordTooShort; if (password.length > 72) return PasswordValidationResult.PasswordTooLong; if (password.toLowerCase() === 'password') return PasswordValidationResult.PasswordIsPassword; return PasswordValidationResult.Ok; } /** * Shared logic to validate emails. * **Note**: Does not check if the email is taken or banned, that's on the server to do. * @param email The email to check * @returns `Ok` if the email is valid, otherwise another member of that enum */ function validateEmail(email: string): EmailValidationResult { if (email.length > 320) return EmailValidationResult.EmailTooLong; if (!validateEmailFormat(email)) return EmailValidationResult.InvalidFormat; return EmailValidationResult.Ok; } function validateEmailFormat(email: string): boolean { // Credit for the regex: https://stackoverflow.com/a/201378 // prettier-ignore const regex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; // eslint-disable-line no-control-regex return regex.test(email.toLowerCase()); } /** * Shared logic to validate usernames. * **Note**: Does not check if the username is taken, that's on the server to do. * @param username The username to check * @returns `Ok` if the username is valid, otherwise another member of that enum * @todo Return a list of errors instead of just one, for better checking (then the Ok could also be replaced by just checking if the list length is 0, which might be cleaner) */ function validateUsername(username: string): UsernameValidationResult { if (username.length < 3) return UsernameValidationResult.UsernameTooShort; if (username.length > 20) return UsernameValidationResult.UsernameTooLong; if (!onlyLettersAndNumbers(username)) return UsernameValidationResult.OnlyLettersAndNumbers; if (reservedUsernames.includes(username.toLowerCase())) return UsernameValidationResult.UsernameIsReserved; return UsernameValidationResult.Ok; } function onlyLettersAndNumbers(string: string): boolean { if (!string) return true; return /^[a-zA-Z0-9]+$/.test(string); } export default { validatePassword, PasswordValidationResult, validateEmail, EmailValidationResult, validateUsername, UsernameValidationResult, getPasswordErrorTranslation, getEmailErrorTranslation, getUsernameErrorTranslation, }; ================================================ FILE: src/shared/util/wsutil.ts ================================================ // src/shared/util/wsutil.ts /* * This script should contain utility methods regarding * sockets that both the CLIENT and server can use. */ // Constants --------------------------------------------------------------------------------- /** * After this much time of no messages sent, the server sends a * 'renewconnection' keepalive expecting an echo back. */ const timeOfInactivityToRenewConnection = 10000; // Variables --------------------------------------------------------------------------------- // Possible websocket closure reasons: // Server closure reasons: // 1000 "Connection expired" (This can say this even if in dev tools we disable our network) // 1008 "Unable to identify client IP address" // 1008 "Authentication needed" // 1008 "Logged out" // 1009 "Too Many Requests. Try again soon." // 1009 "Message Too Big" // 1009 "Too Many Sockets" // 1009 "Origin Error" // 1014 "No echo heard" (Client took too long to respond) // Client closure reasons: // 1000 "Connection closed by client" // 1000 "Connection closed by client. Renew." // Other: // 1006 "" Network error // 1001 "" Endpoint going away. (Closed tab without performing cleanup) // All client-side closure codes: // 1000: Normal closure. // 1001: Endpoint going away. // 1002: Protocol error. // 1003: Unsupported data. // 1005: No status code received (reserved). // 1006: Abnormal closure, no further detail available (reserved). This is usually a network interruption, OR the server is down. // 1007: Invalid data received. // 1008: Policy violation. // 1009: Message too big. // 1010: Missing extension. // 1011: Internal server error. // 1012: Service restart. // 1013: Try again later. // 1014: Bad gateway. // 1015: TLS handshake failure (reserved). // Possible closure reasons (pairings of code and reason): // 1000 "Connection expired" (This can say this even if in dev tools we disable our network) // 1000 "Connection closed by client" // 1000 "Connection closed by client. Renew." // 1008 "Unable to identify client IP address" // 1008 "Authentication needed" // 1008 "Logged out" (Happens when we click log out button) // 1009 "Too Many Requests. Try again soon." // 1009 "Message Too Big" // 1009 "Too Many Sockets" // 1009 "Origin Error" // 1014 "No echo heard" (Client took too long to respond) // These are the closure reasons where we will RETAIN their invite for a set amount of time before deleting it by disconnection! // We will also give them 5 seconds to reconnect before we tell their opponent they have disconnected. // If the closure code is NOT one of the ones below, it means they purposefully closed the socket (like closed the tab), // so IMMEDIATELY tell their opponent they disconnected! const closureCodesNotByChoice: number[] = [1006]; const closureReasonsNotByChoice: string[] = [ 'Connection expired', 'Message Too Big', 'Too Many Sockets', 'No echo heard', 'Connection closed by client. Renew.', ]; // Functions --------------------------------------------------------------------------------- /** * Determines if the WebSocket closure was not initiated by the client (i.e., they had no control over the closure). * If this returns `true`, the client is allowed 5 seconds to reconnect before notifying their opponent of the disconnection. * @param code - The WebSocket closure code. * @param reason - The reason provided for the WebSocket closure. * @returns `true` if the closure was not initiated by the client, otherwise `false`. */ function wasSocketClosureNotByTheirChoice(code: number, reason: string): boolean { return ( closureCodesNotByChoice.includes(code) || closureReasonsNotByChoice.includes(reason.trim()) ); } // ----------------------------------------------------------------------------------------- export default { timeOfInactivityToRenewConnection, wasSocketClosureNotByTheirChoice, }; ================================================ FILE: src/tests/integrationUtils.ts ================================================ // src/tests/integrationUtils.ts import { testRequest } from './testRequest'; import { generateAccount } from '../server/controllers/createAccountController'; // Variables ------------------------------------------------------------------- /** Counter to ensure unique usernames for each test user */ let userCounter = 0; // Functions ------------------------------------------------------------------- /** Creates a new test user, logs them in, and returns their username and session cookie. */ async function createAndLoginUser(): Promise<{ user_id: number; username: string; cookie: string; }> { userCounter++; const username = `ChessMaster-${userCounter}`; const user_id = await generateAccount({ username, email: `${username}@example.com`, password: 'Password123!', autoVerify: true, }); const response = await testRequest().post('/auth').send({ username, password: 'Password123!' }); // Extract the session cookies const cookies = response.headers['set-cookie'] as unknown as string[]; // set-cookie is actually an array const jwt = cookies.find((c) => c.startsWith('jwt=')); const memberInfo = cookies.find((c) => c.startsWith('memberInfo=')); if (!jwt || !memberInfo) throw new Error('Missing login cookies'); // Return both combined return { user_id, username, cookie: [jwt, memberInfo].filter(Boolean).join(';'), }; } // Exports ------------------------------------------------------------------- export default { createAndLoginUser, }; ================================================ FILE: src/tests/testRequest.ts ================================================ // src/tests/testRequest.ts import request, { Test } from 'supertest'; import app from '../server/app.js'; /** * A wrapper around supertest to automatically set common headers * required by the application (e.g. to bypass HTTPS redirects and 404s). */ export function testRequest(): { get: (url: string) => Test; post: (url: string) => Test; put: (url: string) => Test; patch: (url: string) => Test; delete: (url: string) => Test; } { const req = request(app); const commonHeaders = { 'X-Forwarded-Proto': 'https', // Fakes HTTPS to bypass middleware redirect 'User-Agent': 'supertest', // Required to bypass middleware rate limiting }; return { get: (url: string) => req.get(url).set(commonHeaders), post: (url: string) => req.post(url).set(commonHeaders), put: (url: string) => req.put(url).set(commonHeaders), patch: (url: string) => req.patch(url).set(commonHeaders), delete: (url: string) => req.delete(url).set(commonHeaders), }; } ================================================ FILE: src/tests/tests-setup.ts ================================================ // src/tests/tests-setup.ts import type { NextFunction, Request, Response } from 'express'; import { vi, afterAll } from 'vitest'; // Set up environment variables for testing. // Prevents `test` workflow job failing due to missing secrets. process.env['ACCESS_TOKEN_SECRET'] = 'test_access_secret'; process.env['REFRESH_TOKEN_SECRET'] = 'test_refresh_secret'; // Stop Console Bloat // Store the original functions so we can restore them after const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; // Redirect console functions to empty functions console.log = vi.fn(); console.error = vi.fn(); console.warn = vi.fn(); // Mock Logger to prevent file writes // This tells Vitest whenever any file imports logEvents.js, give them these empty functions instead. vi.mock('../server/middleware/logEvents.js', () => ({ logEvents: vi.fn(), // Do nothing logEventsAndPrint: vi.fn(), // Do nothing reqLogger: (_req: Request, _res: Response, next: NextFunction) => next(), // Continue to next middleware logWebsocketStart: vi.fn(), // Do nothing logReqWebsocketIn: vi.fn(), // Do nothing logReqWebsocketOut: vi.fn(), // Do nothing })); // Restore console functions after tests finish so Vitest can print the summary afterAll(() => { console.log = originalLog; console.error = originalError; console.warn = originalWarn; }); ================================================ FILE: src/types/globals.d.ts ================================================ // src/types/globals.d.ts import type { MemberInfo } from '../server/types'; import type { TranslationsObject } from './translations'; /** * Client-side translations subset. * EJS templates use spread operators to flatten nested translation objects. * For example, `...t('play.javascript', {returnObjects: true})` spreads all properties * from `play.javascript` directly into the global translations object. */ type ClientTranslations = TranslationsObject['index']['javascript'] & TranslationsObject['play']['javascript'] & TranslationsObject['play']['play-menu'] & TranslationsObject['member']['javascript'] & TranslationsObject['login']['javascript'] & TranslationsObject['leaderboard']['javascript'] & TranslationsObject['create-account']['javascript'] & TranslationsObject['reset-password']['javascript'] & TranslationsObject['password-validation']; declare global { /** * Global translations object injected by EJS templates. * Contains flattened translation properties from various sections. * The actual shape varies by page, but this represents the union of all possible translations. */ const translations: ClientTranslations; /** htmlscript injected inline inside the game page. It handles the loading animation. */ var htmlscript: { /** Called on failure to load a page asset. */ callback_LoadingError: () => void; /** Removes this specific html element's listener for a loading error. */ removeOnerror: (this: HTMLElement) => void; }; /** Main script that starts the game loop. Called from htmlscript.js */ var main: { start: () => void; }; // Our Custom Events interface DocumentEventMap { ping: CustomEvent; 'socket-closed': CustomEvent; 'premoves-toggle': CustomEvent; 'lingering-annotations-toggle': CustomEvent; 'starfield-toggle': CustomEvent; 'master-volume-change': CustomEvent; 'ambience-toggle': CustomEvent; 'ray-count-change': CustomEvent; canvas_resize: CustomEvent<{ width: number; height: number }>; } // Add an optional 'memberInfo' to the global Express Request interface namespace Express { export interface Request { memberInfo?: MemberInfo; } } } ================================================ FILE: src/types/shaders.d.ts ================================================ // src/types/shaders.d.ts /* * This tells TypeScript all .glsl imports are strings. * * This can't be put inside globals.d.ts because TypeScript * has a weird rule that global declarations must * be in a separate file from module declarations. */ declare module '*.glsl' { const content: string; export default content; } ================================================ FILE: src/types/translations.ts ================================================ // src/types/translations.ts /** * This file is auto-generated by scripts/generate-translation-types.ts on build. * Do NOT edit manually! */ /** * Flat dot-notation union type for server-side i18next. * Use with i18next.t() function. * @example * i18next.t("play.javascript.termination.checkmate") */ export type TranslationKeys = | 'name' | 'english_name' | 'direction' | 'version' | 'maintainer' | 'header.home' | 'header.play' | 'header.news' | 'header.login' | 'header.profile' | 'header.createaccount' | 'header.logout' | 'header.leaderboard' | 'header.settings.language' | 'header.settings.appearance' | 'header.settings.appearance-theme' | 'header.settings.appearance-coordinates' | 'header.settings.appearance-starfield' | 'header.settings.appearance-advanced-effects' | 'header.settings.legalmoves' | 'header.settings.legalmoves-squares' | 'header.settings.legalmoves-dots' | 'header.settings.gameplay' | 'header.settings.gameplay-drag' | 'header.settings.gameplay-premove' | 'header.settings.gameplay-animations' | 'header.settings.gameplay-fast_transitions' | 'header.settings.gameplay-lingering_annotations' | 'header.settings.perspective' | 'header.settings.perspective-mouse-sensitivity' | 'header.settings.perspective-fov' | 'header.settings.sound' | 'header.settings.sound-master-volume' | 'header.settings.sound-ambience' | 'header.settings.ping' | 'header.settings.reset-to-default' | 'footer.contact' | 'footer.terms_of_service' | 'footer.source_code' | 'footer.language' | 'member.javascript.js-confirm_delete' | 'member.javascript.js-enter_password' | 'member.title' | 'member.verify_message' | 'member.resend_message' | 'member.verify_confirm' | 'member.joined' | 'member.seen' | 'member.practice_progress' | 'member.ranked_elo' | 'member.infinity_leaderboard_position' | 'member.infinity_leaderboard_rating_deviation' | 'member.reveal_info' | 'member.account_info_heading' | 'member.email' | 'member.delete_account' | 'member.badge-tooltips.checkmate_bronze' | 'member.badge-tooltips.checkmate_silver' | 'member.badge-tooltips.checkmate_gold' | 'leaderboard.javascript.supported_variants' | 'leaderboard.javascript.rank' | 'leaderboard.javascript.player' | 'leaderboard.javascript.rating' | 'leaderboard.title' | 'leaderboard.inactive_players' | 'leaderboard.your_global_ranking' | 'leaderboard.show_more' | 'index.title' | 'index.secondary_title' | 'index.what_is_it_title' | 'index.what_is_it_pargaraphs' | 'index.how_to_title' | 'index.how_to_paragraph' | 'index.about_title' | 'index.about_paragraphs' | 'index.patreon_title' | 'index.github_title' | 'index.javascript.contribution_count_singular' | 'index.javascript.contribution_count_plural' | 'credits.title' | 'credits.copyright' | 'credits.variants_heading' | 'credits.variants_credits' | 'credits.textures_heading' | 'credits.textures_licensed_under' | 'credits.sounds_heading' | 'credits.sounds_credits' | 'credits.code_heading' | 'credits.code_credits' | 'credits.language_heading' | 'credits.language_credits' | 'create-account.title' | 'create-account.username' | 'create-account.email' | 'create-account.password' | 'create-account.create_button' | 'create-account.agreement' | 'create-account.javascript.js-username_reserved' | 'create-account.javascript.js-username_length' | 'create-account.javascript.js-username_tooshort' | 'create-account.javascript.js-username_wrongenc' | 'create-account.javascript.js-email_invalid' | 'create-account.javascript.js-email_too_long' | 'create-account.javascript.js-email_inuse' | 'reset-password.javascript.js-pwd_no_match' | 'reset-password.javascript.reset-password' | 'reset-password.javascript.processing' | 'reset-password.javascript.network-error' | 'password-validation.js-pwd_too_short' | 'password-validation.js-pwd_too_long' | 'password-validation.js-pwd_not_pwd' | 'play.title' | 'play.loading' | 'play.error' | 'play.main-menu.credits' | 'play.main-menu.play' | 'play.main-menu.practice' | 'play.main-menu.guide' | 'play.main-menu.editor' | 'play.editor.position' | 'play.editor.tools' | 'play.editor.selection' | 'play.editor.palette' | 'play.editor.color' | 'play.editor.tooltip_reset' | 'play.editor.tooltip_clear' | 'play.editor.tooltip_load' | 'play.editor.tooltip_save_as' | 'play.editor.tooltip_save' | 'play.editor.tooltip_copy_notation' | 'play.editor.tooltip_paste_notation' | 'play.editor.tooltip_gamerules' | 'play.editor.tooltip_start_local' | 'play.editor.tooltip_start_engine' | 'play.editor.tooltip_normal' | 'play.editor.tooltip_eraser' | 'play.editor.tooltip_selection_tool' | 'play.editor.tooltip_specialrights' | 'play.editor.tooltip_select_all' | 'play.editor.tooltip_clear_selection' | 'play.editor.tooltip_copy_selection' | 'play.editor.tooltip_paste_selection' | 'play.editor.tooltip_invert_color' | 'play.editor.tooltip_rotate_left' | 'play.editor.tooltip_rotate_right' | 'play.editor.tooltip_flip_horizontal' | 'play.editor.tooltip_flip_vertical' | 'play.editor.reset_header' | 'play.editor.reset_message' | 'play.editor.clear_header' | 'play.editor.clear_message' | 'play.editor.enter_position_name' | 'play.editor.save_button' | 'play.editor.name_header' | 'play.editor.pieces_header' | 'play.editor.date_header' | 'play.editor.no_saves' | 'play.editor.gamerules_header' | 'play.editor.player_to_move' | 'play.editor.white' | 'play.editor.black' | 'play.editor.en_passant' | 'play.editor.move_rule' | 'play.editor.promotion_ranks_white' | 'play.editor.promotion_ranks_black' | 'play.editor.promotion_pieces' | 'play.editor.global_special_rights' | 'play.editor.pawn_double_push' | 'play.editor.castling_label' | 'play.editor.win_conditions' | 'play.editor.checkmate' | 'play.editor.royal_capture' | 'play.editor.all_royals_captured' | 'play.editor.all_pieces_captured' | 'play.editor.world_border' | 'play.editor.start_local_game' | 'play.editor.start_local_game_message' | 'play.editor.start_engine_game' | 'play.editor.play_as' | 'play.editor.time_control' | 'play.editor.engine_difficulty' | 'play.editor.easy' | 'play.editor.medium' | 'play.editor.hard' | 'play.editor.use_default_border' | 'play.editor.start_engine_game_message' | 'play.editor.yes' | 'play.editor.no' | 'play.guide.title' | 'play.guide.rules' | 'play.guide.rules_paragraphs' | 'play.guide.careful_heading' | 'play.guide.careful_paragraphs' | 'play.guide.controls_heading' | 'play.guide.controls_paragraph' | 'play.guide.keybinds' | 'play.guide.controls_paragraph2' | 'play.guide.keybinds_extra' | 'play.guide.fairy_heading' | 'play.guide.fairy_paragraph' | 'play.guide.back' | 'play.guide.pieces.chancellor.name' | 'play.guide.pieces.chancellor.description' | 'play.guide.pieces.archbishop.name' | 'play.guide.pieces.archbishop.description' | 'play.guide.pieces.amazon.name' | 'play.guide.pieces.amazon.description' | 'play.guide.pieces.guard.name' | 'play.guide.pieces.guard.description' | 'play.guide.pieces.hawk.name' | 'play.guide.pieces.hawk.description' | 'play.guide.pieces.centaur.name' | 'play.guide.pieces.centaur.description' | 'play.guide.pieces.knightrider.name' | 'play.guide.pieces.knightrider.description' | 'play.guide.pieces.huygen.name' | 'play.guide.pieces.huygen.description' | 'play.guide.pieces.rose.name' | 'play.guide.pieces.rose.description' | 'play.guide.pieces.obstacle.name' | 'play.guide.pieces.obstacle.description' | 'play.guide.pieces.void.name' | 'play.guide.pieces.void.description' | 'play.practice-menu.title' | 'play.practice-menu.play' | 'play.practice-menu.back' | 'play.practice-menu.difficulty' | 'play.play-menu.title' | 'play.play-menu.colors' | 'play.play-menu.online' | 'play.play-menu.local' | 'play.play-menu.computer' | 'play.play-menu.variant' | 'play.play-menu.Classical' | 'play.play-menu.Confined_Classical' | 'play.play-menu.Classical_Plus' | 'play.play-menu.CoaIP' | 'play.play-menu.Pawndard' | 'play.play-menu.Knighted_Chess' | 'play.play-menu.Palace' | 'play.play-menu.Knightline' | 'play.play-menu.Core' | 'play.play-menu.Standarch' | 'play.play-menu.Pawn_Horde' | 'play.play-menu.Space_Classic' | 'play.play-menu.Space' | 'play.play-menu.Obstocean' | 'play.play-menu.Abundance' | 'play.play-menu.Amazon_Chandelier' | 'play.play-menu.Containment' | 'play.play-menu.Classical_Limit_7' | 'play.play-menu.CoaIP_Limit_7' | 'play.play-menu.Chess' | 'play.play-menu.Classical_KOTH' | 'play.play-menu.CoaIP_KOTH' | 'play.play-menu.CoaIP_HO' | 'play.play-menu.CoaIP_RO' | 'play.play-menu.CoaIP_NO' | 'play.play-menu.Omega' | 'play.play-menu.Omega_Squared' | 'play.play-menu.Omega_Cubed' | 'play.play-menu.Omega_Fourth' | 'play.play-menu.4x4x4x4_Chess' | 'play.play-menu.5D_Chess' | 'play.play-menu.no_clock' | 'play.play-menu.clock' | 'play.play-menu.minutes' | 'play.play-menu.seconds' | 'play.play-menu.infinite_time' | 'play.play-menu.color' | 'play.play-menu.piece_colors' | 'play.play-menu.private' | 'play.play-menu.no' | 'play.play-menu.yes' | 'play.play-menu.rated' | 'play.play-menu.casual' | 'play.play-menu.easy' | 'play.play-menu.medium' | 'play.play-menu.hard' | 'play.play-menu.join_games' | 'play.play-menu.private_invite' | 'play.play-menu.your_invite' | 'play.play-menu.create_invite' | 'play.play-menu.join' | 'play.play-menu.copy' | 'play.play-menu.back' | 'play.play-menu.code' | 'play.gamebuttontooltips.undo_transition' | 'play.gamebuttontooltips.expand_fit_all' | 'play.gamebuttontooltips.recenter' | 'play.gamebuttontooltips.annotations' | 'play.gamebuttontooltips.erase' | 'play.gamebuttontooltips.collapse' | 'play.gamebuttontooltips.rewind_move' | 'play.gamebuttontooltips.forward_move' | 'play.gamebuttontooltips.undo_edit' | 'play.gamebuttontooltips.redo_edit' | 'play.gamebuttontooltips.pause' | 'play.gamebuttontooltips.undo' | 'play.gamebuttontooltips.restart' | 'play.pause.title' | 'play.pause.resume' | 'play.pause.arrows' | 'play.pause.perspective' | 'play.pause.copy' | 'play.pause.paste' | 'play.pause.offer_draw' | 'play.pause.practice_menu' | 'play.pause.main_menu' | 'play.drawoffer.question' | 'play.javascript.guest_indicator' | 'play.javascript.you_indicator' | 'play.javascript.engine_indicator' | 'play.javascript.player_name_white_generic' | 'play.javascript.player_name_black_generic' | 'play.javascript.white_to_move' | 'play.javascript.black_to_move' | 'play.javascript.your_move' | 'play.javascript.their_move' | 'play.javascript.lost_network' | 'play.javascript.failed_to_load' | 'play.javascript.planned_feature' | 'play.javascript.main_menu' | 'play.javascript.resign_game' | 'play.javascript.abort_game' | 'play.javascript.offer_draw' | 'play.javascript.accept_draw' | 'play.javascript.arrows_off' | 'play.javascript.arrows_defense' | 'play.javascript.arrows_all' | 'play.javascript.arrows_all_hippogonals' | 'play.javascript.toggled' | 'play.javascript.menu_online' | 'play.javascript.menu_local' | 'play.javascript.menu_computer' | 'play.javascript.invite_error_digits' | 'play.javascript.invite_copied' | 'play.javascript.move_counter' | 'play.javascript.constructing_mesh' | 'play.javascript.rotating_mesh' | 'play.javascript.lost_connection' | 'play.javascript.please_wait' | 'play.javascript.webgl_unsupported' | 'play.javascript.bigints_unsupported' | 'play.javascript.versus' | 'play.javascript.easy' | 'play.javascript.medium' | 'play.javascript.hard' | 'play.javascript.insane' | 'play.javascript.checkmate_logged_out' | 'play.javascript.checkmate_bronze' | 'play.javascript.checkmate_silver' | 'play.javascript.checkmate_gold' | 'play.javascript.checkmate_bronze_unearned' | 'play.javascript.checkmate_silver_unearned' | 'play.javascript.checkmate_gold_unearned' | 'play.javascript.coords-invalid' | 'play.javascript.coords-exceeded' | 'play.javascript.piecenames.void' | 'play.javascript.piecenames.obstacle' | 'play.javascript.piecenames.king' | 'play.javascript.piecenames.giraffe' | 'play.javascript.piecenames.camel' | 'play.javascript.piecenames.zebra' | 'play.javascript.piecenames.knightrider' | 'play.javascript.piecenames.amazon' | 'play.javascript.piecenames.queen' | 'play.javascript.piecenames.royalQueen' | 'play.javascript.piecenames.hawk' | 'play.javascript.piecenames.chancellor' | 'play.javascript.piecenames.archbishop' | 'play.javascript.piecenames.centaur' | 'play.javascript.piecenames.royalCentaur' | 'play.javascript.piecenames.rose' | 'play.javascript.piecenames.knight' | 'play.javascript.piecenames.guard' | 'play.javascript.piecenames.huygen' | 'play.javascript.piecenames.rook' | 'play.javascript.piecenames.bishop' | 'play.javascript.piecenames.pawn' | 'play.javascript.copypaste.copied_game' | 'play.javascript.copypaste.cannot_paste_in_public' | 'play.javascript.copypaste.cannot_paste_in_rated' | 'play.javascript.copypaste.cannot_paste_in_engine' | 'play.javascript.copypaste.cannot_paste_after_moves' | 'play.javascript.copypaste.clipboard_denied' | 'play.javascript.copypaste.clipboard_invalid' | 'play.javascript.copypaste.game_needs_to_specify' | 'play.javascript.copypaste.pasting_game' | 'play.javascript.copypaste.pasting_in_private' | 'play.javascript.copypaste.piece_count' | 'play.javascript.copypaste.exceeded' | 'play.javascript.copypaste.changed_wincon' | 'play.javascript.copypaste.loaded_from_clipboard' | 'play.javascript.copypaste.copied_position' | 'play.javascript.copypaste.loaded_position_from_clipboard' | 'play.javascript.copypaste.reset_position' | 'play.javascript.copypaste.clear_position' | 'play.javascript.rendering.on' | 'play.javascript.rendering.off' | 'play.javascript.rendering.icon_rendering_off' | 'play.javascript.rendering.icon_rendering_on' | 'play.javascript.rendering.perspective' | 'play.javascript.rendering.perspective_mode_on_desktop' | 'play.javascript.rendering.movement_tutorial' | 'play.javascript.rendering.regenerated_pieces' | 'play.javascript.invites.move_mouse' | 'play.javascript.invites.cannot_cancel' | 'play.javascript.invites.you_are_white' | 'play.javascript.invites.you_are_black' | 'play.javascript.invites.random' | 'play.javascript.invites.accept' | 'play.javascript.invites.cancel' | 'play.javascript.invites.create_invite' | 'play.javascript.invites.cancel_invite' | 'play.javascript.invites.start_game' | 'play.javascript.invites.join_existing_active_games' | 'play.javascript.onlinegame.afk_warning' | 'play.javascript.onlinegame.opponent_afk' | 'play.javascript.onlinegame.opponent_disconnected' | 'play.javascript.onlinegame.opponent_lost_connection' | 'play.javascript.onlinegame.auto_resigning_in' | 'play.javascript.onlinegame.auto_aborting_in' | 'play.javascript.onlinegame.not_logged_in' | 'play.javascript.onlinegame.game_no_longer_exists' | 'play.javascript.onlinegame.another_window_connected' | 'play.javascript.websocket.no_connection' | 'play.javascript.websocket.reconnected' | 'play.javascript.websocket.unable_to_identify_ip' | 'play.javascript.websocket.online_play_disabled' | 'play.javascript.websocket.too_many_requests' | 'play.javascript.websocket.message_too_big' | 'play.javascript.websocket.too_many_sockets' | 'play.javascript.websocket.origin_error' | 'play.javascript.websocket.connection_closed' | 'play.javascript.websocket.please_report_bug' | 'play.javascript.websocket.malformed_message' | 'play.javascript.results.you_checkmate' | 'play.javascript.results.you_time' | 'play.javascript.results.you_resignation' | 'play.javascript.results.you_disconnect' | 'play.javascript.results.you_royalcapture' | 'play.javascript.results.you_allroyalscaptured' | 'play.javascript.results.you_allpiecescaptured' | 'play.javascript.results.you_koth' | 'play.javascript.results.you_generic' | 'play.javascript.results.draw_stalemate' | 'play.javascript.results.draw_repetition' | 'play.javascript.results.draw_moverule' | 'play.javascript.results.draw_insuffmat' | 'play.javascript.results.draw_agreement' | 'play.javascript.results.draw_generic' | 'play.javascript.results.aborted' | 'play.javascript.results.opponent_checkmate' | 'play.javascript.results.opponent_time' | 'play.javascript.results.opponent_resignation' | 'play.javascript.results.opponent_disconnect' | 'play.javascript.results.opponent_royalcapture' | 'play.javascript.results.opponent_allroyalscaptured' | 'play.javascript.results.opponent_allpiecescaptured' | 'play.javascript.results.opponent_koth' | 'play.javascript.results.opponent_generic' | 'play.javascript.results.white_checkmate' | 'play.javascript.results.black_checkmate' | 'play.javascript.results.white_time' | 'play.javascript.results.black_time' | 'play.javascript.results.white_resignation' | 'play.javascript.results.black_resignation' | 'play.javascript.results.white_disconnect' | 'play.javascript.results.black_disconnect' | 'play.javascript.results.white_royalcapture' | 'play.javascript.results.black_royalcapture' | 'play.javascript.results.white_allroyalscaptured' | 'play.javascript.results.black_allroyalscaptured' | 'play.javascript.results.white_allpiecescaptured' | 'play.javascript.results.black_allpiecescaptured' | 'play.javascript.results.white_koth' | 'play.javascript.results.black_koth' | 'play.javascript.results.bug_generic' | 'play.javascript.editor.expand_sidebar' | 'play.javascript.editor.collapse_sidebar' | 'play.javascript.editor.new_position' | 'play.javascript.editor.load_position_header' | 'play.javascript.editor.save_position_as_header' | 'play.javascript.editor.delete_title' | 'play.javascript.editor.delete_message' | 'play.javascript.editor.load_title' | 'play.javascript.editor.load_message' | 'play.javascript.editor.overwrite_title' | 'play.javascript.editor.overwrite_message' | 'play.javascript.editor.tooltip_load_position' | 'play.javascript.editor.tooltip_save_to_cloud' | 'play.javascript.editor.tooltip_remove_from_cloud' | 'play.javascript.editor.tooltip_delete_position' | 'play.javascript.editor.position_loaded' | 'play.javascript.editor.cannot_start_local_empty' | 'play.javascript.editor.cannot_start_engine_empty' | 'play.javascript.editor.position_not_supported' | 'play.javascript.editor.illegal_position_king_capture' | 'play.javascript.editor.saved_in_browser' | 'play.javascript.editor.position_corrupted' | 'play.javascript.editor.failed_to_load' | 'play.javascript.editor.failed_to_convert_icn' | 'play.javascript.editor.too_large_for_cloud' | 'play.javascript.editor.failed_to_upload' | 'play.javascript.editor.saved_to_cloud' | 'play.javascript.editor.no_changes' | 'play.javascript.editor.failed_to_load_cloud' | 'play.javascript.editor.failed_to_delete_cloud' | 'play.javascript.editor.failed_to_remove_cloud' | 'play.javascript.editor.saved_locally' | 'play.javascript.editor.failed_to_fetch_cloud' | 'terms.title' | 'terms.warning' | 'terms.consent' | 'terms.guardian_consent' | 'terms.parents_header' | 'terms.parents_paragraphs' | 'terms.fair_play_header' | 'terms.fair_play_paragraph1' | 'terms.fair_play_paragraph2' | 'terms.fair_play_rules' | 'terms.cleanliness_header' | 'terms.cleanliness_rules' | 'terms.privacy_header' | 'terms.privacy_rules' | 'terms.cookie_header' | 'terms.cookie_paragraphs' | 'terms.conclusion_header' | 'terms.conclusion_paragraphs' | 'terms.thanks' | 'login.title' | 'login.username' | 'login.password' | 'login.login_button' | 'login.send_reset_link' | 'login.forgot_question' | 'login.back_to_login' | 'login.forgot_instruction' | 'login.javascript.network-error' | 'reset_password.title' | 'reset_password.instruction' | 'reset_password.new_password' | 'reset_password.confirm_password' | 'reset_password.submit_button' | 'error-pages.400_message' | 'error-pages.409_message' | 'error-pages.500_message' | 'news.title' | 'news.more_dev_logs' | 'server.javascript.ws-invalid_username' | 'server.javascript.ws-incorrect_password' | 'server.javascript.ws-login_failure_retry_in' | 'server.javascript.ws-seconds' | 'server.javascript.ws-second' | 'server.javascript.ws-username_letters' | 'server.javascript.ws-username_taken' | 'server.javascript.ws-username_bad_word' | 'server.javascript.ws-email_too_long' | 'server.javascript.ws-email_invalid' | 'server.javascript.ws-email_in_use' | 'server.javascript.ws-email_domain_invalid' | 'server.javascript.ws-email_blacklisted' | 'server.javascript.ws-password_length' | 'server.javascript.ws-password_password' | 'server.javascript.ws-password-reset-link-sent' | 'server.javascript.ws-password-change-success' | 'server.javascript.ws-password-reset-token-invalid' | 'server.javascript.ws-forbidden_wrong_account' | 'server.javascript.ws-deleting_account_not_found' | 'server.javascript.ws-deleting_account_in_game' | 'server.javascript.ws-server_error' | 'server.javascript.ws-not_found' | 'server.javascript.ws-forbidden' | 'server.javascript.ws-already_in_game' | 'server.javascript.ws-you_cheated' | 'server.javascript.ws-opponent_cheated' | 'server.javascript.ws-cannot_resign_finished_game' | 'server.javascript.ws-invalid_code' | 'server.javascript.ws-game_aborted' | 'server.javascript.ws-rated_invite_verification_needed' | 'rate-limiting.generic' | 'rate-limiting.error'; /** * Nested object type for client-side translation access. * Represents the full structure of the translation object. */ export interface TranslationsObject { name: string; english_name: string; direction: string; version: string; maintainer: string; header: { home: string; play: string; news: string; login: string; profile: string; createaccount: string; logout: string; leaderboard: string; settings: { language: string; appearance: string; 'appearance-theme': string; 'appearance-coordinates': string; 'appearance-starfield': string; 'appearance-advanced-effects': string; legalmoves: string; 'legalmoves-squares': string; 'legalmoves-dots': string; gameplay: string; 'gameplay-drag': string; 'gameplay-premove': string; 'gameplay-animations': string; 'gameplay-fast_transitions': string; 'gameplay-lingering_annotations': string; perspective: string; 'perspective-mouse-sensitivity': string; 'perspective-fov': string; sound: string; 'sound-master-volume': string; 'sound-ambience': string; ping: string[]; 'reset-to-default': string; }; }; footer: { contact: string; terms_of_service: string; source_code: string; language: string; }; member: { javascript: { 'js-confirm_delete': string; 'js-enter_password': string; }; title: string; verify_message: string; resend_message: string[]; verify_confirm: string; joined: string; seen: string; practice_progress: string; ranked_elo: string; infinity_leaderboard_position: string; infinity_leaderboard_rating_deviation: string; reveal_info: string; account_info_heading: string; email: string; delete_account: string; 'badge-tooltips': { checkmate_bronze: string; checkmate_silver: string; checkmate_gold: string; }; }; leaderboard: { javascript: { supported_variants: string; rank: string; player: string; rating: string; }; title: string; inactive_players: string[]; your_global_ranking: string; show_more: string; }; index: { title: string; secondary_title: string; what_is_it_title: string; what_is_it_pargaraphs: string[]; how_to_title: string; how_to_paragraph: string[]; about_title: string; about_paragraphs: string[]; patreon_title: string; github_title: string; javascript: { contribution_count_singular: string[]; contribution_count_plural: string[]; }; }; credits: { title: string; copyright: string; variants_heading: string; variants_credits: string[]; textures_heading: string; textures_licensed_under: string; sounds_heading: string; sounds_credits: string[]; code_heading: string; code_credits: string[]; language_heading: string; language_credits: string[]; }; 'create-account': { title: string; username: string; email: string; password: string; create_button: string; agreement: string[]; javascript: { 'js-username_reserved': string; 'js-username_length': string; 'js-username_tooshort': string; 'js-username_wrongenc': string; 'js-email_invalid': string; 'js-email_too_long': string; 'js-email_inuse': string; }; }; 'reset-password': { javascript: { 'js-pwd_no_match': string; 'reset-password': string; processing: string; 'network-error': string; }; }; 'password-validation': { 'js-pwd_too_short': string; 'js-pwd_too_long': string; 'js-pwd_not_pwd': string; }; play: { title: string; loading: string; error: string; 'main-menu': { credits: string; play: string; practice: string; guide: string; editor: string; }; editor: { position: string; tools: string; selection: string; palette: string; color: string; tooltip_reset: string; tooltip_clear: string; tooltip_load: string; tooltip_save_as: string; tooltip_save: string; tooltip_copy_notation: string; tooltip_paste_notation: string; tooltip_gamerules: string; tooltip_start_local: string; tooltip_start_engine: string; tooltip_normal: string; tooltip_eraser: string; tooltip_selection_tool: string; tooltip_specialrights: string; tooltip_select_all: string; tooltip_clear_selection: string; tooltip_copy_selection: string; tooltip_paste_selection: string; tooltip_invert_color: string; tooltip_rotate_left: string; tooltip_rotate_right: string; tooltip_flip_horizontal: string; tooltip_flip_vertical: string; reset_header: string; reset_message: string; clear_header: string; clear_message: string; enter_position_name: string; save_button: string; name_header: string; pieces_header: string; date_header: string; no_saves: string; gamerules_header: string; player_to_move: string; white: string; black: string; en_passant: string; move_rule: string; promotion_ranks_white: string; promotion_ranks_black: string; promotion_pieces: string; global_special_rights: string; pawn_double_push: string; castling_label: string; win_conditions: string; checkmate: string; royal_capture: string; all_royals_captured: string; all_pieces_captured: string; world_border: string; start_local_game: string; start_local_game_message: string; start_engine_game: string; play_as: string; time_control: string; engine_difficulty: string; easy: string; medium: string; hard: string; use_default_border: string; start_engine_game_message: string; yes: string; no: string; }; guide: { title: string; rules: string; rules_paragraphs: string[]; careful_heading: string; careful_paragraphs: string[]; controls_heading: string; controls_paragraph: string; keybinds: string[]; controls_paragraph2: string; keybinds_extra: string[]; fairy_heading: string; fairy_paragraph: string; back: string; pieces: { chancellor: { name: string; description: string; }; archbishop: { name: string; description: string; }; amazon: { name: string; description: string; }; guard: { name: string; description: string; }; hawk: { name: string; description: string; }; centaur: { name: string; description: string; }; knightrider: { name: string; description: string; }; huygen: { name: string; description: string; }; rose: { name: string; description: string; }; obstacle: { name: string; description: string; }; void: { name: string; description: string; }; }; }; 'practice-menu': { title: string; play: string; back: string; difficulty: string; }; 'play-menu': { title: string; colors: string; online: string; local: string; computer: string; variant: string; Classical: string; Confined_Classical: string; Classical_Plus: string; CoaIP: string; Pawndard: string; Knighted_Chess: string; Palace: string; Knightline: string; Core: string; Standarch: string; Pawn_Horde: string; Space_Classic: string; Space: string; Obstocean: string; Abundance: string; Amazon_Chandelier: string; Containment: string; Classical_Limit_7: string; CoaIP_Limit_7: string; Chess: string; Classical_KOTH: string; CoaIP_KOTH: string; CoaIP_HO: string; CoaIP_RO: string; CoaIP_NO: string; Omega: string; Omega_Squared: string; Omega_Cubed: string; Omega_Fourth: string; '4x4x4x4_Chess': string; '5D_Chess': string; no_clock: string; clock: string; minutes: string; seconds: string; infinite_time: string; color: string; piece_colors: string[]; private: string; no: string; yes: string; rated: string; casual: string; easy: string; medium: string; hard: string; join_games: string; private_invite: string; your_invite: string; create_invite: string; join: string; copy: string; back: string; code: string; }; gamebuttontooltips: { undo_transition: string; expand_fit_all: string; recenter: string; annotations: string; erase: string; collapse: string; rewind_move: string; forward_move: string; undo_edit: string; redo_edit: string; pause: string; undo: string; restart: string; }; pause: { title: string; resume: string; arrows: string; perspective: string; copy: string; paste: string; offer_draw: string; practice_menu: string; main_menu: string; }; drawoffer: { question: string; }; javascript: { guest_indicator: string; you_indicator: string; engine_indicator: string; player_name_white_generic: string; player_name_black_generic: string; white_to_move: string; black_to_move: string; your_move: string; their_move: string; lost_network: string; failed_to_load: string; planned_feature: string; main_menu: string; resign_game: string; abort_game: string; offer_draw: string; accept_draw: string; arrows_off: string; arrows_defense: string; arrows_all: string; arrows_all_hippogonals: string; toggled: string; menu_online: string; menu_local: string; menu_computer: string; invite_error_digits: string; invite_copied: string; move_counter: string; constructing_mesh: string; rotating_mesh: string; lost_connection: string; please_wait: string; webgl_unsupported: string; bigints_unsupported: string; versus: string; easy: string; medium: string; hard: string; insane: string; checkmate_logged_out: string; checkmate_bronze: string; checkmate_silver: string; checkmate_gold: string; checkmate_bronze_unearned: string; checkmate_silver_unearned: string; checkmate_gold_unearned: string; 'coords-invalid': string; 'coords-exceeded': string; piecenames: { void: string; obstacle: string; king: string; giraffe: string; camel: string; zebra: string; knightrider: string; amazon: string; queen: string; royalQueen: string; hawk: string; chancellor: string; archbishop: string; centaur: string; royalCentaur: string; rose: string; knight: string; guard: string; huygen: string; rook: string; bishop: string; pawn: string; }; copypaste: { copied_game: string; cannot_paste_in_public: string; cannot_paste_in_rated: string; cannot_paste_in_engine: string; cannot_paste_after_moves: string; clipboard_denied: string; clipboard_invalid: string; game_needs_to_specify: string; pasting_game: string; pasting_in_private: string; piece_count: string; exceeded: string; changed_wincon: string; loaded_from_clipboard: string; copied_position: string; loaded_position_from_clipboard: string; reset_position: string; clear_position: string; }; rendering: { on: string; off: string; icon_rendering_off: string; icon_rendering_on: string; perspective: string; perspective_mode_on_desktop: string; movement_tutorial: string; regenerated_pieces: string; }; invites: { move_mouse: string; cannot_cancel: string; you_are_white: string; you_are_black: string; random: string; accept: string; cancel: string; create_invite: string; cancel_invite: string; start_game: string; join_existing_active_games: string; }; onlinegame: { afk_warning: string; opponent_afk: string; opponent_disconnected: string; opponent_lost_connection: string; auto_resigning_in: string; auto_aborting_in: string; not_logged_in: string; game_no_longer_exists: string; another_window_connected: string; }; websocket: { no_connection: string; reconnected: string; unable_to_identify_ip: string; online_play_disabled: string; too_many_requests: string; message_too_big: string; too_many_sockets: string; origin_error: string; connection_closed: string; please_report_bug: string; malformed_message: string; }; results: { you_checkmate: string; you_time: string; you_resignation: string; you_disconnect: string; you_royalcapture: string; you_allroyalscaptured: string; you_allpiecescaptured: string; you_koth: string; you_generic: string; draw_stalemate: string; draw_repetition: string; draw_moverule: string[]; draw_insuffmat: string; draw_agreement: string; draw_generic: string; aborted: string; opponent_checkmate: string; opponent_time: string; opponent_resignation: string; opponent_disconnect: string; opponent_royalcapture: string; opponent_allroyalscaptured: string; opponent_allpiecescaptured: string; opponent_koth: string; opponent_generic: string; white_checkmate: string; black_checkmate: string; white_time: string; black_time: string; white_resignation: string; black_resignation: string; white_disconnect: string; black_disconnect: string; white_royalcapture: string; black_royalcapture: string; white_allroyalscaptured: string; black_allroyalscaptured: string; white_allpiecescaptured: string; black_allpiecescaptured: string; white_koth: string; black_koth: string; bug_generic: string; }; editor: { expand_sidebar: string; collapse_sidebar: string; new_position: string; load_position_header: string; save_position_as_header: string; delete_title: string; delete_message: string[]; load_title: string; load_message: string[]; overwrite_title: string; overwrite_message: string[]; tooltip_load_position: string; tooltip_save_to_cloud: string; tooltip_remove_from_cloud: string; tooltip_delete_position: string; position_loaded: string; cannot_start_local_empty: string; cannot_start_engine_empty: string; position_not_supported: string; illegal_position_king_capture: string; saved_in_browser: string; position_corrupted: string; failed_to_load: string; failed_to_convert_icn: string; too_large_for_cloud: string; failed_to_upload: string; saved_to_cloud: string; no_changes: string; failed_to_load_cloud: string; failed_to_delete_cloud: string; failed_to_remove_cloud: string; saved_locally: string; failed_to_fetch_cloud: string; }; }; }; terms: { title: string; warning: string[]; consent: string; guardian_consent: string; parents_header: string; parents_paragraphs: string[]; fair_play_header: string; fair_play_paragraph1: string[]; fair_play_paragraph2: string; fair_play_rules: string[]; cleanliness_header: string; cleanliness_rules: string[]; privacy_header: string; privacy_rules: string[]; cookie_header: string; cookie_paragraphs: string[]; conclusion_header: string; conclusion_paragraphs: string[]; thanks: string; }; login: { title: string; username: string; password: string; login_button: string; send_reset_link: string; forgot_question: string; back_to_login: string; forgot_instruction: string; javascript: { 'network-error': string; }; }; reset_password: { title: string; instruction: string; new_password: string; confirm_password: string; submit_button: string; }; 'error-pages': { '400_message': string; '409_message': string[]; '500_message': string; }; news: { title: string; more_dev_logs: string[]; }; server: { javascript: { 'ws-invalid_username': string; 'ws-incorrect_password': string; 'ws-login_failure_retry_in': string; 'ws-seconds': string; 'ws-second': string; 'ws-username_letters': string; 'ws-username_taken': string; 'ws-username_bad_word': string; 'ws-email_too_long': string; 'ws-email_invalid': string; 'ws-email_in_use': string; 'ws-email_domain_invalid': string; 'ws-email_blacklisted': string; 'ws-password_length': string; 'ws-password_password': string; 'ws-password-reset-link-sent': string; 'ws-password-change-success': string; 'ws-password-reset-token-invalid': string; 'ws-forbidden_wrong_account': string; 'ws-deleting_account_not_found': string; 'ws-deleting_account_in_game': string; 'ws-server_error': string; 'ws-not_found': string; 'ws-forbidden': string; 'ws-already_in_game': string; 'ws-you_cheated': string; 'ws-opponent_cheated': string; 'ws-cannot_resign_finished_game': string; 'ws-invalid_code': string; 'ws-game_aborted': string; 'ws-rated_invite_verification_needed': string; }; }; 'rate-limiting': { generic: string; error: string; }; } ================================================ FILE: translation/changes.json ================================================ { "99": { "note": "Renamed selection, selection-drag, selection-premove, selection-animations, selection-lingering_annotations on lines 27-31 to gameplay, gameplay-drag, gameplay-premove, gameplay-animations, gameplay-lingering_annotations. Added gameplay-fast_transitions on line 32." }, "98": { "note": "Added appearance-coordinates on line 21." }, "97": { "note": "Added credits.code_credits[2] 'by FirePlank.' on line 114." }, "96": { "note": "Added play.editor.no_saves on line 238." }, "95": { "note": "Removed ws-server_restarting, ws-server_under_maintenance, ws-minutes, ws-minute, lines 757-760. Also removed server_restarting, server_restarting_in, minute, minutes, lines 549-552. " }, "94": { "note": "Update server_restarting line 549 to reflect games are resumed now after server restarts." }, "93": { "note": "Deleted the whole [play.javascript.termination] section, lines 567-581" }, "92": { "note": "Deleted invalid_wincon line 505" }, "91": { "note": "Added illegal_position_king_capture line 654." }, "90": { "note": "Removed editing_heading and editing_paragraphs from [play.guide] lines 309-314" }, "89": { "note": "Added [play.editor] section (lines 193-272) and [play.javascript.editor] section (lines 634-672)." }, "88": { "note": "Added rate-limiting.error on line 671." }, "87": { "note": "Added malformed_message on line 491 under play.javascript.websocket." }, "86": { "note": "Added js-username_reserved & js-username_length lines 156-157. Deleted ws-username_length line 635. Deleted ws-username_reserved line 639." }, "85": { "note": "Changed seen on line 132 from an array to a single string, deleting the 'ago' part.", "changes": ["member.seen"] }, "84": { "note": "Added menu_computer on line 368." }, "83": { "note": "Replaced ws-game_aborted_cheating on line 659 with ws-you_cheated and ws-opponent_cheated." }, "82": { "note": "Added easy, medium, and hard, lines 302-304" }, "81": { "note": "Split inactive_players on line 174 into an array with two quotes." }, "80": { "note": "Renamed ws-you_are_banned to ws-email_blacklisted on line 639, and edited its message." }, "79": { "note": "Updated ToS sentence on line 560 'Abuse bugs or glitches in order to abort the game...'" }, "78": { "note": "Added [rate-limiting] section lines 662-663." }, "77": { "note": "Added js-email_too_long on line 159. Removed js-username_specs on line 155. Removed ws-password_format on line 642. Removed js-pwd_incorrect_format on line 168." }, "76": { "note": "Added contribution_count_singular and renamed contribution_count to contribution_count_plural (lines 75-76)." }, "75": { "note": "Modified undo_edit and redo_edit to include keyboard shortcuts on lines 320-321." }, "74": { "note": "Added Palace line 265. Added CoaIP_RO & CoaIP_NO on lines 282-283." }, "73": { "note": "Added section play.javascript.piecenames on lines 387-409. Added copied_position, loaded_position_from_clipboard, reset_position, clear_position on lines 427-430." }, "72": { "note": "Added play.guide.pieces.rose on line 242." }, "71": { "note": "Added coords-exceeded line 384." }, "70": { "note": "Renamed board, board-theme, and board-starfield to appearance, appearance-theme, appearance-starfield, lines 18-20. Also, added appearance-advanced-effects on line 21." }, "69": { "note": "Added sound, sound-master-volume, and sound-ambience, lines 32-34." }, "68": { "note": "Deleted shaders_failed & failed_compiling_shaders, lines 366-367." }, "67": { "note": "Added coords-invalid line 381" }, "66": { "note": "Added board-theme & board-starfield lines 19-20" }, "65": { "note": "Deleted slidelimit_not_number line 397. Changed invalid_wincon_white and invalid_wincon_black on line 389 to invalid_wincon." }, "64": { "note": "Added undo_edit and redo_edit on lines 310-311." }, "63": { "note": "Deleted password_reset_message 133" }, "62": { "note": "Added ws-deleting_account_in_game on line 611." }, "61": { "note": "Added German traslation credit 115" }, "60": { "note": "Changed lines 153-164, added all of reset-password.javascript, and moved js-pwd_incorrect_format, js-pwd_too_short, js-pwd_too_long, js-pwd_not_pwd to below the new password-validation section. Added ws-password-reset-token-invalid 604. Added all of login.javascript 567-569." }, "59": { "note": "Added ALL of reset_password lines 559-565" }, "58": { "note": "Deleted forgot_password 553. Added send_reset_link, forgot_question, back_to_login lines 554-556, ws-password-reset-link-sent, ws-password-change-success 588-589. Deleted ws-member_not_found 590, ws-username_and_password_required, ws-username_and_password_string 570-571, ws-unable_to_identify_client_ip, ws-you_are_banned_by_server, ws-too_many_requests_to_server, ws-bad_request 591-594. Added forgot_instruction 557." }, "57": { "note": "Deleted ws-no_abort_game_over and ws-no_abort_after_moves on lines 601-602." }, "56": { "note": "Deleted bug_koth line 504, bug_threecheck 501, bug_allpiecescaptured 498, bug_allroyalscaptured 495, bug_royalcapture 492, bug_time 489, bug_checkmate 486, black_threecheck 495, white_threecheck 494, opponent_threecheck 481, you_threecheck 464, threecheck 448. Added white_resignation, black_resignation, white_disconnect, black_disconnect line 485, " }, "55": { "note": "Added cannot_paste_in_rated line 373." }, "54": { "note": "Reworded terms.fair_play_paragraph1 line 517, terms.fair_play_rules line 520, and added line 521. Deleted update on line 554." }, "53": { "note": "Added header.leaderboard on line 14. Added leaderboard.javascript with 4 entries on line 43. Added infinity_leaderboard_rating_deviation on line 127. Added new leaderboard category with 4 entries on line 158. Added engine_indicator line 323. Deleted you_indicator line 401." }, "52": { "note": "Deleted textures_credits line 90." }, "51": { "note": "Added member.infinity_leaderboard_position on line 122 and added server.javascript.ws-rated_invite_verification_needed on line 603" }, "50": { "note": "Added annotations, erase, and collapse, lines 285-287." }, "49": { "note": "Added selection-lingering_annotations, line 25." }, "48": { "note": "Added selection-animations, line 24." }, "47": { "note": "Added ranked_elo on line 119." }, "46": { "note": "On line 183 added a note that holding Control will force-drag the board." }, "45": { "note": "Added credits for 3 new variants 'Confined Classical', '4x4x4x4 Chess', and '5D Chess' on lines 82-84." }, "44": { "note": "Replaced progress_checkmate on line 333 with a comment, and added 7 more translations on lines 339-345 in play.javascript" }, "43": { "note": "Added all of member.badge-tooltips, lines 122-125" }, "42": { "note": "Added member.practice_progress to line 115" }, "41": { "note": "Replaced play.play-menu.5D_Chess on line 245 with 4x4x4x4_Chess and 5D_Chess" }, "40": { "note": "Deleted toggled_edit, line 356" }, "39": { "note": "Deleted checkmates and tactics, lines 208-209. Modified title line 207. Deleted menu_checkmate line 326" }, "38": { "note": "Deleted Trappist_1 line 244, and deleted credit for Trappist-1 on line 82." }, "37": { "note": "Added the huygen to play.guide.pieces (line 203)." }, "36": { "note": "Added practice_menu to play.pause on line 288" }, "35": { "note": "Added undo and restart to play.gamebuttontooltips on lines 277-278" }, "34": { "note": "Moved versus, easy, medium, hard and insane from play.practice-menu to play.javascript on lines 328-332" }, "33": { "note": "Added progress_checkmate on line 332 under play.javascript" }, "32": { "note": [ "Added practice key on line 149 under play.main-menu.", "Added play.practice-menu section on line 206, and everything beneath it.", "Added menu_checkmate key on line 331.", "Added cannot_paste_in_engine key on line 336 under play.javascript.copypaste." ] }, "31": { "note": "Added variant credits for 'Chess on an Infinite Plane - Huygens Options by V. Reinhart.' and 'Trappist-1 by V. Reinhart' on lines 81-82." }, "30": { "note": "Added email_domain_invalid, line 539." }, "29": { "note": [ "Added CoaIP_HO and Trappist_1, lines 230-231. These contain the names of the new variants.", "Added Confined_Classical, line 213.", "Added arrows_all_hippogonals, line 302" ] }, "28": { "note": [ "Deleted unknown_action_received_1 & unknown_action_received_2 on lines 346-347", "Deleted all of play.footer, lines 264-267", "Added player_name_white_generic and player_name_black_generic, lines 280 & 281" ] }, "27": { "note": "Added index.github_title and index.javascript.contribution_count, lines 57-60" }, "26": { "note": "Modified webgl_unsupported, line 303", "changes": ["play.javascript.webgl_unsupported"] }, "25": { "note": "Added the following in header.settings, lines 21-23: selection, selection-drag, selection-premove." }, "24": { "note": "Deleted play.javascript.copypaste.loaded, line 323" }, "23": { "note": [ "Deleted member.rating, line 105", "Deleted ws-unauthorized_patron_page, line 548", "Deleted ws-refresh_token_not_found_logged_out, ws-refresh_token_not_found, ws-refresh_token_expired, and ws-refresh_token_invalid, lines 534-537", "Updated index.how_to_paragraph, line 47", "Added play.play-menu.5D_Chess, line 227" ] }, "22": { "note": "Deleted all of header.javascript (lines 19-23). Changed the value of header.home (line 7). Added header.profile (line 11) & header.logout (line 10). Deleted disable_holiday_theme_desktop and disable_holiday_theme_mobile (lines 299-300). Added all of header.settings (lines 15-25)." }, "21": { "note": "Added notices for how to disable the holiday theme, lines 299-300. DELETED IN UPDATE 22." }, "20": { "note": "Added fields for game button tooltips, lines 241-247", "changes": ["play.gamebuttontooltips"] }, "19": { "note": "Updated wording of verify_message and resend_message, lines 94-95" }, "18": { "note": "Added Spanish translation credit, line 89" }, "17": { "note": "Added credits.language_heading and credits.language_credits, line 82" }, "16": { "note": "Deleted all news posts. The only keys you keep in the `news` section is `title` and `more_dev_logs`! Please verify your language's news posts have been translated correctly within translation/news!" }, "15": { "note": "Added news.sept11-2024, line 493. ALL NEWS POSTS DELETED IN FUTURE CHANGE." }, "14": { "note": "Added translations for the tab titles. Added [play] and play.title on lines 116 & 117. Changed play.main-menu.loading to play.loading, and changed play.main-menu.error to play.error", "changes": [ "index.title", "member.title", "play.title", "play.main-menu.loading", "play.main-menu.error", "play.loading", "play.error" ] }, "13": { "note": "Added draw offers. Deleted ws-player_already_has_invite, ws-accept_own_invite, and ws-invite_cancelled", "changes": [ "play.pause.offer_draw", "play.drawoffer.question", "play.javascript.offer_draw", "play.javascript.accept_draw", "play.javascript.termination.agreement", "play.javascript.results.draw_agreement", "server.javascript.ws-player_already_has_invite", "server.javascript.ws-accept_own_invite", "server.javascript.ws-invite_cancelled" ] }, "12": { "note": "Corrected opponen_resignation to opponent_resignation", "changes": ["play.javascript.results.opponen_resignation"] }, "11": { "note": "Changed the structure of play.javascript.termination.moverule", "changes": ["play.javascript.termination.moverule"] }, "10": { "note": "Added news post Aug 1, 2024. Deleted news post Aug 26, 2023.", "changes": [ "news.aug1-2024.date", "news.aug1-2024.text_top", "news.aug1-2024.update_list", "news.aug26-2023.date", "news.aug26-2023.text_top", "news.aug26-2023.text_box" ] }, "9": { "note": "In the guide, under controls, where it describes what the Tab key does, added sentence 'Clicking these arrows will teleport you to the piece they're pointing to.' line 142. Also changed controls_paragraph, line 137.", "changes": ["play.guide.controls_paragraph", "play.guide.keybinds"] }, "8": { "note": "Added a knightrider description for the guide, on line 170.", "changes": ["play.guide.pieces.knightrider"] }, "7": { "note": "Added ws-server_under_maintenance on line 561.", "changes": ["server.javascript.ws-server_under_maintenance"] }, "6": { "note": "Added all of play.javascript.termination, line 344, which provides spoken language descriptions of what caused the termination of the game." }, "5": { "note": "Deleted create-account.argreement on line 100, and replaced it with create-account.agreement which is different. Deleted play.play-menu.variants. Added several entries to play.play-menu. Added several entries to server.javascript.", "changes": [ "create-account.argreement", "create-account.agreement", "play.play-menu.variants", "play.play-menu.Classical", "play.play-menu.Classical_Plus", "play.play-menu.CoaIP", "play.play-menu.Pawndard", "play.play-menu.Knighted_Chess", "play.play-menu.Knightline", "play.play-menu.Core", "play.play-menu.Standarch", "play.play-menu.Pawn_Horde", "play.play-menu.Space_Classic", "play.play-menu.Space", "play.play-menu.Obstocean", "play.play-menu.Abundance", "play.play-menu.Amazon_Chandelier", "play.play-menu.Containment", "play.play-menu.Classical_Limit_7", "play.play-menu.CoaIP_Limit_7", "play.play-menu.Chess", "play.play-menu.Classical_KOTH", "play.play-menu.CoaIP_KOTH", "play.play-menu.Omega", "play.play-menu.Omega_Squared", "play.play-menu.Omega_Cubed", "play.play-menu.Omega_Fourth", "play.play-menu.no_clock", "play.play-menu.casual", "server.javascript.ws-username_reserved", "server.javascript.ws-already_in_game", "server.javascript.ws-player_already_has_invite", "server.javascript.ws-invite_cancelled", "server.javascript.ws-accept_own_invite", "server.javascript.ws-server_restarting", "server.javascript.ws-minutes", "server.javascript.ws-minute", "server.javascript.ws-no_abort_game_over", "server.javascript.ws-no_abort_after_moves", "server.javascript.ws-game_aborted_cheating", "server.javascript.ws-cannot_resign_finished_game", "server.javascript.ws-invalid_code", "server.javascript.ws-game_aborted" ] }, "4": { "note": "Added news.july22-2024 on line 462", "changes": ["news.july22-2024"] }, "3": { "note": "added play.javascript.invites.start_game on line 314", "changes": ["play.javascript.invites.start_game"] }, "2": { "note": "added login.login_button on line 448", "changes": ["login.login_button"] }, "1": { "note": "inital commit of en-US-toml" } } ================================================ FILE: translation/de-DE.toml ================================================ name = "Deutsch" # Name of language english_name = "German" direction = "ltr" # Change to "rtl" for right to left languages version = "84" maintainer = "estetique_bs,tsevasa" [header] home = "Unendliches Schach" play = "Spielen" news = "Neuigkeiten" login = "Anmelden" profile = "Profil" createaccount = "Konto erstellen" logout = "Abmelden" leaderboard = "Bestenliste" [header.settings] language = "Sprache" appearance = "Erscheinungsbild" # Board color/theme and visual effects appearance-theme = "Brett" appearance-starfield = "Sternenfeld" # The Starfield space animation underneath void appearance-advanced-effects = "Grafik-Effekte" # Post processing and board tile effects at extreme distances legalmoves = "Legale Züge" # Legal moves shape legalmoves-squares = "Quadrate" legalmoves-dots = "Punkte" # Dots and 4 corner triangles selection = "Auswahl" selection-drag = "Figuren ziehen" selection-premove = "Vorauszüge" selection-animations = "Animationen" selection-lingering_annotations = "Verbleibende Anmerkungen" perspective = "Perspektive" # Perspective-mode perspective-mouse-sensitivity = "Mausempfindlichkeit" perspective-fov = "Sichtfeld" sound = "Ton" sound-master-volume = "Gesamtlautstärke" sound-ambience = "Umgebungsklang" ping = ["Ping", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Auf Standard zurücksetzen" [footer] contact = "Kontaktieren Sie uns" terms_of_service = "Nutzungsbedingungen" source_code = "Quellcode" language = "Sprache" [member.javascript] js-confirm_delete = "Sind Sie sicher, dass Sie Ihr Konto löschen möchten? Dies kann NICHT rückgängig gemacht werden! Klicken Sie auf OK, um Ihr Passwort einzugeben." js-enter_password = "Geben Sie Ihr Passwort ein, um Ihr Konto ENDGÜLTIG zu löschen:" [leaderboard.javascript] supported_variants = "Diese Bestenliste wird für die folgenden Varianten verwendet:" rank = "Rang" player = "Spieler" rating = "Bewertung" [index] title = "Unendliches Schach | Startseite - Die offizielle Website" # The tab title secondary_title = "Die offizielle Website für Live-Spiele!" what_is_it_title = "Was ist das?" what_is_it_pargaraphs = [ "Unendliches Schach ist eine Schachvariante, bei der das Brett keinen Rand hat, und somit viel größer als das vertraute 8x8-Brett ist. Die Dame, Türme und Läufer haben keine Begrenzung darin, wie weit sie pro Zug ziehen können. Wählen Sie eine beliebige natürliche Zahl bis zur Unendlichkeit!", "Ohne Einschränkung der Zugweite sind Stellungen möglich, bei denen die Schachmatt-Uhr oder Schachmatt-in-#-Zahl durch die erste unendliche Ordinalzahl, ω (Omega), dargestellt wird. Tatsächlich haben Forscher zeigen können, dass die Schachmatt-Uhr den Wert jeder abzählbaren Ordinalzahl annehmen kann!", "Wie Sie sich vorstellen können, gibt es eine unendliche Anzahl an möglichen Startkonfigurationen, von denen Sie viele auf dieser Webseite kompetitiv spielen können! Ihr Endziel ist immer noch Schachmatt, was neue Taktiken erfordert, da es keine Ränder gibt, an die der feindliche König gedrängt werden kann. Ein Spiel dauert normalerweise nicht viel länger als normale Schachpartien. Bauern wandeln weiterhin auf der ersten bzw. achten Reihe um!", ] how_to_title = "Wie kann ich spielen?" how_to_paragraph = ["Die aktuelle Version 1.10 ist auf der Seite ","Spielen"," verfügbar!"] about_title = "Über das Projekt" about_paragraphs = [ "Ich bin Naviary. Seit ich das Unendliche Schach (das Konzept existierte lange vor dieser Website) zum ersten Mal entdeckte, hat es mich sehr fasziniert und mit seinen Möglichkeiten begeistert! Bis vor kurzem war das Spielen ziemlich schwierig, da die Spieler Bilder des aktuellen Brettes erstellen und für jeden gespielten Zug hin und her senden mussten. Deswegen wissen wenige davon oder haben es spielen können.", ["Mein Ziel ist es, eine Möglichkeit zu schaffen, Unendliches Schach für jeden leicht spielbar zu machen und eine Community darum herum aufzubauen. Ich habe unzählige Stunden meiner eigenen Zeit für diese Website aufgewendet, sie gepflegt und das Spiel entwickelt. Ich habe viele weitere Ideen, die mich noch einige Zeit beschäftigen werden. Obwohl ich dies kostenlos anbieten möchte, hat das Leben seine Anforderungen. Um mich finanziell zu unterstützen, ziehen Sie bitte in Betracht, meinem ", "Patreon", " beizutreten."] ] patreon_title = "Patreon Unterstützer" github_title = "Github Mitwirkende" [index.javascript] contribution_count_singular = ["", " Beitrag"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " Beiträge"] [credits] title = "Credits" copyright = "Alles auf der Website, was nicht unten aufgeführt ist, unterliegt dem Copyright von www.InfiniteChess.org" variants_heading = "Varianten" variants_credits = [ "Core entworfen von Andreas Tsevas.", "Space entworfen von Andreas Tsevas.", "Space Classic entworfen von Andreas Tsevas.", "Coaip („Chess on an Infinite Plane“) entworfen von V. Reinhart.", "Pawn Horde entworfen von Inaccessible Cardinal.", "Abundance entworfen von Clicktuck Suskriberz.", "Pawndard von SexyLexi.", "Classical+ von SexyLexi.", "Knightline von Inaccessible Cardinal.", "Knighted Chess von cycy98.", "entworfen von Cory Evans und Joel Hamkins.", "entworfen von Andreas Tsevas.", "entworfen von Cory Evans und Joel Hamkins.", "entworfen von Cory Evans, Joel Hamkins und Norman Lewis Perlmutter.", "Chess on an Infinite Plane - Huygens Options von V. Reinhart.", "Confined Classical von Andreas Tsevas.", "4x4x4x4 Chess von Andreas Tsevas.", "5D Chess von Jace.", ] textures_heading = "Texturen" textures_licensed_under = "Texturen lizenziert unter der" sounds_heading = "Sounds" sounds_credits = [ ["Einige Sounds werden vom", "Projekt unter der"], "Andere Sounds erstellt von Naviary.", ] code_heading = "Code" code_credits = [ "von Brandon Jones und Colin MacKenzie IV.", "von Andreas Tsevas und Naviary.", ] language_heading = "Sprachübersetzungen" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Französisch von ", "Life Enjoyer", " und ", "cycy98", ".", "Vereinfachtes Chinesisch von ", "Heinrich Xiao", ".", "Traditionelles Chinesisch von ", "Heinrich Xiao", ".", "Polnisch von ", "Tymon Becella", ".", # Apsurt "Portugiesisch von ", "Emerson P. Machado", ".", # The_Skeleton on discord "Spanisch von ", "xa31er", ".", "Deutsch von ", "Estetique", "." ] [member] title = "Mitglied" # The tab name verify_message = "Bitte überprüfen Sie Ihre E-Mails, um Ihr Konto zu verifizieren. Unverifizierte Konten werden nach 3 Tagen gelöscht." resend_message = ["Keine E-Mail erhalten? Überprüfen Sie Ihren Spam-Ordner. Versuchen Sie auch, die ", "E-Mail erneut zu senden.", " Wenn Sie sie immer noch nicht finden können, ", "kontaktieren Sie uns."] verify_confirm = "Vielen Dank! Ihr Konto wurde verifiziert." joined = "Beigetreten:" seen = ["Zuletzt gesehen:", " her"] practice_progress = "Fortschritt im Übungsmodus:" ranked_elo = "Rating:" infinity_leaderboard_position = "Globale Rangliste:" infinity_leaderboard_rating_deviation = "Rating-Abweichung:" reveal_info = "Kontoinformationen anzeigen" account_info_heading = "Kontoinformationen" email = "E-Mail:" delete_account = "Konto löschen" [member.badge-tooltips] checkmate_bronze = "Schachmatt-Veteran: 50% aller Übungs-Schachmattes lösen." checkmate_silver = "Schachmatt-Profi: 75% aller Übungs-Schachmattes lösen." checkmate_gold = "Schachmatt-Meister: 100% aller Übungs-Schachmattes lösen." [create-account] title = "Konto erstellen" # The tab name username = "Benutzername:" email = "E-Mail:" password = "Passwort:" create_button = "Konto erstellen" agreement = ["Ich stimme den ", "Nutzungsbedingungen", " zu."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_tooshort = "Der Benutzername muss mindestens 3 Zeichen lang sein" js-username_wrongenc = "Der Benutzername darf nur Groß- und Kleinbuchstaben von A bis Z und Zahlen von 0 bis 9 enthalten" js-email_invalid = "Dies ist keine gültige E-Mail" js-email_too_long = "Die E-Mail ist zu lang" js-email_inuse = "Diese E-Mail ist bereits in Verwendung" [reset-password.javascript] js-pwd_no_match = "Die Passwörter stimmen nicht überein." reset-password = "Passwort zurücksetzen" processing = "In Bearbeitung..." network-error = "Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut." [password-validation] js-pwd_too_short = "Passwort muss mindestens 6 Zeichen lang sein" js-pwd_too_long = "Passwort darf nicht länger als 72 Zeichen sein" js-pwd_not_pwd = "Passwort darf nicht 'password' sein" [leaderboard] title = "Bestenliste" inactive_players = ["Inaktive Spieler mit Rating-Unsicherheit größer als ", " sind von der Bestenliste ausgeschlossen."] # A number is inserted between these two quotes your_global_ranking = "Ihr globaler Rang:" show_more = "Mehr anzeigen..." [play] title = "Unendliches Schach - Spielen" # The tab title loading = "LÄDT" error = "FEHLER" [play.main-menu] credits = "Credits" play = "Spielen" practice = "Üben" guide = "Anleitung" editor = "Brett-Editor" [play.guide] title = "Anleitung" rules = "Regeln" rules_paragraphs = [ "Die Regeln des Unendlichen Schachs sind fast identisch mit denen des klassischen Schachs, außer dass das Brett in alle Richtungen unendlich ist! Dies sind die einzigen Hinweise und Änderungen, die Sie beachten müssen:", "Figuren mit gleitenden Zügen, wie Türme, Läufer, und Damen, haben keine Einschränkung darin, wie weit sie in einem Zug ziehen können! Solange ihr Weg frei ist, können sie Millionen von Feldern ziehen!", ["In der Grundvariante „Klassisch“ wandeln weiße Bauern auf Reihe 8 und schwarze Bauern auf Reihe 1 um. Dies wird, wie Sie im Bild sehen können, durch die dünnen schwarzen Linien angedeutet. Bauern müssen nur die gegenüberliegende Linie erreichen, ", "nicht", " sie überqueren, um umzuwandeln."], "Felder werden nicht länger durch ihren Buchstaben und ihre Reihennummer (z.B. a1) beschrieben; stattdessen wird jedes Feld durch ein Paar von x- und y-Koordinaten definiert. Das Feld a1 ist zu (1,1) geworden, und das Feld h8 zu (8,8). Auf Desktop-Geräten werden die Koordinaten, über denen sich Ihre Maus befindet, oben auf dem Bildschirm angezeigt.", "Alle anderen Regeln sind die gleichen wie im klassischen Schach, wie Schachmatt, Patt, dreifache Stellungswiederholung, die 50-Züge-Regel, Rochade, En Passant usw.!" ] careful_heading = "Seien Sie vorsichtig!" careful_paragraphs = [ "Die Offenheit des unendlichen Bretts macht es sehr einfach, Gabeln, Fesselungen und Spieße auszunutzen. Ihre Rückseite ist oft sehr anfällig. Achten Sie auf solche Taktiken! Seien Sie kreativ beim Bilden von Schutz für Ihren König und Ihre Türme! Die Eröffnungsstrategie unterscheidet sich stark vom klassischen Schach.", "Viele andere Varianten wurden geschaffen, um Ihre Rückseite zu stärken." ] controls_heading = "Steuerung" controls_paragraph = "Klicken und ziehen Sie das Brett, um sich zu bewegen. Scrollen Sie, um hinein- und herauszuzoomen. Klicken Sie auf eine beliebige Figur, einschließlich der Figuren Ihres Gegners, um jederzeit ihre legalen Züge anzuzeigen! Zusätzliche Steuerelemente sind:" keybinds = [ " um das Sichtfeld zu bewegen.", ["Leertaste", " und ", "Shift", " zum Hinein- und Herauszoomen."], ["Escape", " pausiert das Spiel."], ["Tab", " schaltet die Pfeilindikatoren am Bildschirmrand um, die auf Figuren außerhalb des Bildschirms weisen. Standardmäßig ist diese Einstellung auf „Verteidigung“ eingestellt, sodass Pfeile für die Figuren angezeigt werden, die sich auf Felder ihres Sichtfeldes bewegen könnten. ", "Tab", " kann diesen Modus auf „Alle“ oder „Aus“ umschalten; „Alle“ zeigt Pfeile für alle Figuren an, unabhängig davon, ob sie sich auf Ihren Bildschirm bewegen können. Diese Einstellung kann auch im Pausenmenü umgeschaltet werden. Das Klicken auf einen Pfeil teleportiert Sie zu der Figur, auf die er zeigt."], ["Strg", " erzwingt das Ziehen des Bretts anstatt einer Figur, wenn das Ziehen in den Einstellungen aktiviert ist."], " schaltet auf den „Bearbeitungsmodus“ in lokalen Spielen um. Dies ermöglicht Ihnen, jede Figur überall auf das Brett zu bewegen! Sehr nützlich für die Analyse." ] controls_paragraph2 = "Das waren die wichtigsten Steuerungen, die Sie kennen müssen. Aber hier sind einige Extras, falls Sie sie jemals benötigen sollten!" keybinds_extra = [ " setzt die Darstellung der Figuren zurück. Das ist nützlich, wenn sie unsichtbar werden. Dieser Glitch kann auftreten, wenn Sie die Figuren extreme Entfernungen (wie 1e21) bewegen.", " schaltet die Darstellung der Navigations- und Spielinformationsleisten um, was für Aufnahmen nützlich sein kann. Streaming und das Erstellen von Videos über das Spiel sind willkommen!", " schaltet den FPS-Zähler ein. Dieser zeigt an, wie oft das Spiel pro Sekunde aktualisiert wird, nicht immer die Anzahl der gerenderten Frames, da das Spiel das Rendern überspringt, wenn sich nichts Sichtbares geändert hat, um die Leistung zu steigern.", " schaltet die das Rendering der Icons um. Diese sind anklickbare Mini-Bilder der Figuren, wenn weit genug herausgezoomt wird. Bei importierten Spielen mit über 50.000 Figuren wird dies automatisch ausgeschaltet, da es die Leistung drastisch reduziert.", [" (Backtick, auf der gleichen Taste wie ", ") schaltet in den Debug-Modus um."], ] fairy_heading = "Märchenschachfiguren" fairy_paragraph = "Sie wissen bereits alles Nötige, um die Standardvariante „Klassisch“ zu spielen. Märchenschachfiguren werden im konventionellen Schach nicht verwendet, sind aber in andere Varianten integriert! Wenn Sie sich in einer Variante mit Ihnen unbekannten Figuren wiederfinden, erfahren Sie hier, wie sie funktionieren!" editing_heading = "Brettbearbeitung" editing_paragraphs = [ ["Es ist derzeit ein externer ", "Brett-Editor", " auf einem öffentlichen Google Sheet verfügbar! Dieses enthält Anweisungen zur Verwendung und erfordert grundlegende Google Sheets-Kenntnisse. Nach der Einrichtung können Sie benutzerdefinierte Positionen über die Schaltfläche „Spiel einfügen“ im Optionsmenü erstellen und in das Spiel importieren!"], "Um eine benutzerdefinierte Position mit einem Freund zu spielen, lassen Sie ihn eine private Einladung annehmen, dann können Sie beide den Spielcode einfügen, um zu spielen!", "Ein In-Game-Bretteditor ist noch geplant.", ] back = "Zurück" [play.guide.pieces] chancellor = {name="Kanzler", description="Zieht wie ein Turm und ein Springer zusammen."} archbishop = {name="Erzbischof", description="Zieht wie ein Läufer und ein Springer zusammen."} amazon = {name="Amazone", description="Zieht wie eine Dame und ein Springer zusammen. Dies ist die stärkste Figur im Spiel!"} guard = {name="Wache", description="Zieht wie ein König, ist aber nicht anfällig für Schach oder Schachmatt."} hawk = {name="Falke", description="Springt genau 2 oder 3 Felder in jede Richtung."} centaur = {name="Zentaur", description="Zieht wie ein Springer und eine Wache zusammen."} knightrider = {name="Rösselsprinter", description="Springt wie ein Springer unendlich weit in eine Richtung, bis er blockiert wird."} huygen = {name="Huygen", description="Springt unendlich weit in eine der vier Kardinalrichtungen, wobei er nur Felder mit primzahligem Abstand vom Startfeld besucht, bis er blockiert wird."} rose = {name="Rose", description="Zirkulärer Rösselsprinter. Die Rose bewegt sich auf kreisförmigen Bahnen im oder gegen den Uhrzeigersinn, indem sie wie ein Springer hüpft und sich nach jedem Hüpfen um 45 Grad dreht. Die Rose kann von anderen Figuren blockiert werden, weshalb das rote Feld im Bild für die Rose unerreichbar ist."} obstacle = {name="Hindernis", description="Eine neutrale Figur (von keinem Spieler kontrolliert), die die Bewegung blockiert, aber geschlagen werden kann."} void = {name="Leere", description="Eine neutrale Figur (nicht von einem Spieler kontrolliert), die die Abwesenheit eines Feldes darstellt. Figuren dürfen sich nicht durch oder auf sie bewegen."} [play.practice-menu] title = "Übung - Schachmatts" play = "Spielen" back = "Zurück" difficulty = "Schwierigkeit" [play.play-menu] title = "Spielen - Online" colors = "Farben" online = "Online" local = "Lokal" computer = "Computer" variant = "Variante" Classical = "Klassisch" Confined_Classical = "Eingegrenztes Klassisch" Classical_Plus = "Klassisch+" CoaIP = "Schach auf einer unendlichen Ebene" Pawndard = "Pawndard" Knighted_Chess = "Springer-Schach" Palace = "Palast" Knightline = "Springerschnur" Core = "Kern" Standarch = "Standard" Pawn_Horde = "Bauernhorde" Space_Classic = "Raum Klassisch" Space = "Raum" Obstocean = "Hindernismeer" Abundance = "Überfluss" Amazon_Chandelier = "Amazonenleuchter" Containment = "Eingrenzung" Classical_Limit_7 = "Klassisch - Limit 7" CoaIP_Limit_7 = "CoaIP - Limit 7" Chess = "Schach" Classical_KOTH = "Experimentell: Klassisch - KOTH" CoaIP_KOTH = "Experimentell: CoaIP - KOTH" CoaIP_HO = "Schach auf einer unendlichen Ebene - Huygens Option" CoaIP_RO = "Schach auf einer unendlichen Ebene - Rosen Option" CoaIP_NO = "Schach auf einer unendlichen Ebene - Rösselsprinter Option" Omega = "Showcase: Omega" Omega_Squared = "Showcase: Omega^2" Omega_Cubed = "Showcase: Omega^3" Omega_Fourth = "Showcase: Omega^4" 4x4x4x4_Chess = "4×4×4×4 Schach" 5D_Chess = "5D Schach" no_clock = "Keine Uhr" clock = "Uhr" minutes = "min" seconds = "s" infinite_time = "Unendliche Zeit" color = "Farbe" piece_colors = ["Zufällig", "Weiß", "Schwarz"] private = "Privat" no = "Nein" yes = "Ja" rated = "Gewertet" casual = "Ungewertet" easy = "Einfach" medium = "Mittel" hard = "Schwer" join_games = "Beitreten - Aktive Spiele:" private_invite = "Private Einladung:" your_invite = "Ihr Einladungscode:" create_invite = "Einladung erstellen" join = "Beitreten" copy = "Kopieren" back = "Zurück" code = "Code" [play.gamebuttontooltips] undo_transition = "Übergang rückgängig machen" expand_fit_all = "Auf alle passen" recenter = "Zentrieren" annotations = "Anmerkungen zeichnen" erase = "Anmerkungen löschen" collapse = "Anmerkungen einklappen" rewind_move = "Zug zurückspulen" forward_move = "Zug vorspulen" undo_edit = "Rückgängig (Strg+Z)" # Board editor redo_edit = "Wiederherstellen (Strg+Y)" # Board editor pause = "Pause" undo = "Zug rückgängig machen" # Checkmate practice game restart = "Spiel neu starten" # Checkmate practice game [play.pause] title = "Pausiert" resume = "Fortsetzen" arrows = "Pfeile: Verteidigung" perspective = "Perspektive: Aus" copy = "Spiel kopieren" paste = "Spiel einfügen" offer_draw = "Remis anbieten" practice_menu = "Übungsmenü" main_menu = "Hauptmenü" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Remis akzeptieren?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Gast)" you_indicator = "(Sie)" engine_indicator = "Engine" player_name_white_generic = "Weiß" player_name_black_generic = "Schwarz" white_to_move = "Weiß am Zug" black_to_move = "Schwarz am Zug" your_move = "Ihr Zug" their_move = "Zug des Gegners" lost_network = "Netzwerk verloren." failed_to_load = "Eine oder mehrere Ressourcen konnten nicht geladen werden. Bitte aktualisieren Sie." planned_feature = "Diese Funktion ist geplant!" main_menu = "Hauptmenü" resign_game = "Aufgeben" abort_game = "Spiel abbrechen" offer_draw = "Remis anbieten" # Offer draw button text in the pause menu accept_draw = "Remis akzeptieren" # Offer draw button text in the pause menu arrows_off = "Pfeile: Aus" arrows_defense = "Pfeile: Verteidigung" arrows_all = "Pfeile: Alle" arrows_all_hippogonals = "Pfeile: Alle (mit Hippogonalen)" toggled = "Eingeschaltet" menu_online = "Spielen - Online" menu_local = "Spielen - Lokal" menu_computer = "Spielen - Computer" invite_error_digits = "Einladungscode muss 5 Ziffern lang sein." invite_copied = "Einladungscode in Zwischenablage kopiert." move_counter = "Zug:" constructing_mesh = "Mesh wird konstruiert" rotating_mesh = "Mesh wird gedreht" lost_connection = "Verbindung verloren." please_wait = "Bitte warten Sie einen Moment, um diese Aufgabe auszuführen." webgl_unsupported = "Bitte aktualisieren Sie Ihren Browser! Er unterstützt WebGL2 nicht." bigints_unsupported = "BigInts werden nicht unterstützt. Bitte aktualisieren Sie Ihren Browser.\nBigInts werden benötigt, um das Brett unendlich zu machen." # Checkmate Practice versus = "gegen" easy = "Leicht" medium = "Mittel" hard = "Schwer" insane = "Wahnsinnig" checkmate_logged_out = "Sie müssen angemeldet sein, um Abzeichen zu verdienen." checkmate_bronze = "Schachmatt-Veteran: 50% aller Übungs-Schachmattes lösen." checkmate_silver = "Schachmatt-Profi: 75% aller Übungs-Schachmattes lösen." checkmate_gold = "Schachmatt-Meister: 100% aller Übungs-Schachmattes lösen." checkmate_bronze_unearned = "Lösen Sie 50% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen." checkmate_silver_unearned = "Lösen Sie 75% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen." checkmate_gold_unearned = "Lösen Sie 100% aller Übungs-Schachmattes, um dieses Abzeichen zu verdienen." coords-invalid = "Ungültiges Koordinatenformat. Bitte ganze Zahlen oder e-Notation eingeben (z. B. 1.23e4)." coords-exceeded = "So weit darf man nicht teleportieren! Das wäre ja zu einfach ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Leere" obstacle = "Hindernis" king = "König" giraffe = "Giraffe" camel = "Kamel" zebra = "Zebra" knightrider = "Rösselsprinter" amazon = "Amazone" queen = "Dame" royalQueen = "Royale Dame" hawk = "Falke" chancellor = "Kanzler" archbishop = "Erzbischof" centaur = "Zentaur" royalCentaur = "Royaler Zentaur" rose = "Rose" knight = "Springer" guard = "Wache" huygen = "Huygen" rook = "Turm" bishop = "Läufer" pawn = "Bauer" [play.javascript.copypaste] copied_game = "Spiel in Zwischenablage kopiert!" cannot_paste_in_public = "Spiel kann nicht in einem öffentlichen Match eingefügt werden!" cannot_paste_in_rated = "Spiel kann nicht in einem gewerteten Match eingefügt werden!" cannot_paste_in_engine = "Spiel kann nicht in einem Engine-Match eingefügt werden!" cannot_paste_after_moves = "Spiel kann nicht eingefügt werden, nachdem gezogen wurde!" clipboard_denied = "Zwischenablage-Berechtigung verweigert. Dies könnte Ihr Browser sein." clipboard_invalid = "Zwischenablage nicht in gültiger ICN-Notation." game_needs_to_specify = "Spiel muss entweder die Metadaten 'Variante' oder die Eigenschaft 'Position' angeben." invalid_wincon = "Spieler hat eine ungültige Gewinnbedingung" pasting_game = "Spiel wird eingefügt..." pasting_in_private = "Das Einfügen eines Spiels in einem privaten Match führt zu einer Desynchronisation, wenn Ihr Gegner nicht dasselbe tut!" piece_count = "Figurenanzahl" exceeded = "überschritten" changed_wincon = "Schachmatt-Gewinnbedingungen wurden auf Königsfang geändert und die Icon-Darstellung deaktiviert. Drücken Sie 'P' zum Reaktivieren (nicht empfohlen)." loaded_from_clipboard = "Spiel aus Zwischenablage geladen!" copied_position = "Position in Zwischenablage kopiert!" loaded_position_from_clipboard = "Position aus Zwischenablage geladen!" reset_position = "Position wurde zurückgesetzt!" clear_position = "Position wurde geleert!" [play.javascript.rendering] on = "An" off = "Aus" icon_rendering_off = "Icon-Darstellung deaktiviert." icon_rendering_on = "Icon-Darstellung aktiviert." perspective = "Perspektive" perspective_mode_on_desktop = "Perspektivmodus ist auf dem Desktop verfügbar!" movement_tutorial = "WASD zum Bewegen. Leertaste und Shift zum Zoomen." regenerated_pieces = "Figuren regeneriert." [play.javascript.invites] move_mouse = "Bewegen Sie die Maus, um die Verbindung wiederherzustellen." cannot_cancel = "Einladung mit undefinierter ID kann nicht abgebrochen werden." you_are_white = "Sie sind: Weiß" you_are_black = "Sie sind: Schwarz" random = "Zufällig" accept = "Annehmen" cancel = "Abbrechen" create_invite = "Einladung erstellen" cancel_invite = "Einladung abbrechen" start_game = "Spiel starten" join_existing_active_games = "Beitreten - Aktive Spiele:" [play.javascript.onlinegame] afk_warning = "Sie sind AFK." opponent_afk = "Gegner ist AFK." opponent_disconnected = "Gegner hat die Verbindung getrennt." opponent_lost_connection = "Gegner hat die Verbindung verloren." auto_resigning_in = "Automatische Aufgabe in" auto_aborting_in = "Automatischer Abbruch in" not_logged_in = "Sie sind nicht angemeldet. Bitte melden Sie sich an, um die Verbindung zu diesem Spiel wiederherzustellen." game_no_longer_exists = "Spiel existiert nicht mehr." another_window_connected = "Ein anderes Fenster hat sich verbunden." server_restarting = "Server wird in Kürze neu gestartet..." server_restarting_in = "Server wird neu gestartet in" minute = "Minute" minutes = "Minuten" [play.javascript.websocket] no_connection = "Keine Verbindung." reconnected = "Wieder verbunden." unable_to_identify_ip = "IP-Adresse kann nicht identifiziert werden." online_play_disabled = "Online-Spiel deaktiviert. Cookies werden nicht unterstützt. Versuchen Sie einen anderen Browser." too_many_requests = "Zu viele Anfragen. Versuchen Sie es später erneut." message_too_big = "Nachricht zu groß." too_many_sockets = "Zu viele Sockets" origin_error = "Ursprungsfehler." connection_closed = "Verbindung unerwartet getrennt. Server-Nachricht:" please_report_bug = "Dies sollte niemals passieren, bitte melden Sie diesen Fehler!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Schachmatt" stalemate = "Patt" repetition = "Dreifache Stellungswiederholung" moverule = ["", "-Züge-Regel"] # The game inserts a number inbetween these two strings insuffmat = "Unzureichendes Material" royalcapture = "Königsfang" allroyalscaptured = "Alle Könige geschlagen" allpiecescaptured = "Alle Figuren geschlagen" koth = "König des Hügels" resignation = "Aufgabe" agreement = "Vereinbarung" time = "Zeitüberschreitung" aborted = "Abgebrochen" # Game was cancelled (no elo exchanged) disconnect = "Verlassen" # A player left [play.javascript.results] you_checkmate = "Sie haben gewonnen durch Schachmatt!" you_time = "Sie haben auf Zeit gewonnen!" you_resignation = "Sie haben gewonnen durch Aufgabe!" you_disconnect = "Sie haben gewonnen durch Verlassen!" you_royalcapture = "Sie haben durch Schlagen des Königs gewonnen!" you_allroyalscaptured = "Sie haben gewonnen, da alle Könige geschlagen wurden!" you_allpiecescaptured = "Sie haben gewonnen, da alle Figuren geschlagen wurden!" you_koth = "Sie haben durch König des Hügels gewonnen!" you_generic = "Sie haben gewonnen!" draw_stalemate = "Remis durch Patt!" draw_repetition = "Remis durch Stellungswiederholung!" draw_moverule = ["Remis durch die ", "-Züge-Regel!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Remis aufgrund unzureichenden Materials!" draw_agreement = "Remis durch Vereinbarung!" draw_generic = "Remis!" aborted = "Spiel abgebrochen." opponent_checkmate = "Sie haben verloren durch Schachmatt!" opponent_time = "Sie haben auf Zeit verloren!" opponent_resignation = "Sie haben verloren durch Aufgabe!" opponent_disconnect = "Sie haben verloren durch Verlassen!" opponent_royalcapture = "Sie haben durch Schlagen des Königs verloren!" opponent_allroyalscaptured = "Sie haben verloren, da alle Könige gefangen wurden!" opponent_allpiecescaptured = "Sie haben verloren, da alle Figuren gefangen wurden!" opponent_koth = "Sie haben durch König des Hügels verloren!" opponent_generic = "Sie haben verloren!" white_checkmate = "Weiß gewinnt durch Schachmatt!" black_checkmate = "Schwarz gewinnt durch Schachmatt!" white_time = "Weiß gewinnt auf Zeit!" black_time = "Schwarz gewinnt auf Zeit!" white_resignation = "Weiß gewinnt durch Aufgabe!" black_resignation = "Schwarz gewinnt durch Aufgabe!" white_disconnect = "Weiß gewinnt durch Verlassen!" black_disconnect = "Schwarz gewinnt durch Verlassen!" white_royalcapture = "Weiß gewinnt durch Schlagen des Königs!" black_royalcapture = "Schwarz gewinnt durch Schlagen des Königs!" white_allroyalscaptured = "Weiß gewinnt, da alle Könige geschlagen wurden!" black_allroyalscaptured = "Schwarz gewinnt, da alle Könige geschlagen wurden!" white_allpiecescaptured = "Weiß gewinnt, da alle Figuren geschlagen wurden!" black_allpiecescaptured = "Schwarz gewinnt, da alle Figuren geschlagen wurden!" white_koth = "Weiß gewinnt durch König des Hügels!" black_koth = "Schwarz gewinnt durch König des Hügels!" bug_generic = "Ein Fehler ist aufgetreten, bitte melden Sie ihn!" [terms] title = "Nutzungsbedingungen" warning = ["DIESES DOKUMENT IST RECHTLICH NICHT BINDEND. Wir sind nur für die englische Version dieses Dokuments verantwortlich. Diese Übersetzung dient ausschließlich zu allgemeinen Informationszwecken. Sie können die offizielle englische Version ", "hier", " einsehen."] consent = "Durch die Nutzung dieser Website erklären Sie sich mit den folgenden Bedingungen einverstanden. Wenn Sie nicht zustimmen, müssen Sie die Nutzung der Website sofort einstellen." guardian_consent = "Wenn Sie unter 18 Jahre alt sind, müssen Sie die Zustimmung eines Elternteils oder Erziehungsberechtigten einholen, um diese Website zu nutzen und ein Konto zu erstellen." parents_header = "Eltern" parents_paragraphs = [ "Es gibt einen Algorithmus, der Benutzern das Festlegen ihres Namens auf gängige Schimpfwörter verbietet. Derzeit gibt es keine Kommunikationsmethode zwischen Mitgliedern auf der Website.", "Derzeit können Mitglieder kein eigenes Profilbild einstellen. Es ist geplant, diese Funktion zu erlauben. Zu diesem Zeitpunkt werden wir unser Bestes tun, um unangemessene Profilbilder zu verhindern.", ] fair_play_header = "Fair Play" fair_play_paragraph1 = ["Sie dürfen nicht mehr als ein Konto besitzen."] fair_play_paragraph2 = "Um das Spielen für alle unterhaltsam und fair zu halten, dürfen Sie NICHT:" fair_play_rules = [ "Den Code in irgendeiner Weise modifizieren oder manipulieren, einschließlich, aber nicht beschränkt auf: Verwendung von Konsolenbefehlen, lokalen Überschreibungen, benutzerdefinierten Skripten, Modifikation von HTTP-Anfragen, WebSocket-Nachrichten usw. Dies kann geschehen, um das Spiel absichtlich zu stören, illegale Züge zu spielen oder sich einen Vorteil zu verschaffen.", "Bugs oder Glitches ausnutzen, um das Spiel abzubrechen, sich einen Vorteil zu verschaffen oder das Spiel anderweitig unspielbar zu machen.", "In gewerteten Spielen Hilfe/Ratschläge von einer anderen Person oder einem Programm bezüglich Ihrer Züge erhalten. (Das Erstellen einer Engine ist in Ordnung und erwünscht, aber Sie müssen deren Verwendung auf ungewertete Gelegenheitsspiele beschränken)", "Rating-Punkte mit anderen Personen tauschen, indem Sie absichtlich verlieren, um das Rating Ihres Gegners zu erhöhen, oder indem Sie Rating-Punkte von einem Gegner erhalten, der beabsichtigt zu verlieren, um Ihre eigene Wertung zu erhöhen. Dies missbraucht das System und führt zu ungenauen Wertungen gemäß Ihrem Fähigkeitsniveau." ] cleanliness_header = "Sauberkeit" cleanliness_rules = [ "In all Ihrer Sprache auf der Website müssen Sie sauber bleiben, keine Obszönitäten oder Flüche verwenden. Sie dürfen niemanden schikanieren, belästigen oder bedrohen oder etwas Illegales tun. Sie dürfen keine anderen Benutzer oder Foren spammen.", "Sie dürfen keine unangemessenen, anzüglichen oder blutigen Bilder in Ihr Profil hochladen. Dies kann zu einem Bann oder zur Kündigung Ihres Kontos führen." ] privacy_header = "Datenschutz" privacy_rules = [ "Derzeit sammeln wir nur Ihre E-Mail-Adresse als persönliche Information. Dies dient dazu, die Konten der Nutzer zu verifizieren und eine Möglichkeit zu bieten, ihre Identität bei der Anforderung eines Passwort-Resets nachzuweisen. Wir versenden keine Werbe-E-Mails oder Angebote. Wir teilen keine E-Mail-Adresse von Nutzern mit anderen.", "InfiniteChess.org kann Daten über Ihre Nutzung der Website sammeln, einschließlich Ihrer IP-Adresse. Dies soll dazu beitragen, Angriffe von Bots und anderen unerwünschten Entitäten zu verhindern und genaue Statistiken in der Datenbank zu führen. Dies ist NICHT Ihre Wohnadresse.", "Alle Spiele, die Sie auf dieser Website spielen, werden zu öffentlichen Informationen. Wenn Sie anonym bleiben möchten, teilen Sie Ihren Benutzernamen nicht mit Freunden oder Familie. Wenn dies Ihr Wunsch ist, liegt es in Ihrer Verantwortung, sicherzustellen, dass niemand herausfindet, dass Ihr Benutzername mit Ihrer menschlichen Identität verbunden ist.", "Ihr Online-Status und die ungefähre letzte Aktivitätszeit auf der Website sind ebenfalls öffentliche Informationen.", ["Während InfiniteChess.org sich nach besten Kräften bemühen wird, die Konten und persönlichen Informationen aller zu schützen, können Sie uns im Falle eines Hacks oder Datenlecks nicht verklagen. Sollte ein Datenleck jemals auftreten, werden die Benutzer auf der ", "News", "-Seite benachrichtigt."], "Es sind keine Inhalte auf der Website zum Kauf verfügbar. Andere persönliche Informationen werden nicht gesammelt.", "Um Ihre privaten Informationen von unseren Servern löschen zu lassen, können Sie Ihr Konto über Ihre Profilseite löschen. Das Einzige, was wir NICHT löschen werden, ist Ihre Spielhistorie, die mit Ihrem Benutzernamen verknüpft ist, da alle Spiele öffentliche Informationen sind.", ] cookie_header = "Cookie-Richtlinie" cookie_paragraphs = [ "Diese Website verwendet Cookies, kleine Textdateien, die in Ihrem Browser gespeichert und beim Herstellen von Verbindungen an den Server gesendet werden. Der Zweck dieser Cookies besteht in der Validierung Ihrer Anmeldesitzung, dass Ihr Browser dem Schachspiel gehört, in dem er sich befindet, und Benutzerpräferenzen zu speichern, damit diese beim erneuten Besuch der Website erhalten bleiben. Die Website verwendet keine Drittanbieter-Cookies, Cookies werden nicht mit externen Parteien geteilt.", "Cookies sind für das korrekte Funktionieren dieser Website und des Spiels erforderlich. Wenn Sie nicht möchten, dass die Website Cookies speichert, müssen Sie die Nutzung der Website einstellen. Sie können in den Einstellungen Ihres Browsers vorhandene Cookies löschen. Durch die fortgesetzte Nutzung dieser Website stimmen Sie der Verwendung von Cookies zu." ] conclusion_header = "Fazit" conclusion_paragraphs = [ "Jede Verletzung dieser Bedingungen kann zu einem Bann oder zur Kündigung Ihres Kontos führen. InfiniteChess.org möchte jedem die Möglichkeit geben, zu spielen und Spaß zu haben! Wir behalten uns jedoch das Recht vor, jederzeit die Konten von Benutzern zu sperren oder zu kündigen, aus Gründen, die nicht offengelegt werden müssen. Es können keine Anklagen gegen uns erhoben werden.", ["Diese Nutzungsbedingungen können jederzeit geändert werden. Es liegt in IHRER Verantwortung, sich über die neuesten Änderungen auf dem Laufenden zu halten! Wenn diese Nutzungsbedingungen ein Update erhalten, wird diese Information auf der ", "News", "-Seite veröffentlicht. Wenn Sie zum Zeitpunkt eines Updates der Nutzungsbedingungen den neuen Bedingungen nicht zustimmen, müssen Sie die Nutzung der Website sofort einstellen. Sie können Ihr Konto von Ihrer Profilseite löschen. Wenn Sie Ihr Konto löschen, werden alle Ihre privaten Informationen und Kontodaten gelöscht, AUSSER Ihre Spielhistorie, die mit Ihrem Benutzernamen verknüpft ist, da dies öffentliche Informationen sind."], ["Diese Seite ist Open Source. Sie dürfen alles auf dieser Website kopieren oder verbreiten, solange Sie die in den ", "Lizenzbedingungen", " dargelegten Bedingungen einhalten! Sollte dieser Link defekt sein, liegt es in Ihrer Verantwortung, die Bedingungen zu finden."], "Wir können nicht garantieren, dass die Website zu 100% der Zeit läuft. Wir können auch nicht garantieren, dass Daten niemals beschädigt werden.", "Sie dürfen keine illegalen Aktivitäten auf der Website durchführen.", ["Wenn Sie Fragen zu diesen Bedingungen oder andere Fragen zur Website haben, ", "schreiben Sie uns eine E-Mail!"] ] thanks = "Vielen Dank!" [login] title = "Anmelden" # The tab name username = "Benutzername:" password = "Passwort:" login_button = "Anmelden" send_reset_link = "Zurücksetzungslink senden" forgot_question = "Passwort vergessen?" back_to_login = "Zurück zur Anmeldung" forgot_instruction = "Bitte geben Sie die mit Ihrem Konto verbundene E-Mail-Adresse ein." [login.javascript] network-error = "Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut." [reset_password] title = "Zurücksetzung Ihres Passworts" instruction = "Bitte geben Sie Ihr neues Passwort ein und bestätigen Sie es." new_password = "Neues Passwort" confirm_password = "Bestätigen Sie Ihr Passwort" submit_button = "Passwort zurücksetzen" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Ungültige Parameter wurden empfangen." 409_message = ["Möglicherweise gab es einen Konflikt mit Benutzername oder E-Mail. Bitte ", "laden Sie", " die Seite neu."] 500_message = "Dies sollte nicht passieren. Es gibt noch einiges zu debuggen!" [news] title = "Neuigkeiten" # The tab name more_dev_logs = ["Weitere Entwickler-Logs werden auf dem ", "offiziellen Discord", " und in den ", "chess.com Foren veröffentlicht!"] [server.javascript] ws-invalid_username = "Benutzername ist ungültig" ws-incorrect_password = "Passwort ist falsch" ws-login_failure_retry_in = "Anmeldung fehlgeschlagen, versuchen Sie es erneut in" ws-seconds = "Sekunden" # unit of time ws-second = "Sekunde" # unit of time ws-username_length = "Benutzername muss 3 bis 20 Zeichen lang sein" ws-username_letters = "Benutzername darf nur Groß- und Kleinbuchstaben von A bis Z und Zahlen von 0 bis 9 enthalten" ws-username_taken = "Dieser Benutzername ist bereits vergeben" ws-username_bad_word = "Dieser Benutzername enthält ein nicht erlaubtes Wort" ws-username_reserved = "Dieser Benutzername ist reserviert" ws-email_too_long = "Ihre E-Mail ist zu laaaaaaaang." ws-email_invalid = "Dies ist keine gültige E-Mail" ws-email_in_use = "Diese E-Mail ist bereits in Verwendung" ws-email_domain_invalid = "Ungültige Domain." ws-email_blacklisted = "Ihre E-Mail-Adresse ist gesperrt." ws-password_length = "Passwort muss 6 bis 72 Zeichen lang sein" ws-password_password = "Passwort darf nicht 'password' sein" ws-password-reset-link-sent = "Falls ein Konto mit dieser E-Mail Adresse existiert, wurde ein Link zur Zurücksetzung des Passworts gesendet." ws-password-change-success = "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie werden in Kürze auf die Anmeldeseite weitergeleitet." ws-password-reset-token-invalid = "Token zum Zurücksetzen des Passworts ist ungültig oder abgelaufen." ws-forbidden_wrong_account = "Zugang verweigert. Dies ist nicht Ihr Konto." ws-deleting_account_not_found = "Konto konnte nicht gelöscht werden. Konto nicht gefunden." ws-deleting_account_in_game = "Sie können Ihr Konto nicht löschen, während Sie noch mit einem Online-Spiel verbunden sind." ws-server_error = "Entschuldigung, es gab einen Serverfehler! Bitte gehen Sie zurück." ws-not_found = "404 Nicht gefunden" ws-forbidden = "Verboten." ws-already_in_game = "Sie sind bereits in einem Spiel." ws-server_restarting = "Der Server startet in" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "Server ist wegen Wartungsarbeiten offline. Schauen Sie bald wieder vorbei!" # Can be changed at will to change the display message. ws-minutes = "Minuten" # unit of time ws-minute = "Minute" # unit of time ws-you_cheated = "Hoppla! Sie haben einen illegalen Zug gemacht. Das Spiel wurde abgebrochen. Wenn dies ein Fehler war, bitte melden Sie diesen Bug!" ws-opponent_cheated = "Ihr Gegner hat versucht, einen illegalen Zug zu machen. Das Spiel wurde abgebrochen." ws-cannot_resign_finished_game = "Spiel kann nicht aufgegeben werden, es ist bereits beendet." ws-invalid_code = "Ungültiger Code!" # Invite code doesn't match any existing invites ws-game_aborted = "Spiel abgebrochen." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "Um gewertet zu spielen, müssen Sie mit einem verifizierten Konto angemeldet sein." [rate-limiting] generic = "Sie haben zu viele Anfragen gestellt, bitte versuchen Sie es später erneut." ================================================ FILE: translation/el-GR.toml ================================================ name = "Ελληνικά" # Name of language english_name = "Greek" direction = "ltr" # Change to "rtl" for right to left languages version = "84" maintainer = "tsevasa" [header] home = "Άπειρο Σκάκι" play = "Παίξτε" news = "Νέα" login = "Σύνδεση" profile = "Προφίλ" createaccount = "Δημιουργία Λογαριασμού" logout = "Αποσύνδεση" leaderboard = "Κατάταξη" [header.settings] language = "Γλώσσα" appearance = "Εμφάνιση" # Board color/theme and visual effects appearance-theme = "Σκακιέρα" appearance-starfield = "Αστρικό Πεδίο" # The Starfield space animation underneath void appearance-advanced-effects = "Προχωρημένα Εφέ" # Post processing and board tile effects at extreme distances legalmoves = "Νόμιμες Κινήσεις" # Legal moves shape legalmoves-squares = "Τετράγωνα" legalmoves-dots = "Τελείες" # Dots and 4 corner triangles selection = "Επιλογή" selection-drag = "Σύρσιμο κομματιών" selection-premove = "Προκινήσεις" selection-animations = "Κινούμενα εφέ" selection-lingering_annotations = "Παραμένουσες Σημειώσεις" perspective = "Προοπτική" # Perspective-mode perspective-mouse-sensitivity = "Ευαισθησία Ποντικιού" perspective-fov = "Οπτικό Πεδίο" sound = "Ήχος" sound-master-volume = "Κύρια Ένταση" sound-ambience = "Ατμόσφαιρα" ping = ["Ping", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Επαναφορά προεπιλογών" [footer] contact = "Επικοινωνία" terms_of_service = "Όροι Χρήσης" source_code = "Πηγαίος Κώδικας" language = "Γλώσσα" [member.javascript] js-confirm_delete = "Είστε σίγουροι ότι θέλετε να διαγράψετε τον λογαριασμό σας; Αυτό ΔΕΝ μπορεί να αναιρεθεί! Πατήστε OK για να εισαγάγετε τον κωδικό σας." js-enter_password = "Εισαγάγετε τον κωδικό σας για να διαγράψετε τον λογαριασμό σας ΜΟΝΙΜΑ:" [leaderboard.javascript] supported_variants = "Αυτή η κατάταξη χρησιμοποιείται για τις εξής αρχικές θέσεις:" rank = "Θέση" player = "Παίκτης" rating = "Βαθμολογία" [index] title = "Άπειρο Σκάκι | Αρχική - Επίσημος Ιστότοπος" # The tab title secondary_title = "Η επίσημη ιστοσελίδα για live παιχνίδια!" what_is_it_title = "Τι είναι;" what_is_it_pargaraphs = [ "Το Άπειρο Σκάκι είναι μια παραλλαγή του σκακιού χωρίς όρια, είναι πολύ μεγαλύτερο παιχνίδι από το κανονικό σκάκι στη γνωστή σκακιέρα 8x8. Η βασίλισσα, οι πύργοι και οι αξιωματικοί δεν έχουν κανένα όριο για το μήκος μιας κίνησης. Διαλέξτε οποιονδήποτε φυσικό αριθμό έως το άπειρο!", "Χωρίς όρια, υπάρχουν θέσεις όπου η απόσταση της παρτίδας από το ματ αναπαρίσταται από το πρώτο άπειρο τακτικό αριθμό, το ωμέγα. Μάλιστα, ερευνητές έχουν δείξει ότι οποιοσδήποτε αριθμήσιμος τακτικός αριθμός μπορεί να επιτευχθεί!", "Όπως μπορείτε να φανταστείτε, υπάρχουν άπειρες δυνατές αρχικές θέσεις, πολλές από τις οποίες μπορείτε να παίξετε ανταγωνιστικά εδώ! Ο τελικός στόχος του παιχνιδιού παραμένει το ματ, που απαιτεί νέες τακτικές αφού δεν υπάρχουν τοίχοι για την παγίδευση του αντίπαλου βασιλιά. Τα παιχνίδια συνήθως δεν διαρκούν πολύ παραπάνω από κανονικά παιχνίδια σκακιού. Τα πιόνια εξακολουθούν να προάγονται στις γραμμές 1 και 8!", ] how_to_title = "Πώς μπορώ να παίξω;" how_to_paragraph = ["Η τρέχουσα έκδοση είναι η 1.10 στη σελίδα ","Παίξτε","!"] about_title = "Σχετικά με το Έργο" about_paragraphs = [ "Είμαι ο Naviary. Από τότε που ανακάλυψα το Άπειρο Σκάκι (η ιδέα υπήρχε πολύ πριν από αυτόν τον ιστότοπο), με έχει συναρπάξει με τις δυνατότητές του! Μέχρι πρόσφατα, το παιχνίδι ήταν αρκετά απρόσβατο, απαιτώντας από τους παίκτες να δημιουργούν εικόνες της τρέχουσας σκακιέρας και να τις στέλνουν μπρος-πίσω για κάθε κίνηση. Εξαιτίας αυτού, λίγοι γνώριζαν το παιχνίδι ή μπορούσαν να το παίξουν.", ["Στόχος μου είναι να δημιουργήσω έναν ιστότοπο ώστε να είναι εύκολα προσβάσιμο σε όλους και να αναπτυχθεί μια κοινότητα γύρω από το άπειρο σκάκι. Έχω αφιερώσει αμέτρητες ώρες από τον προσωπικό μου χρόνο στον ιστότοπο, στη συντήρηση και την ανάπτυξη του παιχνιδιού. Έχω πολλές ακόμα ιδέες που θα με απασχολήσουν για καιρό. Παρότι θέλω να παραμείνει δωρεάν η ιστοσελίδα, η ζωή έχει απαιτήσεις. Για να με υποστηρίξετε οικονομικά, σκεφτείτε να γίνετε μέλος στο ", "Patreon", " μου."] # Patreon receives a hyperlink, here ] patreon_title = "Υποστηρικτές Patreon" github_title = "Συνεισφέροντες Github" [index.javascript] contribution_count_singular = ["", " συνεισφορά"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " συνεισφορές"] [credits] title = "Συντελεστές" copyright = "Οτιδήποτε στον ιστότοπο που δεν αναφέρεται παρακάτω αποτελεί πνευματική ιδιοκτησία του www.InfiniteChess.org" variants_heading = "Αρχικές θέσεις" variants_credits = [ "Core σχεδιασμός από τον Andreas Tsevas.", "Space σχεδιασμένο από τον Andreas Tsevas.", "Space Classic σχεδιασμένο από τον Andreas Tsevas.", "Coaip (Σκάκι σε Άπειρο Επίπεδο) σχεδιασμένο από τον V. Reinhart.", "Pawn Horde σχεδιασμένο από τον Inaccessible Cardinal.", "Abundance σχεδιασμένο από τον Clicktuck Suskriberz.", "Pawndard από τον SexyLexi.", "Κλασικό+ από τον SexyLexi.", "Knightline από τον Inaccessible Cardinal.", "Knighted Chess από τον cycy98.", "σχεδιασμένο από τους Cory Evans και Joel Hamkins.", "σχεδιασμένο από τον Andreas Tsevas.", "σχεδιασμένο από τους Cory Evans και Joel Hamkins.", "σχεδιασμένο από τους Cory Evans, Joel Hamkins και Norman Lewis Perlmutter.", "Σκάκι σε Άπειρο Επίπεδο - Επιλογή Huygens από τον V. Reinhart.", "Περιορισμένο Κλασικό από τον Andreas Tsevas.", "4x4x4x4 Chess από τον Andreas Tsevas.", "5D Chess από τον Jace.", ] textures_heading = "Υφές" textures_licensed_under = "υφές με άδεια βάσει της" sounds_heading = "Ήχοι" sounds_credits = [ ["Ορισμένοι ήχοι παρέχονται από το", "έργο υπό την"], "Άλλοι ήχοι δημιουργήθηκαν από τον Naviary.", ] code_heading = "Κώδικας" code_credits = [ "από τους Brandon Jones και Colin MacKenzie IV.", "από τους Andreas Tsevas και Naviary.", ] language_heading = "Μεταφράσεις Γλωσσών" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Γαλλικά από ", "Life Enjoyer", " και ", "cycy98", ".", "Απλοποιημένα Κινέζικα από ", "Heinrich Xiao", ".", "Παραδοσιακά Κινέζικα από ", "Heinrich Xiao", ".", "Πολωνικά από ", "Tymon Becella", ".", # Apsurt "Πορτογαλικά από ", "Emerson P. Machado", ".", # The_Skeleton on discord "Ισπανικά από ", "xa31er", ".", "Γερμανικά από ", "Estetique", "." ] [member] title = "Μέλος" # The tab name verify_message = "Παρακαλούμε ελέγξτε το email σας για να επιβεβαιώσετε τον λογαριασμό σας. Οι μη επιβεβαιωμένοι λογαριασμοί διαγράφονται μετά από 3 ημέρες." resend_message = ["Δεν το λάβατε; Ελέγξτε τον φάκελο ανεπιθύμητων. Επίσης, ", "στείλτε το ξανά.", " Αν ακόμα δεν το βρίσκετε, ", "επικοινωνήστε μαζί μας."] verify_confirm = "Ευχαριστούμε! Ο λογαριασμός σας επιβεβαιώθηκε." joined = "Εγγραφή:" seen = ["Τελευταία εμφάνιση πριν: ", ""] practice_progress = "Πρόοδος Εξάσκησης:" ranked_elo = "Βαθμολογία:" infinity_leaderboard_position = "Παγκόσμια Κατάταξη:" infinity_leaderboard_rating_deviation = "Απόκλιση Βαθμολογίας:" reveal_info = "Εμφάνιση Πληροφοριών Λογαριασμού" account_info_heading = "Πληροφορίες Λογαριασμού" email = "Email:" delete_account = "Διαγραφή λογαριασμού" [member.badge-tooltips] checkmate_bronze = "Βετεράνος Ματ: Ολοκληρώστε 50% όλων των ασκήσεων ματ." checkmate_silver = "Επαγγελματίας Ματ: Ολοκληρώστε 75% όλων των ασκήσεων ματ." checkmate_gold = "Δάσκαλος Ματ: Ολοκληρώστε 100% όλων των ασκήσεων ματ." [create-account] title = "Δημιουργία Λογαριασμού" # The tab name username = "Όνομα χρήστη:" email = "Email:" password = "Κωδικός πρόσβασης:" create_button = "Δημιουργία Λογαριασμού" agreement = ["Συμφωνώ με τους ", "Όρους Χρήσης", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_tooshort = "Το όνομα χρήστη πρέπει να έχει τουλάχιστον 3 χαρακτήρες" js-username_wrongenc = "Το όνομα χρήστη πρέπει να περιέχει μόνο γράμματα A-Z και αριθμούς 0-9" js-email_invalid = "Αυτό δεν είναι έγκυρο email" js-email_too_long = "Το email είναι πολύ μεγάλο" js-email_inuse = "Αυτό το email χρησιμοποιείται ήδη" [reset-password.javascript] js-pwd_no_match = "Οι κωδικοί πρόσβασης δεν ταιριάζουν." reset-password = "Επαναφορά Κωδικού" processing = "Επεξεργασία..." network-error = "Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά." [password-validation] js-pwd_too_short = "Ο κωδικός πρόσβασης πρέπει να έχει 6+ χαρακτήρες" js-pwd_too_long = "Ο κωδικός πρόσβασης δεν μπορεί να ξεπερνά τους 72 χαρακτήρες" js-pwd_not_pwd = "Ο κωδικός πρόσβασης δεν πρέπει να είναι 'password'" [leaderboard] title = "Κατάταξη" inactive_players = ["Οι ανενεργοί παίκτες με απόκλιση βαθμολογίας πάνω από ", " εξαιρούνται από την κατάταξη."] # A number is inserted between these two quotes your_global_ranking = "Η Παγκόσμια Κατάταξή σας:" show_more = "Εμφάνιση περισσότερων..." [play] title = "Άπειρο Σκάκι - Παίξτε" # The tab title loading = "ΦΟΡΤΩΣΗ" error = "ΣΦΑΛΜΑ" [play.main-menu] credits = "Συντελεστές" play = "Παιχνίδι" practice = "Εξάσκηση" guide = "Οδηγός" editor = "Επεξεργαστής Σκακιέρας" [play.guide] title = "Οδηγός" rules = "Κανόνες" rules_paragraphs = [ "Οι κανόνες του Άπειρου Σκακιού είναι σχεδόν ίδιοι με του κλασικού σκακιού, με τη διαφορά ότι η σκακιέρα είναι άπειρη προς όλες τις κατευθύνσεις! Αυτές είναι οι μόνες αλλαγές που πρέπει να γνωρίζετε:", "Τα κομμάτια με ολισθαίνουσες κινήσεις, όπως οι πύργοι, οι αξιωματικοί και η βασίλισσα, δεν έχουν όριο στο πόσο μακριά μπορούν να κινηθούν σε έναν γύρο! Όσο η διαδρομή τους δεν εμποδίζεται, μπορείτε να κινηθείτε εκατομμύρια τετράγωνα!", ["Στην προεπιλεγμένη αρχική θέση \"Κλασικό\", τα λευκά πιόνια προάγονται στη γραμμή 8 και τα μαύρα στη γραμμή 1. Σε αυτή την εικόνα, αυτό υποδεικνύεται με τις λεπτές μαύρες γραμμές — είναι αχνές, δείτε αν μπορείτε να τις εντοπίσετε! Τα πιόνια χρειάζεται μόνο να φτάσουν στην απέναντι γραμμή για να προαχθούν, ", "όχι", " να την περάσουν."], "Τα τετράγωνα δεν περιγράφονται πλέον με γράμμα και αριθμό γραμμής (π.χ. a1)· αντίθετα, κάθε τετράγωνο ορίζεται από ένα ζεύγος συντεταγμένων x και y. Το τετράγωνο a1 έχει γίνει (1,1) και το h8 έχει γίνει (8,8). Σε υπολογιστές, η συντεταγμένη του ποντικιού εμφανίζεται στο επάνω μέρος της οθόνης.", "Όλοι οι υπόλοιποι κανόνες είναι ίδιοι με το κλασικό σκάκι, όπως ματ, πατ, επανάληψη τριών φορών, ο κανόνας των 50 κινήσεων, ροκέ, en passant κτλ.!" ] careful_heading = "Προσοχή!" careful_paragraphs = [ "Η ανοιχτότητα της άπειρης σκακιέρας σημαίνει ότι είναι πολύ εύκολο να εκμεταλλευτείτε πιρούνια, καρφώματα και σουβλιές. H πίσω γραμμή σας είναι συχνά πολύ ευάλωτh. Προσέξτε τέτοιες τακτικές! Να είστε δημιουργικοί στη δημιουργία άμυνας για τον βασιλιά και τους πύργους σας! Η στρατηγική του ανοίγματος είναι πολύ διαφορετική απότι στο κλασικό σκάκι.", "Πολλές άλλες αρχικές θέσεις έχουν δημιουργηθεί με στόχο την ενίσχυση της πίσω γραμμής σας." ] controls_heading = "Χειρισμός" controls_paragraph = "Κάντε κλικ και σύρετε τη σκακιέρα για να μετακινηθείτε. Κάντε κύλιση για μεγέθυνση και σμίκρυνση. Κάντε κλικ σε οποιοδήποτε κομμάτι, συμπεριλαμβανομένων των κομματιών του αντιπάλου, για να δείτε τις νόμιμες κινήσεις του οποιαδήποτε στιγμή! Επιπλέον χειρισμοί είναι:" keybinds = [ " για μετακίνηση.", ["Space", " και ", "Shift", " για μεγέθυνση και σμίκρυνση."], ["Escape", " για παύση του παιχνιδιού."], ["Tab", " ενεργοποιεί/απενεργοποιεί τους δείκτες βελών στις άκρες της οθόνης που δείχνουν κομμάτια εκτός οθόνης. Από προεπιλογή, αυτή η λειτουργία είναι ρυθμισμένη σε \"Άμυνα\", που εμφανίζει βέλη προς όλα τα κομμάτια που μπορούν να κινηθούν προς την οθόνη σας σύμφωνα με την κατεύθυνση κίνησής τους. Όμως το ", "Tab", " μπορεί να αλλάξει αυτή τη λειτουργία σε \"Όλα\" ή \"Απενεργοποιημένο\"· το \"Όλα\" εμφανίζει βέλη για όλα τα κομμάτια, ανεξάρτητα από το αν μπορούν να κινηθούν προς την οθόνη σας. Αυτή η ρύθμιση μπορεί επίσης να αλλάξει από το μενού παύσης. Κάνοντας κλικ σε ένα βέλος θα μεταφερθείτε στο κομμάτι στο οποίο δείχνει."], ["Control", " σύρει τη σκακιέρα αντί να σύρει ένα κομμάτι, αν το σύρσιμο είναι ενεργοποιημένο στις ρυθμίσεις."], " ενεργοποιήσει τη \"Λειτουργία Επεξεργασίας\" σε τοπικά παιχνίδια. Αυτό σας επιτρέπει να μετακινείτε οποιοδήποτε κομμάτι οπουδήποτε στη σκακιέρα! Πολύ χρήσιμο για ανάλυση." ] controls_paragraph2 = "Αυτοί είναι οι βασικοί χειρισμοί που πρέπει να γνωρίζετε. Όμως υπάρχουν και μερικά επιπλέον αν ποτέ τους χρειαστείτε!" keybinds_extra = [ " επαναφέρει την απόδοση των κομματιών. Αυτό είναι χρήσιμο αν γίνουν αόρατα. Αυτό το σφάλμα μπορεί να συμβεί αν μετακινηθείτε σε ακραίες αποστάσεις (όπως 1e21).", " ενεργοποιεί/απενεργοποιεί την απόδοση των γραμμών πλοήγησης και των πληροφοριών παιχνιδιού, κάτι που μπορεί να είναι χρήσιμο για streaming και βιντεοσκόπηση. Το streaming και η δημιουργία βίντεο για το παιχνίδι είναι ευπρόσδεκτα!", " ενεργοποιεί/απενεργοποιεί τον μετρητή FPS. Αυτός εμφανίζει πόσες φορές το παιχνίδι ενημερώνεται ανά δευτερόλεπτο, όχι πάντα τον αριθμό των καρέ που αποδίδονται, καθώς το παιχνίδι παραλείπει την απόδοση όταν δεν αλλάζει κάτι ορατό για αύξηση της απόδοσης.", " ενεργοποιεί/απενεργοποιεί την απόδοση εικονιδίων. Αυτά είναι τα μικρά εικονίδια των κομματιών που μπορείτε να κάνετε κλικ όταν κάνετε πολύ μεγάλη σμίκρυνση. Σε εισαγόμενα παιχνίδια με πάνω από 50.000 κομμάτια αυτό απενεργοποιείται αυτόματα, καθώς μειώνει δραστικά την απόδοση, αλλά μπορεί να ενεργοποιηθεί ξανά με ", [" (backtick, στο ίδιο πλήκτρο με το ", ") ενεργοποιεί/απενεργοποιεί τη Λειτουργία Εντοπισμού Σφαλμάτων."], ] fairy_heading = "Παραμυθένια Κομμάτια" fairy_paragraph = "Γνωρίζετε ήδη όσα χρειάζεστε για να παίξετε την προεπιλεγμένη αρχική θέση \"Κλασικό\". Τα παραμυθένια σκακιστικά κομμάτια δεν χρησιμοποιούνται στο συμβατικό σκάκι, αλλά υπάρχουν σε άλλες αρχικές θέσεις! Αν βρεθείτε σε μια αρχική θέση με κομμάτια που δεν έχετε ξαναδεί, μάθετε εδώ πώς λειτουργούν!" editing_heading = "Επεξεργασία Σκακιέρας" editing_paragraphs = [ ["Υπάρχει ένας εξωτερικός ", "επεξεργαστής σκακιέρας", " διαθέσιμος αυτή τη στιγμή σε ένα δημόσιο Google Sheet! Περιλαμβάνει οδηγίες χρήσης. Αυτό απαιτεί βασικές γνώσεις Google Sheets. Μετά την εγκατάσταση, θα μπορείτε να δημιουργείτε και να εισάγετε προσαρμοσμένες θέσεις στο παιχνίδι μέσω του κουμπιού \"Επικόλληση Παιχνιδιού\" στο μενού επιλογών!"], "Για να Παίξτετε μια προσαρμοσμένη θέση με έναν φίλο, ζητήστε του να συνδεθεί σε μια ιδιωτική πρόσκληση και στη συνέχεια και οι δύο μπορείτε να επικολλήσετε τον κωδικό παιχνιδιού για να ξεκινήσετε!", "Ένας ενσωματωμένος επεξεργαστής σκακιέρας στο παιχνίδι βρίσκεται ακόμα υπό σχεδιασμό.", ] back = "Πίσω" [play.guide.pieces] chancellor = {name="Καγκελάριος", description="Κινείται σαν πύργος και ίππος μαζί."} archbishop = {name="Αρχιεπίσκοπος", description="Κινείται σαν αξιωματικός και ίππος μαζί."} amazon = {name="Αμαζόνα", description="Κινείται σαν βασίλισσα και ίππος μαζί. Είναι το ισχυρότερο κομμάτι στο παιχνίδι!"} guard = {name="Φρουρός", description="Κινείται σαν βασιλιάς, αλλά δεν υπόκειται σε σαχ ή σαχ ματ."} hawk = {name="Γεράκι", description="Πηδά ακριβώς 2 ή 3 τετράγωνα προς οποιαδήποτε κατεύθυνση."} centaur = {name="Κένταυρος", description="Κινείται σαν ίππος και φρουρός μαζί."} knightrider = {name="Ιπποδρόμος", description="Πηδά σαν ίππος άπειρες φορές προς μία κατεύθυνση, μέχρι να εμποδιστεί."} huygen = {name="Huygen", description="Πηδά άπειρα προς μία από τις τέσσερις κύριες κατευθύνσεις, επισκεπτόμενος μόνο τετράγωνα με πρώτο αριθμό απόστασης από το αρχικό του τετράγωνο, μέχρι να εμποδιστεί."} rose = {name="Ρόδο", description="Κυκλικός ιπποδρόμος. Κινείται σε δεξιόστροφες και αριστερόστροφες κυκλικές τροχιές πηδώντας σαν ίππος και στρέφοντας κατά 45 μοίρες μετά από κάθε άλμα. Μπορεί να μπλοκαριστεί από άλλα κομμάτια, γι’ αυτό και το κόκκινο τετράγωνο στην εικόνα δεν είναι προσβάσιμο για το ρόδο."} obstacle = {name="Εμπόδιο", description="Ουδέτερο κομμάτι (δεν ελέγχεται από κανέναν παίκτη) που μπλοκάρει την κίνηση, αλλά μπορεί να αιχμαλωτιστεί."} void = {name="Κενό", description="Ουδέτερο κομμάτι (δεν ελέγχεται από κανέναν παίκτη) που αναπαριστά την απουσία σκακιέρας. Τα κομμάτια δεν μπορούν να κινηθούν μέσα από αυτό ή επάνω του."} [play.practice-menu] title = "Εξάσκηση - Ματ" play = "Παίξτε" back = "Πίσω" difficulty = "Δυσκολία" [play.play-menu] title = "Παίξτε - Διαδικτυακά" colors = "Χρώματα" online = "Διαδικτυακό" local = "Τοπικό" computer = "Υπολογιστής" variant = "Αρχική Θέση" Classical = "Κλασικό" Confined_Classical = "Περιορισμένο Κλασικό" Classical_Plus = "Κλασικό+" CoaIP = "Σκάκι σε Άπειρο Επίπεδο" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Palace = "Palace" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Pawn Horde" Space_Classic = "Space Classic" Space = "Space" Obstocean = "Obstocean" Abundance = "Abundance" Amazon_Chandelier = "Amazon Chandelier" Containment = "Containment" Classical_Limit_7 = "Κλασικό - Όριο 7" CoaIP_Limit_7 = "Σκάκι σε Άπειρο Επίπεδο - Όριο 7" Chess = "Σκάκι" Classical_KOTH = "Πειραματικό: Κλασικό - King of the Hill" CoaIP_KOTH = "Πειραματικό: Coaip - King of the Hill" CoaIP_HO = "Σκάκι σε Άπειρο Επίπεδο - Επιλογή Huygens" CoaIP_RO = "Σκάκι σε Άπειρο Επίπεδο - Επιλογή Ρόδων" CoaIP_NO = "Σκάκι σε Άπειρο Επίπεδο - Επιλογή Ιπποδρόμων" Omega = "Παρουσίαση: Ωμέγα" Omega_Squared = "Παρουσίαση: Ωμέγα²" Omega_Cubed = "Παρουσίαση: Ωμέγα³" Omega_Fourth = "Παρουσίαση: Ωμέγα⁴" 4x4x4x4_Chess = "Σκάκι 4×4×4×4" 5D_Chess = "Σκάκι 5D" no_clock = "Χωρίς Ρολόι" clock = "Ρολόι" minutes = "λ" seconds = "δ" infinite_time = "Άπειρος Χρόνος" color = "Χρώμα" piece_colors = ["Τυχαίο", "Λευκό", "Μαύρο"] private = "Ιδιωτικό" no = "Όχι" yes = "Ναι" rated = "Βαθμολογία" casual = "Φιλικό" easy = "Εύκολο" medium = "Μέτριο" hard = "Δύσκολο" join_games = "Σύνδεση σε Υπάρχοντα - Ενεργά Παιχνίδια:" private_invite = "Ιδιωτική Πρόσκληση:" your_invite = "Ο Κωδικός Πρόσκλησής σας:" create_invite = "Δημιουργία Πρόσκλησης" join = "Σύνδεση" copy = "Αντιγραφή" back = "Πίσω" code = "Κωδικός" [play.gamebuttontooltips] undo_transition = "Αναίρεση μετάβασης" expand_fit_all = "Επέκταση" recenter = "Επανακεντράρισμα" annotations = "Σχεδίαση σημειώσεων" erase = "Διαγραφή σημειώσεων" collapse = "Σύμπτυξη σημειώσεων" rewind_move = "Προηγούμενη κίνηση" forward_move = "Επόμενη κίνηση" undo_edit = "Αναίρεση επεξεργασίας (Ctrl+Z)" # Board editor redo_edit = "Επανάληψη επεξεργασίας (Ctrl+Y)" # Board editor pause = "Παύση" undo = "Αναίρεση κίνησης" # Checkmate practice game restart = "Επανεκκίνηση παιχνιδιού" # Checkmate practice game [play.pause] title = "Σε Παύση" resume = "Συνέχιση" arrows = "Βέλη: Άμυνα" perspective = "Προοπτική: Ανενεργή" copy = "Αντιγραφή Παιχνιδιού" paste = "Επικόλληση Παιχνιδιού" offer_draw = "Προσφορά Ισοπαλίας" practice_menu = "Μενού Εξάσκησης" main_menu = "Κύριο Μενού" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Αποδοχή προσφοράς ισοπαλίας;" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Επισκέπτης)" you_indicator = "(Εσύ)" engine_indicator = "Μηχανή" player_name_white_generic = "Λευκά" player_name_black_generic = "Μαύρα" white_to_move = "Σειρά των λευκών" black_to_move = "Σειρά των μαύρων" your_move = "Η σειρά σας" their_move = "Η σειρά του αντιπάλου" lost_network = "Χάθηκε η σύνδεση δικτύου." failed_to_load = "Αποτυχία φόρτωσης ενός ή περισσότερων πόρων. Παρακαλώ ανανεώστε." planned_feature = "Αυτή η λειτουργία είναι προγραμματισμένη!" main_menu = "Κύριο Μενού" resign_game = "Παραίτηση" abort_game = "Ακύρωση παιχνιδιού" offer_draw = "Προσφορά ισοπαλίας" # Offer draw button text in the pause menu accept_draw = "Αποδοχή ισοπαλίας" # Offer draw button text in the pause menu arrows_off = "Βέλη: Ανενεργά" arrows_defense = "Βέλη: Άμυνα" arrows_all = "Βέλη: Όλα" arrows_all_hippogonals = "Βέλη: Όλα (με ιππογώνιες)" toggled = "Εναλλάχθηκε" menu_online = "Παίξτε - Διαδικτυακά" menu_local = "Παίξτε - Τοπικά" menu_computer = "Παίξτε - Υπολογιστής" invite_error_digits = "Ο κωδικός πρόσκλησης πρέπει να αποτελείται από 5 σύμβολα." invite_copied = "Ο κωδικός πρόσκλησης αντιγράφηκε στο πρόχειρο." move_counter = "Κίνηση:" constructing_mesh = "Κατασκευή πλέγματος" rotating_mesh = "Περιστροφή πλέγματος" lost_connection = "Χάθηκε η σύνδεση." please_wait = "Παρακαλώ περιμένετε λίγο για να εκτελέσετε αυτή την ενέργεια." webgl_unsupported = "Παρακαλώ αναβαθμίστε τον φυλλομετρητή σας! Δεν υποστηρίζει WebGL2." bigints_unsupported = "Δεν υποστηρίζονται BigInts. Παρακαλώ αναβαθμίστε τον φυλλομετρητή σας.\nΤα BigInts είναι απαραίτητα για να είναι η σκακιέρα άπειρη." # Checkmate Practice versus = "vs" easy = "Εύκολο" medium = "Μέτριο" hard = "Δύσκολο" insane = "Παράλογο" checkmate_logged_out = "Πρέπει να είστε συνδεδεμένοι για να κερδίσετε εμβλήματα." checkmate_bronze = "Βετεράνος του Ματ: Ολοκληρώστε το 50% όλων των ασκήσεων ματ." checkmate_silver = "Επαγγελματίας του Ματ: Ολοκληρώστε το 75% όλων των ασκήσεων ματ." checkmate_gold = "Μαέστρος του Ματ: Ολοκληρώστε το 100% όλων των ασκήσεων ματ." checkmate_bronze_unearned = "Ολοκληρώστε το 50% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα." checkmate_silver_unearned = "Ολοκληρώστε το 75% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα." checkmate_gold_unearned = "Ολοκληρώστε το 100% όλων των ασκήσεων ματ για να κερδίσετε αυτό το έμβλημα." coords-invalid = "Μη έγκυρη μορφή συντεταγμένων. Παρακαλώ εισαγάγετε ακέραιους ή εκθετική γραφή (π.χ. 1.23e4)." coords-exceeded = "Δεν μπορείτε να τηλεμεταφερθείτε τόσο μακριά! Θα ήταν πολύ εύκολο ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Κενό" obstacle = "Εμπόδιο" king = "Βασιλιάς" giraffe = "Καμηλοπάρδαλη" camel = "Καμήλα" zebra = "Ζέβρα" knightrider = "Ιπποδρόμος" amazon = "Αμαζόνα" queen = "Βασίλισσα" royalQueen = "Βασιλική βασίλισσα" hawk = "Γεράκι" chancellor = "Καγκελάριος" archbishop = "Αρχιεπίσκοπος" centaur = "Κένταυρος" royalCentaur = "Βασιλικός κένταυρος" rose = "Ρόδο" knight = "Ίππος" guard = "Φρουρός" huygen = "Huygen" rook = "Πύργος" bishop = "Αξιωματικός" pawn = "Πιόνι" [play.javascript.copypaste] copied_game = "Το παιχνίδι αντιγράφηκε στο πρόχειρο!" cannot_paste_in_public = "Δεν είναι δυνατή η επικόλληση παιχνιδιού σε δημόσιο αγώνα!" cannot_paste_in_rated = "Δεν είναι δυνατή η επικόλληση παιχνιδιού σε βαθμολογούμενο αγώνα!" cannot_paste_in_engine = "Δεν είναι δυνατή η επικόλληση παιχνιδιού σε αγώνα μηχανής!" cannot_paste_after_moves = "Δεν είναι δυνατή η επικόλληση παιχνιδιού αφού έχουν γίνει κινήσεις!" clipboard_denied = "Απορρίφθηκε η άδεια πρόσβασης στο πρόχειρο. Πιθανότατα φταίει ο φυλλομετρητής σας." clipboard_invalid = "Το πρόχειρο δεν είναι σε έγκυρη σημειογραφία ICN." game_needs_to_specify = "Το παιχνίδι πρέπει να καθορίζει είτε τα μεταδεδομένα 'Variant' είτε την ιδιότητα 'position'." invalid_wincon = "Ο παίκτης έχει μη έγκυρη συνθήκη νίκης" pasting_game = "Επικόλληση παιχνιδιού..." pasting_in_private = "Η επικόλληση παιχνιδιού σε ιδιωτικό αγώνα θα προκαλέσει αποσυγχρονισμό αν ο αντίπαλος δεν κάνει το ίδιο!" piece_count = "Αριθμός κομματιών" exceeded = "υπέρβαση" changed_wincon = "Οι συνθήκη νίκης ματ μετατράπηκε σε βασιλική αιχμαλωσία και η απόδοση εικονιδίων απενεργοποιήθηκε. Πατήστε 'P' για επανενεργοποίηση (δεν συνιστάται)." loaded_from_clipboard = "Το παιχνίδι φορτώθηκε από το πρόχειρο!" copied_position = "Η θέση αντιγράφηκε στο πρόχειρο!" loaded_position_from_clipboard = "Η θέση φορτώθηκε από το πρόχειρο!" reset_position = "Η θέση επαναφέρθηκε!" clear_position = "Η θέση εκκαθαρίστηκε!" [play.javascript.rendering] on = "Ενεργή" off = "Ανενεργή" icon_rendering_off = "Η απόδοση εικονιδίων απενεργοποιήθηκε." icon_rendering_on = "Η απόδοση εικονιδίων ενεργοποιήθηκε." perspective = "Προοπτική" perspective_mode_on_desktop = "Η λειτουργία προοπτικής είναι διαθέσιμη σε επιτραπέζιους υπολογιστές!" movement_tutorial = "WASD για μετακίνηση. Space & shift για μεγέθυνση." regenerated_pieces = "Τα κομμάτια αναδημιουργήθηκαν." [play.javascript.invites] move_mouse = "Μετακινήστε το ποντίκι για επανασύνδεση." cannot_cancel = "Δεν είναι δυνατή η ακύρωση πρόσκλησης με μη καθορισμένο ID." you_are_white = "Είστε: Λευκός" you_are_black = "Είστε: Μαύρος" random = "Τυχαίο" accept = "Αποδοχή" cancel = "Ακύρωση" create_invite = "Δημιουργία Πρόσκλησης" cancel_invite = "Ακύρωση Πρόσκλησης" start_game = "Έναρξη Παιχνιδιού" join_existing_active_games = "Σύνδεση σε Υπάρχοντα - Ενεργά Παιχνίδια:" [play.javascript.onlinegame] afk_warning = "Είστε AFK." opponent_afk = "Ο αντίπαλος είναι AFK." opponent_disconnected = "Ο αντίπαλος αποσυνδέθηκε." opponent_lost_connection = "Ο αντίπαλος έχασε τη σύνδεση." auto_resigning_in = "Αυτόματη παραίτηση σε" auto_aborting_in = "Αυτόματη ακύρωση σε" not_logged_in = "Δεν είστε συνδεδεμένοι. Παρακαλώ συνδεθείτε για να επανασυνδεθείτε σε αυτό το παιχνίδι." game_no_longer_exists = "Το παιχνίδι δεν υπάρχει πλέον." another_window_connected = "Ένα άλλο παράθυρο έχει συνδεθεί." server_restarting = "Ο διακομιστής επανεκκινείται σύντομα..." server_restarting_in = "Ο διακομιστής επανεκκινείται σε" minute = "λεπτό" minutes = "λεπτά" [play.javascript.websocket] no_connection = "Χωρίς σύνδεση." reconnected = "Επανασυνδέθηκε." unable_to_identify_ip = "Αδυναμία αναγνώρισης IP." online_play_disabled = "Το διαδικτυακό παιχνίδι είναι απενεργοποιημένο. Τα cookies δεν υποστηρίζονται. Δοκιμάστε διαφορετικό φυλλομετρητή." too_many_requests = "Πάρα πολλά αιτήματα. Δοκιμάστε ξανά σύντομα." message_too_big = "Το μήνυμα είναι πολύ μεγάλο." too_many_sockets = "Πάρα πολλές συνδέσεις" origin_error = "Σφάλμα προέλευσης." connection_closed = "Η σύνδεση έκλεισε απροσδόκητα. Μήνυμα διακομιστή:" please_report_bug = "Αυτό δεν θα έπρεπε να συμβεί ποτέ, παρακαλώ αναφέρετε αυτό το σφάλμα!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Σαχ ματ" stalemate = "Πατ" repetition = "Τριπλή επανάληψη" moverule = ["Kανόνας ", " κίνησεων"] # The game inserts a number inbetween these two strings insuffmat = "Ανεπαρκές υλικό" royalcapture = "Βασιλική αιχμαλωσία" allroyalscaptured = "Όλοι οι βασιλιάδες αιχμαλωτίστηκαν" allpiecescaptured = "Όλα τα κομμάτια αιχμαλωτίστηκαν" koth = "Βασιλιάς του λόφου" resignation = "Παραίτηση" agreement = "Συμφωνία" time = "Λήξη χρόνου" aborted = "Ακυρώθηκε" # Game was cancelled (no elo exchanged) disconnect = "Εγκαταλείφθηκε" # A player left [play.javascript.results] you_checkmate = "Κερδίσατε με σαχ ματ!" you_time = "Κερδίσατε λόγω χρόνου!" you_resignation = "Κερδίσατε λόγω παραίτησης!" you_disconnect = "Κερδίσατε λόγω εγκατάλειψης!" you_royalcapture = "Κερδίσατε με βασιλική αιχμαλωσία!" you_allroyalscaptured = "Κερδίσατε με αιχμαλωσία όλων των βασιλιάδων!" you_allpiecescaptured = "Κερδίσατε με αιχμαλωσία όλων των κομματιών!" you_koth = "Κερδίσατε λόγω βασιλιά του λόφου!" you_generic = "Κερδίσατε!" draw_stalemate = "Ισοπαλία λόγω πατ!" draw_repetition = "Ισοπαλία λόγω επανάληψης!" draw_moverule = ["Ισοπαλία λόγω του κανόνα των ", " κινήσεων!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Ισοπαλία λόγω ανεπαρκούς υλικού!" draw_agreement = "Ισοπαλία κατόπιν συμφωνίας!" draw_generic = "Ισοπαλία!" aborted = "Το παιχνίδι ακυρώθηκε." opponent_checkmate = "Χάσατε με σαχ ματ!" opponent_time = "Χάσατε λόγω χρόνου!" opponent_resignation = "Χάσατε λόγω παραίτησης!" opponent_disconnect = "Χάσατε λόγω εγκατάλειψης!" opponent_royalcapture = "Χάσατε με βασιλική αιχμαλωσία!" opponent_allroyalscaptured = "Χάσατε με αιχμαλωσία όλων των βασιλιάδων!" opponent_allpiecescaptured = "Χάσατε με αιχμαλωσία όλων των κομματιών!" opponent_koth = "Χάσατε λόγω βασιλιά του λόφου!" opponent_generic = "Χάσατε!" white_checkmate = "Τα λευκά κερδίζουν με σαχ ματ!" black_checkmate = "Τα μαύρα κερδίζουν με σαχ ματ!" white_time = "Τα λευκά κερδίζουν λόγω χρόνου!" black_time = "Τα μαύρα κερδίζουν λόγω χρόνου!" white_resignation = "Τα λευκά κερδίζουν λόγω παραίτησης!" black_resignation = "Τα μαύρα κερδίζουν λόγω παραίτησης!" white_disconnect = "Τα λευκά κερδίζουν λόγω αποσύνδεσης!" black_disconnect = "Τα μαύρα κερδίζουν λόγω αποσύνδεσης!" white_royalcapture = "Τα λευκά κερδίζουν με βασιλική αιχμαλωσία!" black_royalcapture = "Τα μαύρα κερδίζουν με βασιλική αιχμαλωσία!" white_allroyalscaptured = "Τα λευκά κερδίζουν με αιχμαλωσία όλων των βασιλιάδων!" black_allroyalscaptured = "Τα μαύρα κερδίζουν με αιχμαλωσία όλων των βασιλιάδων!" white_allpiecescaptured = "Τα λευκά κερδίζουν με αιχμαλωσία όλων των κομματιών!" black_allpiecescaptured = "Τα μαύρα κερδίζουν με αιχμαλωσία όλων των κομματιών!" white_koth = "Τα λευκά κερδίζουν με βασιλιά του λόφου!" black_koth = "Τα μαύρα κερδίζουν με βασιλιά του λόφου!" bug_generic = "Αυτό είναι σφάλμα, παρακαλώ αναφέρετέ το!" [terms] title = "Όροι Χρήσης" warning = ["ΤΟ ΠΑΡΟΝ ΕΓΓΡΑΦΟ ΔΕΝ ΕΙΝΑΙ ΝΟΜΙΚΑ ΔΕΣΜΕΥΤΙΚΟ. Φέρουμε ευθύνη μόνο για την αγγλική έκδοση του παρόντος εγγράφου. Αυτή η μετάφραση παρέχεται αποκλειστικά για γενικούς ενημερωτικούς σκοπούς. Μπορείτε να αποκτήσετε πρόσβαση στην επίσημη αγγλική έκδοση ", "εδώ", "."] consent = "Χρησιμοποιώντας αυτόν τον ιστότοπο, συμφωνείτε να τηρείτε τους ακόλουθους όρους. Αν δεν συμφωνείτε, πρέπει να σταματήσετε αμέσως τη χρήση του ιστότοπου." guardian_consent = "Αν είστε κάτω των 18 ετών, πρέπει να λάβετε συγκατάθεση από γονέα ή νόμιμο κηδεμόνα για να χρησιμοποιήσετε αυτόν τον ιστότοπο και να δημιουργήσετε λογαριασμό." parents_header = "Γονείς" parents_paragraphs = [ "Υπάρχει αλγόριθμος που αποτρέπει τους χρήστες από το να ορίζουν όνομα που περιέχει συνηθισμένες βωμολοχίες. Προς το παρόν δεν υπάρχει τρόπος επικοινωνίας μεταξύ των μελών στον ιστότοπο.", "Αυτή τη στιγμή, τα μέλη δεν μπορούν να ορίσουν τη δική τους εικόνα προφίλ. Υπάρχει σχέδιο να επιτραπεί αυτή η λειτουργία. Τότε θα κάνουμε ό,τι μπορούμε για να αποτρέψουμε ακατάλληλες εικόνες προφίλ.", ] fair_play_header = "Τίμιο Παιχνίδι" fair_play_paragraph1 = ["Δεν μπορείτε να έχετε περισσότερους από έναν λογαριασμούς."] fair_play_paragraph2 = "Για να διατηρείται το παιχνίδι διασκεδαστικό και δίκαιο για όλους, ΔΕΝ πρέπει:" fair_play_rules = [ "Να τροποποιείτε ή να χειραγωγείτε τον κώδικα με οποιονδήποτε τρόπο, συμπεριλαμβανομένων ενδεικτικά: χρήσης εντολών κονσόλας, τοπικών παρακάμψεων, προσαρμοσμένων scripts, τροποποίησης αιτημάτων HTTP, μηνυμάτων websocket κ.λπ. Αυτό μπορεί να γίνει για να σπάσει σκόπιμα το παιχνίδι, να παιχτούν παράνομες κινήσεις ή να αποκτήσετε πλεονέκτημα.", "Να καταχράστε σφάλματα ή δυσλειτουργίες για να ακυρώσετε το παιχνίδι ή να αποκτήσετε πλεονέκτημα.", "Σε παιχνίδια κατάταξης, να λαμβάνετε βοήθεια/συμβουλές από άλλο άτομο ή πρόγραμμα σχετικά με το τι να Παίξτετε. (Η δημιουργία μηχανής είναι επιτρεπτή και ενθαρρύνεται, αλλά πρέπει να περιορίζεται σε μη βαθμολογούμενα, φιλικά παιχνίδια)", "Να ανταλλάσσετε πόντους elo με άλλους παίκτες χάνοντας σκόπιμα για να αυξήσετε τη βαθμολογία του αντιπάλου ή λαμβάνοντας πόντους elo από αντίπαλο που σκοπεύει να χάσει για να αυξήσει τη δική σας βαθμολογία. Αυτό καταχράται το σύστημα και δημιουργεί ανακριβείς βαθμολογίες σε σχέση με το επίπεδο δεξιοτήτων σας." ] cleanliness_header = "Καθαρότητα" cleanliness_rules = [ "Σε όλη τη γλώσσα που χρησιμοποιείτε στον ιστότοπο, πρέπει να παραμένετε κόσμιοι, χωρίς χυδαιότητα ή βωμολοχίες. Δεν μπορείτε να εκφοβίζετε, να παρενοχλείτε ή να απειλείτε κανέναν, ούτε να κάνετε οτιδήποτε παράνομο. Δεν μπορείτε να σπαμάρετε άλλους χρήστες ή φόρουμ.", "Δεν μπορείτε να ανεβάζετε στο προφίλ σας εικόνες που είναι ακατάλληλες, προκλητικές ή βίαιες. Κάτι τέτοιο μπορεί να οδηγήσει σε αποκλεισμό ή τερματισμό του λογαριασμού σας." ] privacy_header = "Ιδιωτικότητα" privacy_rules = [ "Προς το παρόν, η μόνη προσωπική πληροφορία που συλλέγουμε είναι το email σας. Αυτό γίνεται για την επαλήθευση των λογαριασμών των χρηστών και για να παρέχεται τρόπος απόδειξης της ταυτότητάς τους όταν ζητούν επαναφορά κωδικού. Δεν αποστέλλουμε προωθητικά email ή προσφορές. Δεν κοινοποιούμε το email κανενός χρήστη σε τρίτους.", "Το InfiniteChess.org ενδέχεται να συλλέγει δεδομένα σχετικά με τη χρήση σας στον ιστότοπο, συμπεριλαμβανομένης της διεύθυνσης IP σας. Αυτό γίνεται για την αποτροπή επιθέσεων από bots και άλλες ανεπιθύμητες οντότητες και για τη διατήρηση ακριβών στατιστικών στη βάση δεδομένων. Αυτό ΔΕΝ είναι η διεύθυνση κατοικίας σας.", "Όλα τα παιχνίδια που παίζετε σε αυτόν τον ιστότοπο αποτελούν δημόσια πληροφορία. Αν επιθυμείτε να παραμείνετε ανώνυμοι, μην κοινοποιείτε το όνομα χρήστη σας σε φίλους ή οικογένεια. Αν αυτός είναι ο στόχος σας, είναι δική σας ευθύνη να διασφαλίσετε ότι κανείς δεν θα συνδέσει το όνομα χρήστη σας με την πραγματική σας ταυτότητα.", "Η κατάσταση σύνδεσης του λογαριασμού σας και ο κατά προσέγγιση τελευταίος χρόνος δραστηριότητάς σας στον ιστότοπο αποτελούν επίσης δημόσια πληροφορία.", ["Παρότι το InfiniteChess.org θα καταβάλει κάθε δυνατή προσπάθεια για να διατηρήσει ασφαλείς τους λογαριασμούς και τις προσωπικές πληροφορίες όλων, σε περίπτωση παραβίασης ή διαρροής δεδομένων δεν μπορείτε να ασκήσετε νομικές αξιώσεις εναντίον μας. Αν συμβεί ποτέ διαρροή δεδομένων, οι χρήστες θα ειδοποιηθούν στη σελίδα ", "Νέα", "."], "Δεν υπάρχει περιεχόμενο προς αγορά στον ιστότοπο. Καμία άλλη προσωπική πληροφορία δεν συλλέγεται.", "Για να διαγραφούν οι ιδιωτικές σας πληροφορίες από τους διακομιστές μας, μπορείτε να διαγράψετε τον λογαριασμό σας μέσω της σελίδας προφίλ. Το μόνο στοιχείο που ΔΕΝ διαγράφουμε και συνδέεται με το όνομα χρήστη σας είναι το ιστορικό παιχνιδιών σας, καθώς όλα τα παιχνίδια είναι δημόσια πληροφορία.", ] cookie_header = "Πολιτική Cookies" cookie_paragraphs = [ "Αυτός ο ιστότοπος χρησιμοποιεί cookies, τα οποία είναι μικρά αρχεία κειμένου που αποθηκεύονται στον φυλλομετρητή σας και αποστέλλονται στον διακομιστή όταν πραγματοποιούνται συνδέσεις. Ο σκοπός αυτών των cookies είναι: η επικύρωση της συνεδρίας σύνδεσής σας, η επικύρωση ότι ο φυλλομετρητής σας ανήκει στο σκακιστικό παιχνίδι στο οποίο αναφέρει ότι βρίσκεται και η αποθήκευση προτιμήσεων παιχνιδιού ώστε να διατηρούνται όταν επισκέπτεστε ξανά τον ιστότοπο. Ο ιστότοπος δεν χρησιμοποιεί cookies τρίτων και τα cookies δεν κοινοποιούνται σε εξωτερικά μέρη.", "Τα cookies είναι απαραίτητα για τη σωστή λειτουργία του ιστότοπου και του παιχνιδιού. Αν δεν θέλετε ο ιστότοπος να αποθηκεύει cookies, πρέπει να σταματήσετε να τον χρησιμοποιείτε. Μπορείτε να μεταβείτε στις ρυθμίσεις του φυλλομετρητή σας για να διαγράψετε υπάρχοντα cookies. Συνεχίζοντας τη χρήση του ιστότοπου, συναινείτε στη χρήση cookies." ] conclusion_header = "Συμπέρασμα" conclusion_paragraphs = [ "Οποιαδήποτε παραβίαση αυτών των όρων μπορεί να οδηγήσει σε αποκλεισμό ή τερματισμό του λογαριασμού σας. Το InfiniteChess.org θέλει να δίνει σε όλους την ευκαιρία να παίζουν και να διασκεδάζουν! Ωστόσο, διατηρούμε το δικαίωμα να αποκλείουμε ή να τερματίζουμε λογαριασμούς χρηστών οποιαδήποτε στιγμή, για λόγους που δεν απαιτείται να γνωστοποιηθούν. Δεν μπορούν να ασκηθούν νομικές αξιώσεις εναντίον μας.", ["Αυτοί οι όροι χρήσης ενδέχεται να τροποποιηθούν οποιαδήποτε στιγμή. Είναι ΔΙΚΗ ΣΑΣ ευθύνη να διασφαλίζετε ότι είστε ενημερωμένοι για τις τελευταίες αλλαγές! Όταν οι όροι χρήσης ενημερώνονται, η σχετική πληροφορία θα δημοσιεύεται στη σελίδα ", "Νέα", ". Αν, τη στιγμή ενημέρωσης των όρων, δεν συμφωνείτε με τους νέους όρους, πρέπει να σταματήσετε άμεσα τη χρήση του ιστότοπου. Μπορείτε να διαγράψετε τον λογαριασμό σας από τη σελίδα προφίλ. Αν διαγράψετε τον λογαριασμό σας, όλες οι ιδιωτικές πληροφορίες και τα δεδομένα λογαριασμού σας θα διαγραφούν, ΕΚΤΟΣ από το ιστορικό παιχνιδιών που συνδέεται με το όνομα χρήστη σας, το οποίο είναι δημόσια πληροφορία."], ["Αυτός ο ιστότοπος είναι ανοιχτού κώδικα. Μπορείτε να αντιγράψετε ή να διανείμετε οτιδήποτε σε αυτόν τον ιστότοπο εφόσον τηρείτε τους όρους που περιγράφονται στους ", "όρους άδειας", "! Αν αυτός ο σύνδεσμος δεν λειτουργεί, είναι δική σας ευθύνη να βρείτε τους όρους."], "Δεν μπορούμε να εγγυηθούμε ότι ο ιστότοπος θα λειτουργεί 100% του χρόνου. Επίσης δεν μπορούμε να εγγυηθούμε ότι τα δεδομένα δεν θα υποστούν ποτέ αλλοίωση.", "Δεν επιτρέπεται να πραγματοποιείτε οποιαδήποτε παράνομη δραστηριότητα στον ιστότοπο.", ["Αν έχετε οποιαδήποτε ερώτηση σχετικά με αυτούς τους όρους ή οποιαδήποτε άλλη απορία για τον ιστότοπο, ", "στείλτε μας email!"] ] thanks = "Σας ευχαριστούμε!" [login] title = "Σύνδεση" # The tab name username = "Όνομα χρήστη:" password = "Κωδικός πρόσβασης:" login_button = "Σύνδεση" send_reset_link = "Αποστολή Συνδέσμου Επαναφοράς" forgot_question = "Ξεχάσατε τον κωδικό σας;" back_to_login = "Επιστροφή στη Σύνδεση" forgot_instruction = "Παρακαλώ εισαγάγετε τη διεύθυνση email που είναι συνδεδεμένη με τον λογαριασμό σας." [login.javascript] network-error = "Παρουσιάστηκε σφάλμα δικτύου. Παρακαλώ δοκιμάστε ξανά." [reset_password] title = "Επαναφορά Κωδικού Πρόσβασης" instruction = "Παρακαλώ εισαγάγετε και επιβεβαιώστε τον νέο σας κωδικό πρόσβασης." new_password = "Νέος Κωδικός Πρόσβασης" confirm_password = "Επιβεβαίωση Κωδικού Πρόσβασης" submit_button = "Επαναφορά Κωδικού Πρόσβασης" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Ελήφθησαν μη έγκυρες παράμετροι." 409_message = ["Ενδέχεται να υπάρχει σύγκρουση ονόματος χρήστη ή email. Παρακαλώ ", "ανανεώστε", " τη σελίδα."] 500_message = "Αυτό δεν θα έπρεπε να συμβεί. Απαιτείται αποσφαλμάτωση!" [news] title = "Νέα" # The tab name more_dev_logs = ["Περισσότερα αρχεία ανάπτυξης δημοσιεύονται στο ", "επίσημο discord", ", και στα ", "φόρουμ του chess.com!"] [server.javascript] ws-invalid_username = "Το όνομα χρήστη δεν είναι έγκυρο" ws-incorrect_password = "Ο κωδικός πρόσβασης είναι λανθασμένος" ws-login_failure_retry_in = "Αποτυχία σύνδεσης, δοκιμάστε ξανά σε" ws-seconds = "δευτερόλεπτα" # unit of time ws-second = "δευτερόλεπτο" # unit of time ws-username_length = "Το όνομα χρήστη πρέπει να έχει από 3 έως 20 χαρακτήρες" ws-username_letters = "Το όνομα χρήστη πρέπει να περιέχει μόνο γράμματα A-Z και αριθμούς 0-9" ws-username_taken = "Αυτό το όνομα χρήστη χρησιμοποιείται ήδη" ws-username_bad_word = "Αυτό το όνομα χρήστη περιέχει μη επιτρεπόμενη λέξη" ws-username_reserved = "Αυτό το όνομα χρήστη είναι δεσμευμένο" ws-email_too_long = "Το email σας είναι υπερβολικά μακρύ." ws-email_invalid = "Αυτό δεν είναι έγκυρο email" ws-email_in_use = "Αυτό το email χρησιμοποιείται ήδη" ws-email_domain_invalid = "Μη έγκυρος τομέας." ws-email_blacklisted = "Το email σας έχει αποκλειστεί." ws-password_length = "Ο κωδικός πρόσβασης πρέπει να έχει από 6 έως 72 χαρακτήρες" ws-password_password = "Ο κωδικός πρόσβασης δεν πρέπει να είναι 'password'" ws-password-reset-link-sent = "Αν υπάρχει λογαριασμός με αυτό το email, έχει σταλεί σύνδεσμος επαναφοράς κωδικού." ws-password-change-success = "Ο κωδικός πρόσβασης επαναφέρθηκε επιτυχώς. Θα μεταφερθείτε σύντομα στη σελίδα σύνδεσης." ws-password-reset-token-invalid = "Το διακριτικό επαναφοράς κωδικού είναι μη έγκυρο ή έχει λήξει." ws-forbidden_wrong_account = "Απαγορεύεται. Αυτός δεν είναι ο λογαριασμός σας." ws-deleting_account_not_found = "Αποτυχία διαγραφής λογαριασμού. Ο λογαριασμός δεν βρέθηκε." ws-deleting_account_in_game = "Δεν μπορείτε να διαγράψετε τον λογαριασμό σας ενώ είστε ακόμα συνδεδεμένοι σε διαδικτυακό παιχνίδι." ws-server_error = "Συγγνώμη, παρουσιάστηκε σφάλμα διακομιστή! Παρακαλώ επιστρέψτε." ws-not_found = "404 Δεν Βρέθηκε" ws-forbidden = "Απαγορεύεται." ws-already_in_game = "Βρίσκεστε ήδη σε παιχνίδι." ws-server_restarting = "Ο διακομιστής επανεκκινείται σε" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "Ο διακομιστής βρίσκεται υπό συντήρηση. Ελέγξτε ξανά σύντομα!" # Can be changed at will to change the display message. ws-minutes = "λεπτά" # unit of time ws-minute = "λεπτό" # unit of time ws-you_cheated = "Ωχ! Παίξατε κάτι παράνομο. Το παιχνίδι ακυρώθηκε. Αν πρόκειται για λάθος, παρακαλώ αναφέρετε αυτό το σφάλμα!" ws-opponent_cheated = "Εντοπίσαμε ότι ο αντίπαλός σας έπαιξε κάτι παράνομο. Το παιχνίδι ακυρώθηκε." ws-cannot_resign_finished_game = "Δεν μπορείτε να παραιτηθείτε από παιχνίδι που έχει ήδη ολοκληρωθεί." ws-invalid_code = "Μη έγκυρος κωδικός!" # Invite code doesn't match any existing invites ws-game_aborted = "Το παιχνίδι ακυρώθηκε." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "Για να Παίξτετε παιχνίδι κατάταξης, πρέπει να είστε συνδεδεμένοι με επαληθευμένο λογαριασμό." [rate-limiting] generic = "Έχετε πραγματοποιήσει πάρα πολλά αιτήματα, παρακαλώ δοκιμάστε ξανά αργότερα." ================================================ FILE: translation/en-US.toml ================================================ name = "English" # Name of language english_name = "English" direction = "ltr" # Change to "rtl" for right to left languages version = "99" maintainer = "Naviary" [header] home = "Infinite Chess" play = "Play" news = "News" login = "Log In" profile = "Profile" createaccount = "Create Account" logout = "Log Out" leaderboard = "Leaderboard" [header.settings] language = "Language" appearance = "Appearance" # Board color/theme and visual effects appearance-theme = "Theme" appearance-coordinates = "Coordinates" # File/rank coordinate labels on the board edges appearance-starfield = "Starfield" # The Starfield space animation underneath void appearance-advanced-effects = "Advanced Effects" # Post processing and board tile effects at extreme distances legalmoves = "Legal Moves" # Legal moves shape legalmoves-squares = "Squares" legalmoves-dots = "Dots" # Dots and 4 corner triangles gameplay = "Gameplay" gameplay-drag = "Dragging pieces" gameplay-premove = "Premoves" gameplay-animations = "Animations" gameplay-fast_transitions = "Fast Transitions" gameplay-lingering_annotations = "Lingering Annotations" perspective = "Perspective" # Perspective-mode perspective-mouse-sensitivity = "Mouse Sensitivity" perspective-fov = "Field of View" sound = "Sound" sound-master-volume = "Master Volume" sound-ambience = "Ambience" ping = ["Ping", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Reset to default" [footer] contact = "Contact us" terms_of_service = "Terms of Service" source_code = "Source Code" language = "Language" [member.javascript] js-confirm_delete = "Are you sure you want to delete your account? This CANNOT be undone! Click OK to enter your password." js-enter_password = "Enter your password to PERMANENTLY delete your account:" [leaderboard.javascript] supported_variants = "This leaderboard is used for the following variants:" rank = "Rank" player = "Player" rating = "Rating" [index] title = "Infinite Chess | Home - The Official Website" # The tab title secondary_title = "The official website for playing live!" what_is_it_title = "What is it?" what_is_it_pargaraphs = [ "Infinite Chess is a variant of chess in which there are no borders, much larger than your familiar 8x8 board. The queen, rooks, and bishops have no limit to how far they can move per turn. Pick any natural number up to infinity!", "With no limit to how far you can move, there are positions possible where the doomsday clock, or checkmate-in-blank, number is represented by the first infinite ordinal, omega ω. In fact, researchers have discovered that any countable ordinal is achievable for the checkmate clock!", "As you can imagine, there are infinite possibilities for starting configurations, many of which you can play competitively! Your end goal is still checkmate, which requires new tactics seeing as there are no walls to trap the enemy king against. Games don't typically last much longer than normal chess games. Pawns also still promote at ranks 1 & 8!", ] how_to_title = "How can I play?" how_to_paragraph = ["The current version release is 1.10 on the ","Play"," page!"] about_title = "About the Project" about_paragraphs = [ "I am Naviary. Since I first discovered Infinite Chess (the concept existed long before this website), I have been very intrigued by it and its possibilities! Up to just recently, playing has been quite difficult, requiring players to create images of the current board and send them back and forth for every move played. Due to this, not many people know about or have been able to play this.", ["It is my goal to build a way to make this easily playable for everyone and grow a community surrounding it. I have spent countless hours of my own time on this website, upkeeping it, and developing the game. I have many more ideas that will keep me occupied for some time. While I wish to keep this free to play, life has requirements. To help support me financially please consider joining my ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Patreon Supporters" github_title = "Github Contributors" [index.javascript] contribution_count_singular = ["", " contribution"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " contributions"] [credits] title = "Credits" copyright = "Anything on the website that is not listed below is copyright of www.InfiniteChess.org" variants_heading = "Variants" variants_credits = [ "Core designed by Andreas Tsevas.", "Space designed by Andreas Tsevas.", "Space Classic designed by Andreas Tsevas.", "Coaip (Chess on an Infinite Plane) designed by V. Reinhart.", "Pawn Horde designed by Inaccessible Cardinal.", "Abundance designed by Clicktuck Suskriberz.", "Pawndard by SexyLexi.", "Classical+ by SexyLexi.", "Knightline by Inaccessible Cardinal.", "Knighted Chess by cycy98.", "designed by Cory Evans and Joel Hamkins.", "designed by Andreas Tsevas.", "designed by Cory Evans and Joel Hamkins.", "designed by Cory Evans, Joel Hamkins, and Norman Lewis Perlmutter.", "Chess on an Infinite Plane - Huygens Options by V. Reinhart.", "Confined Classical by Andreas Tsevas.", "4x4x4x4 Chess by Andreas Tsevas.", "5D Chess by Jace.", ] textures_heading = "Textures" textures_licensed_under = "textures licensed under the" sounds_heading = "Sounds" sounds_credits = [ ["Some sounds are provided by the", "project under the"], "Other sounds created by Naviary.", ] code_heading = "Code" code_credits = [ "by Brandon Jones and Colin MacKenzie IV.", "by Andreas Tsevas and Naviary.", "by FirePlank.", ] language_heading = "Language Translations" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "French by ", "Life Enjoyer", " and ", "cycy98", ".", "Simplified Chinese by ", "Heinrich Xiao", ".", "Traditional Chinese by ", "Heinrich Xiao", ".", "Polish by ", "Tymon Becella", ".", # Apsurt "Português by ", "Emerson P. Machado", ".", # The_Skeleton on discord "Spanish by ", "xa31er", ".", "German by ", "Estetique", "." ] [member] title = "Member" # The tab name verify_message = "Please check your email to verify your account. Unverified accounts are deleted after 3 days." resend_message = ["Didn't get one? Check your spam folder. Also, ", "send it again.", " If you still can't find it, ", "message us."] verify_confirm = "Thank you! Your account has been verified." joined = "Joined:" seen = "Seen:" # Last seen: ____ practice_progress = "Practice Mode Progress:" ranked_elo = "Rating:" infinity_leaderboard_position = "Global Ranking:" infinity_leaderboard_rating_deviation = "Rating Deviation:" reveal_info = "Show Account Info" account_info_heading = "Account Info" email = "Email:" delete_account = "Delete account" [member.badge-tooltips] checkmate_bronze = "Checkmate Veteran: Complete 50% of all practice checkmates." checkmate_silver = "Checkmate Pro: Complete 75% of all practice checkmates." checkmate_gold = "Checkmate Master: Complete 100% of all practice checkmates." [create-account] title = "Create Account" # The tab name username = "Username:" email = "Email:" password = "Password:" create_button = "Create Account" agreement = ["I agree to the ", "Terms of Service", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_reserved = "That username is reserved" js-username_length = "Username must be between 3-20 characters" js-username_tooshort = "Username must be at least 3 characters long" js-username_wrongenc = "Username must only contain letters A-Z and numbers 0-9" js-email_invalid = "This is not a valid email" js-email_too_long = "The email is too long" js-email_inuse = "This email is already in use" [reset-password.javascript] js-pwd_no_match = "Passwords do not match." reset-password = "Reset Password" processing = "Processing..." network-error = "A network error occurred. Please try again." [password-validation] js-pwd_too_short = "Password must be 6+ characters long" js-pwd_too_long = "Password can't be over 72 characters long" js-pwd_not_pwd = "Password must not be 'password'" [leaderboard] title = "Leaderboard" inactive_players = ["Inactive players with a rating deviation above ", " are excluded from the leaderboard."] # A number is inserted between these two quotes your_global_ranking = "Your Global Ranking:" show_more = "Show more..." [play] title = "Infinite Chess - Play" # The tab title loading = "LOADING" error = "ERROR" [play.main-menu] credits = "Credits" play = "Play" practice = "Practice" guide = "Guide" editor = "Board Editor" [play.editor] # Sidebar section headers position = "Position" tools = "Tools" selection = "Selection" palette = "Palette" color = "Color" # Sidebar button tooltips tooltip_reset = "Reset position" tooltip_clear = "Clear position" tooltip_load = "Load position" tooltip_save_as = "Save position as" tooltip_save = "Save position" tooltip_copy_notation = "Copy notation" tooltip_paste_notation = "Paste notation" tooltip_gamerules = "Game rules" tooltip_start_local = "Start local game from position" tooltip_start_engine = "Start engine game from position" # Tool tooltips tooltip_normal = "Normal (F)" tooltip_eraser = "Eraser (G)" tooltip_selection_tool = "Selection (H)" tooltip_specialrights = "Special rights toggle (J)" # Selection tooltips tooltip_select_all = "Select all (Ctrl+A)" tooltip_clear_selection = "Clear selection (Del)" tooltip_copy_selection = "Copy selection (Ctrl+C)" tooltip_paste_selection = "Paste selection (Ctrl+V)" tooltip_invert_color = "Invert selection color" tooltip_rotate_left = "Rotate selection left" tooltip_rotate_right = "Rotate selection right" tooltip_flip_horizontal = "Flip selection horizontally" tooltip_flip_vertical = "Flip selection vertically" # Reset Position window reset_header = "Reset position" reset_message = "Do you want to reset the board and create a new position? Unsaved changes will be lost." # Clear Position window clear_header = "Clear position" clear_message = "Do you want to clear the board and create a new position? Unsaved changes will be lost." # Load Position window enter_position_name = "Enter position name:" save_button = "Save" name_header = "Name" pieces_header = "Pieces" # Represents piece count date_header = "Date" # Represents date last modified no_saves = "No saved positions." # Game Rules window gamerules_header = "Game Rules" player_to_move = "Player to move:" white = "White" black = "Black" en_passant = "En passant square:" move_rule = "Move rule state:" promotion_ranks_white = "Promotion ranks (White):" promotion_ranks_black = "Promotion ranks (Black):" promotion_pieces = "Promotion pieces:" global_special_rights = "Global special rights:" pawn_double_push = "Pawn double push" castling_label = "Castling" win_conditions = "Win conditions:" checkmate = "Checkmate" royal_capture = "Royal capture" all_royals_captured = "All royals captured" all_pieces_captured = "All pieces captured" world_border = "World Border:" # Start Local Game window start_local_game = "Start local game" start_local_game_message = "Do you want to leave the board editor and start a local game from this position? Changes will be saved." # Start Engine Game window start_engine_game = "Start engine game" play_as = "Play as:" time_control = "Time Control (leave blank for unlimited time):" engine_difficulty = "Engine difficulty:" easy = "Easy" medium = "Medium" hard = "Hard" use_default_border = "Use default engine world border:" start_engine_game_message = "Do you want to leave the board editor and start an engine game from this position? Changes will be saved." # Common yes = "Yes" no = "No" [play.guide] title = "Guide" rules = "Rules" rules_paragraphs = [ "The rules to Infinite Chess are almost identical to classical chess, except that the board is infinite in all directions! These are the only notes and changes you need to be aware of:", "Pieces with sliding moves, such as rooks, bishops, and the queen, have no limit to how far they can move in one turn! As long as their path is unobstructed, you can move millions of squares!", ["In the \"Classical\" default variant, white pawns promote at rank 8, and black pawns at rank 1. In this image, this is indicated by the thin black lines—they are faint, see if you can spot them! Pawns only need to reach the opposite line to promote, ", "not", " cross it."], "Squares are no longer described by their letter and rank number (e.g., a1); rather, each square is defined by a pair of x and y coordinates. The a1 square has become (1,1), and the h8 square has become (8,8). On desktop devices, the coordinate your mouse is over is displayed at the top of the screen.", "All other rules are the same as in classical chess, such as checkmate, stalemate, 3-fold repetition, the 50-move rule, castling, en passant, etc.!" ] careful_heading = "Be Careful!" careful_paragraphs = [ "The openness of the infinite board means it is very easy to exploit forks, pins, and skewers. Your backside is often very vulnerable. Watch out for tactics like this! Be creative about forming protection for your king and rooks! Opening strategy is very different from classical chess.", "Many other variants have been created with the aim of strengthening your backside." ] controls_heading = "Controls" controls_paragraph = "Click and drag the board to move around. Scroll to zoom in and out. Click any piece, including your opponent's pieces, to view their legal moves at any point! Additional controls are:" keybinds = [ " to move around.", ["Space", " and ", "Shift", " to zoom in and out."], ["Escape", " to pause the game."], ["Tab", " toggles the arrow indicators on the edges of the screen pointing to pieces off-screen. By default, this mode is set to \"Defense\", which displays arrows pointing to all pieces that can move to your screen along their direction of movement. But ", "Tab", " can switch this mode to \"All\", or \"Off\"; \"All\" displays arrows for all pieces, regardless of if they can move to your screen. This setting can also be toggled in the pause menu. Clicking an arrow will teleport you to the piece it points to."], ["Control", " will force-drag the board instead of dragging a piece, if dragging is enabled in the settings."], " will toggle \"Edit Mode\" in local games. This allows you to move any piece anywhere else on the board! Very useful for analyzing." ] controls_paragraph2 = "Those are the major controls you need to know. But here are some extras if you ever find yourself needing them!" keybinds_extra = [ " will reset the rendering of the pieces. This is useful if they turn invisible. This glitch can happen if you move extreme distances (like 1e21).", " will toggle the rendering of the navigation and game info bars, which can be useful for recording. Streaming and making videos on the game is welcome!", " will toggle your FPS meter. This displays the number of times the game is updating per second, not always the number of frames rendered, as the game skips rendering when nothing visible has changed to increase performance.", " will toggle icon rendering. These are the clickable mini-pictures of the pieces when you zoom out far enough. In imported games with over 50,000 pieces this is automatically toggled off, as it drastically lowers performance, but they can be toggled back on with ", [" (backtick, on the same key as ", ") will toggle Debug mode."], ] fairy_heading = "Fairy Pieces" fairy_paragraph = "You already know what you need to know to play the default \"Classical\" variant. Fairy chess pieces are not used in conventional chess, but are incorporated into other variants! If you find yourself in a variant with some pieces you haven't seen before, learn how they work here!" back = "Back" [play.guide.pieces] chancellor = {name="Chancellor", description="Moves like a rook and a knight combined."} archbishop = {name="Archbishop", description="Moves like a bishop and a knight combined."} amazon = {name="Amazon", description="Moves like a queen and a knight combined. This is the strongest piece in the game!"} guard = {name="Guard", description="Moves like a king, except it is not susceptible to check or checkmate."} hawk = {name="Hawk", description="Leaps exactly 2 or 3 squares in any direction."} centaur = {name="Centaur", description="Moves like a knight and a guard combined."} knightrider = {name="Knightrider", description="Hops like a knight infinitely in one direction, until obstructed."} huygen = {name="Huygen", description="Hops infinitely in one of the four cardinal directions, visiting only squares with a prime numbered distance from its start square, until obstructed."} rose = {name="Rose", description="Circular knightrider. It moves along clockwise and anticlockwise circular trajectories by hopping like a knight and turning 45 degrees after every hop. It can be blocked by other pieces, which is why the red square in the image is unreachable for the rose."} obstacle = {name="Obstacle", description="A neutral piece (not controlled by either player) that blocks movement, but can be captured."} void = {name="Void", description="A neutral piece (not controlled by either player) that represents the absence of board. Pieces may not move through or on top of it."} [play.practice-menu] title = "Practice - Checkmates" play = "Play" back = "Back" difficulty = "Difficulty" [play.play-menu] title = "Play - Online" colors = "Colors" online = "Online" local = "Local" computer = "Computer" variant = "Variant" Classical = "Classical" Confined_Classical = "Confined Classical" Classical_Plus = "Classical+" CoaIP = "Chess on an Infinite Plane" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Palace = "Palace" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Pawn Horde" Space_Classic = "Space Classic" Space = "Space" Obstocean = "Obstocean" Abundance = "Abundance" Amazon_Chandelier = "Amazon Chandelier" Containment = "Containment" Classical_Limit_7 = "Classical - Limit 7" CoaIP_Limit_7 = "Coaip - Limit 7" Chess = "Chess" Classical_KOTH = "Experimental: Classical - KOTH" CoaIP_KOTH = "Experimental: Coaip - KOTH" CoaIP_HO = "Chess on an Infinite Plane - Huygens Option" CoaIP_RO = "Chess on an Infinite Plane - Roses Option" CoaIP_NO = "Chess on an Infinite Plane - Knightriders Option" Omega = "Showcase: Omega" Omega_Squared = "Showcase: Omega^2" Omega_Cubed = "Showcase: Omega^3" Omega_Fourth = "Showcase: Omega^4" 4x4x4x4_Chess = "4×4×4×4 Chess" 5D_Chess = "5D Chess" no_clock = "No Clock" clock = "Clock" minutes = "m" seconds = "s" infinite_time = "Infinite Time" color = "Color" piece_colors = ["Random", "White", "Black"] private = "Private" no = "No" yes = "Yes" rated = "Rated" casual = "Casual" easy = "Easy" medium = "Medium" hard = "Hard" join_games = "Join Existing - Active Games:" private_invite = "Private Invite:" your_invite = "Your Invite Code:" create_invite = "Create Invite" join = "Join" copy = "Copy" back = "Back" code = "Code" [play.gamebuttontooltips] undo_transition = "Undo transition" expand_fit_all = "Expand to fit all" recenter = "Recenter" annotations = "Draw annotations" erase = "Erase annotations" collapse = "Collapse annotations" rewind_move = "Rewind move" forward_move = "Forward move" undo_edit = "Undo edit (Ctrl+Z)" # Board editor redo_edit = "Redo edit (Ctrl+Y)" # Board editor pause = "Pause" undo = "Undo move" # Checkmate practice game restart = "Restart game" # Checkmate practice game [play.pause] title = "Paused" resume = "Resume" arrows = "Arrows: Defense" perspective = "Perspective: Off" copy = "Copy Game" paste = "Paste Game" offer_draw = "Offer Draw" practice_menu = "Practice Menu" main_menu = "Main Menu" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Accept draw offer?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Guest)" you_indicator = "(You)" engine_indicator = "Engine" player_name_white_generic = "White" player_name_black_generic = "Black" white_to_move = "White to move" black_to_move = "Black to move" your_move = "Your move" their_move = "Their move" lost_network = "Lost network." failed_to_load = "One or more resources failed to load. Please refresh." planned_feature = "This feature is planned!" main_menu = "Main Menu" resign_game = "Resign Game" abort_game = "Abort Game" offer_draw = "Offer Draw" # Offer draw button text in the pause menu accept_draw = "Accept Draw" # Offer draw button text in the pause menu arrows_off = "Arrows: Off" arrows_defense = "Arrows: Defense" arrows_all = "Arrows: All" arrows_all_hippogonals = "Arrows: All (with hippogonals)" toggled = "Toggled" menu_online = "Play - Online" menu_local = "Play - Local" menu_computer = "Play - Computer" invite_error_digits = "Invite code needs to be 5 digits." invite_copied = "Copied invite code to clipboard." move_counter = "Move:" constructing_mesh = "Constructing mesh" rotating_mesh = "Rotating mesh" lost_connection = "Lost connection." please_wait = "Please wait a moment to perform this task." webgl_unsupported = "Please upgrade your browser! It does not support WebGL2." bigints_unsupported = "BigInts are not supported. Please upgrade your browser.\nBigInts are needed to make the board infinite." # Checkmate Practice versus = "vs" easy = "Easy" medium = "Medium" hard = "Hard" insane = "Insane" checkmate_logged_out = "You must be logged in to earn badges." checkmate_bronze = "Checkmate Veteran: Complete 50% of all practice checkmates." checkmate_silver = "Checkmate Pro: Complete 75% of all practice checkmates." checkmate_gold = "Checkmate Master: Complete 100% of all practice checkmates." checkmate_bronze_unearned = "Complete 50% of all practice checkmates to earn this badge." checkmate_silver_unearned = "Complete 75% of all practice checkmates to earn this badge." checkmate_gold_unearned = "Complete 100% of all practice checkmates to earn this badge." coords-invalid = "Invalid coordinate format. Please enter integers or e-notation (e.g., 1.23e4)." coords-exceeded = "You can't teleport that far! That would be too easy ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Void" obstacle = "Obstacle" king = "King" giraffe = "Giraffe" camel = "Camel" zebra = "Zebra" knightrider = "Knightrider" amazon = "Amazon" queen = "Queen" royalQueen = "Royal queen" hawk = "Hawk" chancellor = "Chancellor" archbishop = "Archbishop" centaur = "Centaur" royalCentaur = "Royal centaur" rose = "Rose" knight = "Knight" guard = "Guard" huygen = "Huygen" rook = "Rook" bishop = "Bishop" pawn = "Pawn" [play.javascript.copypaste] copied_game = "Copied game to clipboard!" cannot_paste_in_public = "Cannot paste game in a public match!" cannot_paste_in_rated = "Cannot paste game in a rated match!" cannot_paste_in_engine = "Cannot paste game in an engine match!" cannot_paste_after_moves = "Cannot paste game after moves are made!" clipboard_denied = "Clipboard permission denied. This might be your browser." clipboard_invalid = "Clipboard is not in valid ICN notation." game_needs_to_specify = "Game needs to specify either the 'Variant' metadata, or 'position' property." pasting_game = "Pasting game..." pasting_in_private = "Pasting a game in a private match will cause a desync if your opponent doesn't do the same!" piece_count = "Piece count" exceeded = "exceeded" changed_wincon = "Changed checkmate win conditions to royalcapture, and toggled off icon rendering. Hit 'P' to re-enable (not recommended)." loaded_from_clipboard = "Loaded game from clipboard!" copied_position = "Copied position to clipboard!" loaded_position_from_clipboard = "Loaded position from clipboard!" reset_position = "Position was reset!" clear_position = "Position was cleared!" [play.javascript.rendering] on = "On" off = "Off" icon_rendering_off = "Toggled off icon rendering." icon_rendering_on = "Toggled on icon rendering." perspective = "Perspective" perspective_mode_on_desktop = "Perspective mode is available on desktop!" movement_tutorial = "WASD to move. Space & shift to zoom." regenerated_pieces = "Regenerated pieces." [play.javascript.invites] move_mouse = "Move the mouse to reconnect." cannot_cancel = "Cannot cancel invite of undefined ID." you_are_white = "You're: White" you_are_black = "You're: Black" random = "Random" accept = "Accept" cancel = "Cancel" create_invite = "Create Invite" cancel_invite = "Cancel Invite" start_game = "Start Game" join_existing_active_games = "Join Existing - Active Games:" [play.javascript.onlinegame] afk_warning = "You are AFK." opponent_afk = "Opponent is AFK." opponent_disconnected = "Opponent has disconnected." opponent_lost_connection = "Opponent has lost connection." auto_resigning_in = "Auto-resigning in" auto_aborting_in = "Auto-aborting in" not_logged_in = "You are not logged in. Please login to reconnect to this game." game_no_longer_exists = "Game no longer exists." another_window_connected = "Another window has connected." [play.javascript.websocket] no_connection = "No connection." reconnected = "Reconnected." unable_to_identify_ip = "Unable to identify IP." online_play_disabled = "Online play disabled. Cookies not supported. Try a different browser." too_many_requests = "Too many requests. Try again soon." message_too_big = "Message too big." too_many_sockets = "Too many sockets" origin_error = "Origin error." connection_closed = "Connection closed unexpectedly. Server message:" please_report_bug = "This should never happen, please report this bug!" malformed_message = "Received unexpected websocket message. Please report this bug!" [play.javascript.results] you_checkmate = "You win by checkmate!" you_time = "You win on time!" you_resignation = "You win by resignation!" you_disconnect = "You win by abandonment!" you_royalcapture = "You win by royal capture!" you_allroyalscaptured = "You win by all royals captured!" you_allpiecescaptured = "You win by all pieces captured!" you_koth = "You win by king of the hill!" you_generic = "You win!" draw_stalemate = "Draw by stalemate!" draw_repetition = "Draw by repetition!" draw_moverule = ["Draw by the ", "-move-rule!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Draw by insufficient material!" draw_agreement = "Draw by agreement!" draw_generic = "Draw!" aborted = "Game aborted." opponent_checkmate = "You lose by checkmate!" opponent_time = "You lose on time!" opponent_resignation = "You lose by resignation!" opponent_disconnect = "You lose by abandonment!" opponent_royalcapture = "You lose by royal capture!" opponent_allroyalscaptured = "You lose by all royals captured!" opponent_allpiecescaptured = "You lose by all pieces captured!" opponent_koth = "You lose by king of the hill!" opponent_generic = "You lose!" white_checkmate = "White wins by checkmate!" black_checkmate = "Black wins by checkmate!" white_time = "White wins on time!" black_time = "Black wins on time!" white_resignation = "White wins by resignation!" black_resignation = "Black wins by resignation!" white_disconnect = "White wins by disconnection!" black_disconnect = "Black wins by disconnection!" white_royalcapture = "White wins by royal capture!" black_royalcapture = "Black wins by royal capture!" white_allroyalscaptured = "White wins by all royals captured!" black_allroyalscaptured = "Black wins by all royals captured!" white_allpiecescaptured = "White wins by all pieces captured!" black_allpiecescaptured = "Black wins by all pieces captured!" white_koth = "White wins by king of the hill!" black_koth = "Black wins by king of the hill!" bug_generic = "This is a bug, please report!" [play.javascript.editor] # Sidebar toggle expand_sidebar = "Expand sidebar" collapse_sidebar = "Collapse sidebar" # Position header new_position = "New position" # Load/Save Position window headers load_position_header = "Load Position" save_position_as_header = "Save Position As" # Confirmation modal delete_title = "Delete position?" delete_message = ["Are you sure that you want to delete position \"", "\"? This cannot be undone."] load_title = "Load position?" load_message = ["Are you sure that you want to load position \"", "\"? Unsaved changes to the current position will be lost."] overwrite_title = "Overwrite position?" overwrite_message = ["Are you sure that you want to overwrite position \"", "\"? This cannot be undone."] # Save list row tooltips tooltip_load_position = "Load position" tooltip_save_to_cloud = "Save to cloud" tooltip_remove_from_cloud = "Remove from cloud" # Transfer position from cloud to local browser tooltip_delete_position = "Delete position" # Toast messages position_loaded = "Position successfully loaded." cannot_start_local_empty = "Cannot start local game from empty position!" cannot_start_engine_empty = "Cannot start engine game from empty position!" position_not_supported = "Position is not supported for reason:" illegal_position_king_capture = "Illegal position: King capture possible on turn 1." saved_in_browser = "Position saved in browser." position_corrupted = "The position was corrupted." failed_to_load = "Failed to load position:" failed_to_convert_icn = "Failed to convert position to ICN for cloud upload." too_large_for_cloud = "Position is too large to save to the cloud." failed_to_upload = "Failed to upload position to cloud:" saved_to_cloud = "Position saved to cloud." no_changes = "No changes made." failed_to_load_cloud = "Failed to load position from the cloud:" failed_to_delete_cloud = "Failed to delete position from the cloud:" failed_to_remove_cloud = "Failed to remove position from cloud:" # Transfers position from cloud to local browser saved_locally = "Position saved locally." failed_to_fetch_cloud = "Failed to fetch cloud saves:" [terms] title = "Terms of Service" warning = ["THIS DOCUMENT IS NOT LEGALLY BINDING. We are only accountable for the English version of this document. This translation is provided solely for general informational purposes. You can access the official English version ", "here", "."] consent = "By using this site, you agree to abide by the following terms. If you do not agree, you must immediately stop using the site." guardian_consent = "If you are under 18, you must receive consent from a parent or legal guardian to use this site and to create an account." parents_header = "Parents" parents_paragraphs = [ "There is an algorithm in place for prohibiting users setting their name to common cuss words. At this time there is no method of communication between members on the site.", "Currently, members cannot set their own profile picture. There is a plan to allow this feature. At that time we will do our best to prevent innapropriate profile pictures.", ] fair_play_header = "Fair Play" fair_play_paragraph1 = ["You cannot have more than one account."] fair_play_paragraph2 = "To keep play fun and fair for everyone, you must NOT:" fair_play_rules = [ "Modify or manipulate the code in any way, including but not limited to: Using console commands, local overrides, custom scripts, modifying http requests, websocket messages, etc. This can be done to intentionally break the game, play otherwise illegal moves, or to give you an advantage.", "Abuse bugs or glitches in order to abort the game, or give you an advantage.", "In rated games, receive help/advice from another person or program as to what you should play. (Creating an engine is ok and encouraged, but you must limit its use to unrated, casual, games)", "Trade elo points with other people by purposefully losing with intent to boost the elo of your opponent, or by receiving elo points from an opponent that intends to lose to boost your own rating. This abuses the system and creates unaccurate ratings according to your level of skill." ] cleanliness_header = "Cleanliness" cleanliness_rules = [ "In all your language on the site, you must remain clean, no vulgarity or cursing. You cannot bully, harass, or threaten anyone, or do anything that is illegal. You cannot spam other users or forums.", "You cannot upload imagery to your profile that is inappropriate, suggestive, or gory. Doing so may result in a ban or termination of your account." ] privacy_header = "Privacy" privacy_rules = [ "Currently, the only personal information we collect is your email. This is with intent to verify users' accounts, and provide a means of proving who they are when they request a password reset. We do not send any promotional emails or offers. We do not share any user's email address with anyone else.", "InfiniteChess.org may collect data about your usage on the site, including your ip address. This is intended to help prevent attacks from bots and other unwanted entities, and to keep accurate statistics in the database. This is NOT your home address.", "All games you play on this website become public information. If you wish to remain anonymous, do not share your username with friends or family. If this is your desire, it is your responsibility to make sure no one finds out your username is associated with your human identity.", "Your account online status, and the approximate last time you were active on the website, is also public information.", ["While InfiniteChess.org will strive to keep everyone's account and personal information safe to the best of our ability, in the event of a hack or data leak, you may not press charges on us. If a data leak ever happens, users will be notified on the ", "News", "page."], "There is no content available on the site for purchase. Any other personal information is not collected.", "To have your private information deleted from our servers, you may delete your account through your profile page. The only thing with ties to your username that we will NOT delete, is your game history, because all games are public information.", ] cookie_header = "Cookie Policy" cookie_paragraphs = [ "This site uses cookies, which are small text files that are stored in your browser, and sent to the server when connections are made. The purpose of these cookies are to: Validate your login session, validate your browser belongs to the chess game it says it's in, and to store user game preferences so they can keep their preferences when they re-visit the site. The site does not use 3rd-party cookies, cookies are not shared with external parties.", "Cookies are required for this site and game to function correctly. If you do not want the site to store cookies, you must stop using the site. You can navigate to your browsers preferences to delete existing cookies. By continuing to use this site, you are consenting to the use of cookies." ] conclusion_header = "Conclusion" conclusion_paragraphs = [ "Any violations of these terms may result in a ban or termination of your account. InfiniteChess.org wants to be able to give everyone the opportunity to play and have fun! But, we reserve the right to, at any time, ban or terminate the accounts of any users, for reasons that need not to be disclosed. Charges may not be pressed against us.", ["These terms of service may be modified at any point. It is YOUR responsibility to make sure you stay updated on the latest changes! When these terms of service receive an update, that information will be posted on the", "News", "page. If, at the time of a terms-of-service update, you do not agree with the new terms, you must immediately stop using the website. You may delete your account from your profile page. If you delete your account, all your private information and account data will be deleted, EXCEPT we do not delete your game history associated with your username, that is public information."], ["This site is open source. You may copy or distribute anything on this website as long as you follow the conditions outlined in", "the license terms", "! If this link is broken, it is your responsibility to find the terms."], "We cannot guarantee the site will be running 100% of the time. We also cannot guarantee that data will never be corrupted.", "You may not perform any illegal activity on the site.", ["If you have any questions regarding these terms, or any other question about the site,", "email us!"] ] thanks = "Thank you!" [login] title = "Log In" # The tab name username = "Username:" password = "Password:" login_button = "Log In" send_reset_link = "Send Reset Link" forgot_question = "Forgot Password?" back_to_login = "Back to Login" forgot_instruction = "Please enter the email address associated with your account." [login.javascript] network-error = "A network error occurred. Please try again." [reset_password] title = "Reset Your Password" instruction = "Please enter and confirm your new password." new_password = "New Password" confirm_password = "Confirm Password" submit_button = "Reset Password" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Invalid parameters were received." 409_message = ["There may have been a clashing username or email. Please ", "reload", ", the page."] 500_message = "This isn't supposed to happen. There is some debugging to be done!" [news] title = "News" # The tab name more_dev_logs = ["More dev logs are posted on the ", "official discord", ", and on the ", "chess.com forums!"] [server.javascript] ws-invalid_username = "Username is invalid" ws-incorrect_password = "Password is incorrect" ws-login_failure_retry_in = "Failed to login, try again in" ws-seconds = "seconds" # unit of time ws-second = "second" # unit of time ws-username_letters = "Username must only contain letters A-Z and numbers 0-9" ws-username_taken = "That username is taken" ws-username_bad_word = "That username contains a word that is not allowed" ws-email_too_long = "Your email is too looooooong." ws-email_invalid = "This is not a valid email" ws-email_in_use = "This email is already in use" ws-email_domain_invalid = "Invalid domain." ws-email_blacklisted = "Your email is blacklisted." ws-password_length = "Password must be 6-72 characters long" ws-password_password = "Password must not be 'password'" ws-password-reset-link-sent = "If an account with that email exists, a password reset link has been sent." ws-password-change-success = "Password has been reset successfully. You will be redirected to the login page shortly." ws-password-reset-token-invalid = "Password reset token is invalid or has expired." ws-forbidden_wrong_account = "Forbidden. This is not your account." ws-deleting_account_not_found = "Failed to delete account. Account not found." ws-deleting_account_in_game = "You cannot delete your account while you are still connected to an online game." ws-server_error = "Sorry, there was a server error! Please go back." ws-not_found = "404 Not Found" ws-forbidden = "Forbidden." ws-already_in_game = "You are already in a game." ws-you_cheated = "Oops! You played something illegal. Game has been aborted. If this was a mistake, please report this bug!" ws-opponent_cheated = "We caught your opponent playing something illegal. Game has been aborted." ws-cannot_resign_finished_game = "Can't resign game, it's already over." ws-invalid_code = "Invalid code!" # Invite code doesn't match any existing invites ws-game_aborted = "Game aborted." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "To play ranked, you need to be signed in with a verified account." [rate-limiting] generic = "You have made too many requests, please try again later." error = "Too many requests" ================================================ FILE: translation/es-ES.toml ================================================ name = "Español" # Name of language english_name = "Spanish" direction = "ltr" # Change to "rtl" for right to left languages version = "83" maintainer = "xa31" [header] home = "Infinite Chess" play = "Jugar" news = "Noticias" login = "Iniciar Sesión" profile = "Perfíl" createaccount = "Crear Cuenta" logout = "Cerrar sesión" leaderboard = "Clasificaciónes" [header.settings] language = "Idioma" appearance = "Apariencia" # Board color/theme and visual effects appearance-theme = "Tema" appearance-starfield = "Campo de Estrellas" # The Starfield space animation underneath void appearance-advanced-effects = "Efectos Avanzados" # Post processing and board tile effects at extreme distances legalmoves = "Jugadas Legales" # Legal moves shape legalmoves-squares = "Casillas" legalmoves-dots = "Puntos" # Dots and 4 corner triangles selection = "Selección" selection-drag = "Arrastrar piezas" selection-premove = "Premovimientos (Premoves)" selection-animations = "Animaciones" selection-lingering_annotations = "Anotaciones duraderas" perspective = "Perspectiva" # Perspective-mode perspective-mouse-sensitivity = "Sensibilidad del ratón" perspective-fov = "Campo de visión (FOV)" sound = "Sonido" sound-master-volume = "Volúmen General" sound-ambience = "Ambientación" ping = ["Latencia", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Restablecer valores" [footer] contact = "Contacte con nosotros" terms_of_service = "Términos de Servicio" source_code = "Código Fuente" language = "Idioma" [member.javascript] js-confirm_delete = "¿Esta seguro que desea eliminar su cuenta? !Esta acción NO puede ser deshecha! Haga clic en OK para introducir su contraseña." js-enter_password = "Introduzca su contraseña para eliminar su cuenta PERMANENTEMENTE:" [leaderboard.javascript] supported_variants = "Esta tabla se usa para las siguientes variantes: " rank = "Clasificación" player = "Jugador" rating = "Puntuación" [index] title = "Infinite Chess | Página principal – La web oficial" # The tab title secondary_title = "¡La página web oficial para jugar!" what_is_it_title = "¿Qué es?" what_is_it_pargaraphs = [ "Infinite Chess es una variante de ajedrez en la cual no hay bordes, y es mucho mas grande que el típico tablero de 8x8. La dama, torres y alfiles no tienen ningún límite a como de lejos pueden moverse cada turno. ¡Escoge cualquier número natural hasta el infinito!", "Sin límite a como de lejos puedes moverte, hay posiciones en las que el reloj de mate, o mate en x, adquiere el primer ordinal infinito, omega ω. De hecho, estudios han demostrado que cualquier número ordinal contable, ¡Es valido para el reloj de mate!", "Como puedes imaginar, hay infinitas posibilidades para la posición de inicio, ¡Muchas de las cuales puedes jugar competitivamente! Tu objetivo final sigue siendo el jaque mate, que requiere nuevas estrategias, dado que ya no hay muros para atrapar al rey rival. Las partidas no suelen durar mas que las de ajedrez normal. ¡Los Peones siguen coronando en las filas 1 y 8!", ] how_to_title = "¿Como Puedo Jugar?" how_to_paragraph = ["¡La actual versión es la 1.10, y está disponible en la pagina ","Jugar","!"] about_title = "Sobre el proyecto" about_paragraphs = [ "Soy Naviary. Desde que descubrí Infinte Chess (el concepto existe desde hace mucho antes que esta web), ¡Me han intrigado sus posibilidades! Hasta hace muy poco, jugar ha sido bastante complicado, usuarios de chess.com tenían que crear imágenes del tablero actual y enviarlas entre ellos cada jugada. Debido a esto, no mucha gente conoce o ha sido capaz de jugar a esto.", ["Es mi objetivo crear una manera de hacerlo fácilmente jugable por todo el mundo, y crear una comunidad rodeándolo. He invertido incontables horas de mi tiempo en esta web, manteniendo y desarrollando el juego. Aún tengo muchas más ideas que me mantendrán ocupado durante un tiempo. Aunque deseo mantener este proyecto gratis, la vida tiene requerimientos, y para ayudarme financieramente por favor considera unirte a mi ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Contribuidores en Patreon" github_title = "Contribuidores en Github" [index.javascript] contribution_count_singular = ["", " contribución"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " contribuciones"] [credits] title = "Créditos" copyright = "Cualquier cosa presente en la web que no este en la siguiente lista tiene copyright de www.InfiniteChess.org" variants_heading = "Variantes" variants_credits = [ "Core diseñado por Andreas Tsevas.", "Space diseñado por Andreas Tsevas.", "Space Classic diseñado por Andreas Tsevas.", "Aeupi (Ajedrez en un plano infinito) diseñado por V. Reinhart.", "Pawn Horde diseñado por Inaccessible Cardinal.", "Abundance diseñado por Clicktuck Suskriberz.", "Pawndard hecho por SexyLexi.", "Classical+ hecho por SexyLexi.", "Knightline hecho por Inaccessible Cardinal.", "Knighted Chess hecho por cycy98.", "diseñado por Cory Evans y Joel Hamkins.", "diseñado por Andreas Tsevas.", "diseñado por Cory Evans y Joel Hamkins.", "diseñado por Cory Evans, Joel Hamkins, y Norman Lewis Perlmutter.", "Ajedrez en un plano infinito - Opciones Huygens por V. Reinhart.", "Trappist-1 por V. Reinhart", "Ajedrez 4x4x4x4 por Andreas Tsevas.", "Ajedrez 5D por Jace.", ] textures_heading = "Texturas" textures_licensed_under = "texturas bajo la licencia" sounds_heading = "Sonidos" sounds_credits = [ ["Algunos sonidos han sido proporcionados por el provecto", "bajo la licencia"], "Otros sonidos creados por Naviary.", ] code_heading = "Codigo" code_credits = [ "por Brandon Jones y Colin MacKenzie IV.", "por Andreas Tsevas y Naviary.", ] language_heading = "Traducciones" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Francés por ", "Life Enjoyer", " y ", "cycy98", ".", "Chino simplificado por ", "Heinrich Xiao", ".", "Chino tradicional por ", "Heinrich Xiao", ".", "Polaco por ", "Tymon Becella", ".", # Apsurt "Portugués por ", "Emerson P. Machado", ".", # The_Skeleton on discord "Español por ", "xa31er", ".", "Alemán por ", "Estetique", "." ] [member] title = "Miembro" # The tab name verify_message = "Por favor comprueba tu e-mail para verificar tu cuenta. Las cuentas sin verificar se eliminan tras 3 días." resend_message = ["¿El e-mail no ha llegado? Comprueba la carpeta de spam. También puedes, ", "mandarlo otra vez.", "Si no puedes encontrarlo, ", "mandanos un mensaje."] verify_confirm = "¡Gracias! Tu cuenta ha sido verificada." joined = "Se unió:" seen = ["Visto hace:", ""] practice_progress = "Progreso del modo práctica" ranked_elo = "Puntuación:" infinity_leaderboard_position = "Clasificación global:" infinity_leaderboard_rating_deviation = "Desviación de la puntuación:" reveal_info = "Mostrar información de la Cuenta" account_info_heading = "Información de la Cuenta" email = "E-mail:" delete_account = "Eliminar cuenta" [member.badge-tooltips] checkmate_bronze = "Veterano del jaque mate: Completa el 50% de los mates de práctica." checkmate_silver = "Pro del jaque mate: Completa el 75% de los mates de práctica." checkmate_gold = "Maestro del jaque mate: Completa el 100% de los mates de práctica." [create-account] title = "Crear Cuenta" # The tab name username = "Nombre de Usuario:" email = "E-mail:" password = "contraseña:" create_button = "Crear Cuenta" agreement = ["Acepto los ", "Términos de Servicio", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_tooshort = "El nombre de usuario debe tener 3 caracteres como mínimo" js-username_wrongenc = "El nombre de usuario debe contener solo los caracteres A-Z y 0-9" js-email_invalid = "No es un e-mail válido" js-email_too_long = "El email es demasiado largo." js-email_inuse = "Este e-mail ya esta registrado con otra cuenta" [reset-password.javascript] js-pwd_no_match = "Las contraseñas no coinciden" reset-password = "Reiniciar contraseña" processing = "Procesando..." network-error = "Un error de red ha ocurrido. Pro favor prueba otra vez" [password-validation] js-pwd_too_short = "La contraseña debe tener 6 o más caracteres" js-pwd_too_long = "La contraseña no puede tener mas de 72 caracteres" js-pwd_not_pwd = "La contraseña no debe ser 'password'" [leaderboard] title = "Tabla de clasificaciones" inactive_players = ["Los jugadores inactivos con una incertidumbre de puntuación por encima de ", " son excluídos de la la tabla de clasificaciónes."] # A number is inserted between these two quotes your_global_ranking = "Tu clasificación global:" show_more = "Mostrar más..." [play] title = "Infinite Chess - Jugar" # The tab title loading = "CARGANDO" error = "ERROR" [play.main-menu] credits = "Créditos" play = "Jugar" practice = "Practicar" guide = "Guía" editor = "Editor de tablero" [play.guide] title = "Guía" rules = "Reglas" rules_paragraphs = [ "Las reglas del ajedrez infinito son casi idénticas a las del ajedrez clásico, ¡Excepto que el tablero es infinito en todas las direcciones! Estas son las únicas notas y cambios que necesitas saber:", "Las piezas que tienen movimientos deslizantes, como las torres, alfiles y damas, ¡No tienen ningún límite a como de lejos se pueden mover en un turno! Mientras su camino no esté obstruido, ¡Pueden moverse millones de casillas!", ["En variante por defecto \"Clásica\", los peones blancos coronan en la fila 8, y los negras en la fila 1. En esta imagen, esto se indica con las finas lineas negras, son difíciles de ver, ¡Fíjate a ver si puedes encontrarlas! Los Peones solo necesitan llegar a la línea opuesta para coronar", "no", " cruzarla."], "La notación de las casillas ya no se describe por la letra y numero de fila, (ej. a1), en cambio, cada casilla se identifica por una pareja de coordenadas x e y. La casilla a1 se convierte en (1,1), y la casilla h8 en (8,8). En dispositivos de escritorio, la coordenada que el puntero de tu ratón esté encima se muestra en la parte superior de la pantalla", "¡Todas las demás reglas son iguales que en el ajedrez clásico, como el jaque mate, tablas, triple repetición, la regla de los 50 movimientos, enroque, en passant, etc.!" ] careful_heading = "¡Ten cuidado!" careful_paragraphs = [ "La libertad del tablero infinito significa que es muy fácil explotar ataques dobles y clavadas. Tu lado trasero es a menudo una parte muy vulnerable. ¡Ten cuidado con tácticas como esta! ¡Sé creativo al formar protección para tu rey y tus torres! La estrategia de apertura es muy distinta a la del ajedrez clásico.", "Muchas otras variantes han sido creadas con el objetivo de fortalecer tu defensa trasera." ] controls_heading = "Controles" controls_paragraph = "Haz clic y arrastra para mover la cámara. Mueve la rueda del ratón para hacer zoom. Haz clic en cualquiera pieza, incluidas las de tus oponentes, ¡Para ver sus movimientos legales en cualquier momento! Otros controles son:" keybinds = [ " para mover la cámara.", ["Espacio", " y ", "Mayus Izq.", " para hacer zoom."], ["Esc", " para pausar el juego."], ["Tab", " activa o desactiva las flechas indicadoras en los bordes de la pantalla que apuntan a las piezas que están fuera de plano. Por defecto, están en modo \"Defensa\", que muestra una flecha para las piezas que pueden moverse a tu localización desde donde están. Pero ", "tab", " puede cambiar al modo \"Todos\", o \"Ninguno\", de los cuales \"Todos\" revela todas las piezas en estas ortogonales y diagonales, sin importar que puedan moverse de esa manera. Esta opción también puede cambiarse en el menú de pausa. Hacer clic en las flechas te lleva a la pieza que están apuntando."], ["Control", " fuerza el arrastre del tablero en vez de las piezas, si el arrastre está activado en las opciones."], " activa o desactiva \"Modo Edición\" en partidas locales. ¡Esto te permite mover cualquier pieza a cualquier lugar del tablero! Muy útil para analizar posiciones." ] controls_paragraph2 = "Esos son los principales controles que necesitas saber." keybinds_extra = [ " reinicia el renderizado de las piezas. Esto es útil si la pieza se vuelve invisible. Este error puede ocurrir si mueves las piezas a distancias extremas (como 1e12).", " activa o desactiva el renderizado de las barras de navegación e información, que puede ser útil para grabar ¡Hacer streaming y vídeos en el juego es bienvenido!", " activa o desactiva el medidor de FPS. Esto muestra el número de veces que se actualiza el juego por segundo, no siempre el numero de FPS, ya que el juego salta el renderizado cuando nada visible haya cambiado, para ahorrar tiempo de computación.", " activa o desactiva el renderizado por iconos. Los iconos son unas mini-imágenes clicables de las piezas cuando haces el suficiente zoom hacia fuera. En partidas importadas con más de 50.000 piezas esto se desactiva automáticamente, al ser costoso computacionalmente, pero puede volverse a activar con ", [" (tilde invertida, la misma tecla que ", ") activará o desactivará el modo Debug"], ] fairy_heading = "Piezas Fairy" fairy_paragraph = "Ya sabes todo lo que necesitas saber para jugar a la variante por defecto \"Clásica\". Las piezas de ajedrez Fairy no se usan en ajedrez convencional, ¡Pero están incorporadas en otras variantes! Si te encuentras en una variante con algunas piezas que no has visto antes, ¡Vamos a aprender como funcionan aquí!" editing_heading = "Edición de tablero" editing_paragraphs = [ ["Hay un ", "editor de tablero externo", " ¡Disponible ahora mismo en una hoja de Google sheets pública! Incluye instrucciones sobre como usarla. Requiere unos conocimientos básicos sobre Google Sheets. Después de la preparación, ¡Podrás crear y importar posiciones personalizadas en el juego a través de el botón \"Pegar Juego\" en el menu opciones!"], "Para jugar una posición personalizada con un amigo, mándale una invitación privada, después los dos pegáis el código de juego ¡Antes de empezar a jugar!", "Un editor de tablero integrado está planeado.", ] back = "Atrás" [play.guide.pieces] chancellor = {name="Canciller", description="Se mueve como una torre y un caballo combinados."} archbishop = {name="Arzobispo", description="Se mueve como un alfil y un caballo combinados."} amazon = {name="Amazona", description="Se mueve como una dama y un caballo combinados. ¡Esta es la pieza más potente del juego!"} guard = {name="Guardia", description="Se mueve como un rey, pero no es susceptible al jaque o el jaque mate."} hawk = {name="Halcón", description="Salta exactamente 2 o 3 casillas en cualquier dirección."} centaur = {name="Centauro", description="Se mueve como un caballo y un guardia combinados."} knightrider = {name="Jinete", description="Se mueve como un caballo infinitamente en una dirección, hasta que esté bloqueado."} huygen = {name="Huygen", description="Salta infinitamente en una de las direcciones cardinales, visitando solo los cuadrados primos desde la casilla de salida, hasta que se vea obstruido."} rose = {name="Rosa", description="Jinete circular. Se mueve en trayectorias circulares horarias y antihorarias, saltando como un caballo, pero girando 45 grados tras cada salto. Puede ser bloqueada por otras piezas, que es la razón por la que el cuadrado rojo en la imágen no es accesible para la rosa."} obstacle = {name="Obstáculo", description="Una pieza neutral (Que no es controlada por ningún jugador) que bloquea el movimiento, pero puede ser capturada."} void = {name="Vacío", description="Una pieza neutral (Que no es controlada por ningún jugador) que representa la ausencia de tablero. Las demás piezas no pueden moverse a través o encima de él."} [play.practice-menu] title = "Práctica - Mates" play = "Jugar" back = "Atrás" difficulty = "Dificultad" [play.play-menu] title = "Jugar - Online" colors = "Colores" online = "Online" local = "Local" computer = "Ordenador" variant = "Variante" Classical = "Clásica" Confined_Classical = "Clásica confinada" Classical_Plus = "Clásica+" CoaIP = "Ajedrez en un plano infinito (Aeupi)" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Palace = "Palacio" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Horda de peones" Space_Classic = "Clásica Espacio" Space = "Espacio" Obstocean = "Obstocean" Abundance = "Abundancia" Amazon_Chandelier = "Candelabro de Amazona" Containment = "Contención" Classical_Limit_7 = "Clásica - Límite 7" CoaIP_Limit_7 = "Aeupi - Límite 7" Chess = "Ajedrez" Classical_KOTH = "Experimental: Clásica - KOTH" CoaIP_KOTH = "Experimental: Aeupi - KOTH" CoaIP_HO = "Ajedrez en un plano infinito - Huygens" CoaIP_RO = "Ajedrez en un plano infinito - Rosas" CoaIP_NO = "Ajedrez en un plano infinito - Jinetes" Omega = "Demostración: Omega" Omega_Squared = "Demostración: Omega^2" Omega_Cubed = "Demostración: Omega^3" Omega_Fourth = "Demostración: Omega^4" 4x4x4x4_Chess = "Ajedrez 4×4×4×4" 5D_Chess = "Ajedrez 5D" no_clock = "Sin Reloj" clock = "Reloj" minutes = "m" seconds = "s" infinite_time = "Tiempo Infinito" color = "Color" piece_colors = ["Aleatorio", "Blancas", "Negras"] private = "Privado" no = "No" yes = "Sí" rated = "Por puntos" casual = "Casual" easy = "Facil" medium = "Normal" hard = "Difícil" join_games = "Unirse a una partida existente - Partidas Activas:" private_invite = "Invitación privada:" your_invite = "Tu código de invitación:" create_invite = "Crear invitación" join = "Unirse" copy = "Copiar" back = "Atrás" code = "Codigo Fuente" [play.gamebuttontooltips] undo_transition = "Deshacer transición" expand_fit_all = "Expandir para ver todo" recenter = "Recentrar" annotations = "Dibujar anotaciones" erase = "Borrar anotaciones" collapse = "Ocultar anotaciones" rewind_move = "Deshacer movimiento" forward_move = "Rehacer movimiento" undo_edit = "Deshacer (Crtl+Z)" # Board editor redo_edit = "Rehacer (Ctrl+Y)" # Board editor pause = "Pausa" undo = "Deshacer movimiento" # Checkmate practice game restart = "Reiniciar posición" # Checkmate practice game [play.pause] title = "Pausa" resume = "Resumir" arrows = "Flechas: Defensa" perspective = "Perspectiva: Apagada" copy = "Copiar Partida" paste = "Pegar Partida" offer_draw = "Ofrecer Tablas" practice_menu = "Menú de práctica" main_menu = "Menú principal" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Aceptar tablas?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Invitado)" you_indicator = "(Tú)" engine_indicator = "Ordenador" player_name_white_generic = "Blancas" player_name_black_generic = "Negras" white_to_move = "Juegan blancas" black_to_move = "Juegan negras" your_move = "Tu turno" their_move = "Su turno" lost_network = "Conexión perdida" failed_to_load = "Uno o más recursos fallaron al cargar. Por favor refresca la página." planned_feature = "¡Esta función esta planeada!" main_menu = "Menú principal" resign_game = "Rendirse" abort_game = "Abortar partida" offer_draw = "Ofrecer tablas" # Offer draw button text in the pause menu accept_draw = "Aceptar tablas" # Offer draw button text in the pause menu arrows_off = "Flechas: Apagadas" arrows_defense = "Flechas: Defensa" arrows_all = "Flechas: Todas" arrows_all_hippogonals = "Flechas: Todas (Con hippogonales)" toggled = "Activado / Desactivado" menu_online = "Jugar - Online" menu_local = "Jugar -Local" invite_error_digits = "El código de invitación tiene que tener 5 dígitos." invite_copied = "Código de invitación copiado al portapapeles." move_counter = "Jugada:" constructing_mesh = "Construyendo malla" rotating_mesh = "Rotando malla" lost_connection = "Conexión perdida." please_wait = "Por favor espera un momento para realizar esta acción." webgl_unsupported = "¡Por favor, actualiza tu navegador! No es compatible con WebGL2." bigints_unsupported = "Las BigInts no tienen soporte. Por favor actualiza tu navegador.\nLas BigInts son necesarias para hacer que el tablero sea infinito." # Checkmate Practice versus = "vs" easy = "Facil" medium = "Media" hard = "Dificil" insane = "Insana" checkmate_logged_out = "Debes iniciar sesión para conseguir insignias" checkmate_bronze = "Veterano del jaque mate: Completa el 50% de los mates de práctica." checkmate_silver = "Pro del jaque mate: Completa el 75% de los mates de práctica." checkmate_gold = "Maestro del jaque mate: Completa el 100% de los mates de práctica." checkmate_bronze_unearned = "Completa el 50% de los mates de práctica." checkmate_silver_unearned = "Completa el 75% de los mates de práctica." checkmate_gold_unearned = "Completa el 100% de los mates de práctica." coords-invalid = "Formato de coordenadas no válido. Por favor introduze números enteros o notación 'e' (ej. 1.23e4)" coords-exceeded = "¡No puedes ir tan lejos! Eso sería muy facil ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Vacio" obstacle = "Obstáculo" king = "Rey" giraffe = "Girafa" camel = "Camello" zebra = "Cebra" knightrider = "Jinete" amazon = "Amazona" queen = "Dama" royalQueen = "Dama real" hawk = "Halcón" chancellor = "Canciller" archbishop = "Arzobispo" centaur = "Centauro" royalCentaur = "Centauro real" rose = "Rosa" knight = "Caballo" guard = "Guardia" huygen = "Huygen" rook = "Torre" bishop = "Alfil" pawn = "Peón" [play.javascript.copypaste] copied_game = "¡Posición copiada en el portapapeles!" cannot_paste_in_public = "¡No se pueden pegar posiciones en una partida pública" cannot_paste_in_rated = "¡No se pueden pegar posiciones en una partida por puntos!" cannot_paste_in_engine = "¡No se pueden pegar posiciones en una partida con el ordenador!" cannot_paste_after_moves = "¡No se pueden pegar posiciones si ya se han hecho movimientos!" clipboard_denied = "Permiso de portapapeles denegado. Esto puede ser tu navegador." clipboard_invalid = "El portapapeles no está en notación ICN valida." game_needs_to_specify = "LA posición debe especificar el metadato 'Variant', o la propiedad 'position'." invalid_wincon = "El jugador tiene una condición de victoria no válida" pasting_game = "Pegando posición..." pasting_in_private = "Pegar una posición en una partida privada ¡Causará una desicronización si tu oponente no hace lo mismo!" piece_count = "Numero de piezas" exceeded = "excedido" changed_wincon = "Cambiada la condición de victoria por jaque mate a captura real, y desactivado el renderizado de iconos. Pulsa 'P' para re-activarlo (no recomendado)." loaded_from_clipboard = "Partida cargada desde el portapapeles" copied_position = "¡Posición copiada al portapapeles!" loaded_position_from_clipboard = "¡Posición cargada desde el portapapeles!" reset_position = "¡Posición reiniciada!" clear_position = "¡Posición limpiada!" [play.javascript.rendering] on = "Activado" off = "Desactivado" icon_rendering_off = "Desactivado el renderizado de iconos." icon_rendering_on = "Activado el renderizado de iconos." perspective = "Perspectiva" perspective_mode_on_desktop = "¡El modo perspectiva está disponible en dispositivos de escritorio!" movement_tutorial = "WASD para moverse. Espacio y Mayus Izq. para hacer zoom." regenerated_pieces = "Piezas regeneradas." [play.javascript.invites] move_mouse = "Mueve el ratón para reconectarte" cannot_cancel = "No se puede cancelar una invitación de ID indefinido." you_are_white = "Eres: Blancas" you_are_black = "Eres: Negras" random = "Aleatorio" accept = "Aceptar" cancel = "Cancelar" create_invite = "Crear Invitación" cancel_invite = "Cancelar Invitación" start_game = "Empezar partida" join_existing_active_games = "Unirse a una partida existente - Partidas Activas:" [play.javascript.onlinegame] afk_warning = "Estás AFK." opponent_afk = "Tu oponente está AFK." opponent_disconnected = "Tu oponente se ha desconectado." opponent_lost_connection = "Tu oponente ha perdido conexión." auto_resigning_in = "Auto-rindiéndose en" auto_aborting_in = "Auto-abortando en" not_logged_in = "No has iniciado sesión. Por favor inicia sesión para reconectarse a esta partida." game_no_longer_exists = "Esta partida ya no existe" another_window_connected = "Otra ventana se ha conectado." server_restarting = "Servidor reiniciándose inminentemente..." server_restarting_in = "Servidor reiniciándose en" minute = "minuto" minutes = "minutos" [play.javascript.websocket] no_connection = "Sin conexión." reconnected = "Reconectado." unable_to_identify_ip = "Incapaz de identificar IP." online_play_disabled = "Juego Online desactivado. Las Cookies no están soportadas. Prueba con otro buscador." too_many_requests = "Demasiadas conexiones. Inténtalo otra vez dentro de un rato." message_too_big = "Mensaje demasiado grande." too_many_sockets = "Demasiados sockets" origin_error = "Error de origen." connection_closed = "La conexión ha sido cerrada inesperadamente. Mensaje del servidor:" please_report_bug = "Esto no debería pasar, ¡Por favor reporta este bug!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Jaque mate" stalemate = "Tablas" repetition = "Triple repetición" moverule = ["Regla de los ", " movimientos"] # The game inserts a number inbetween these two strings insuffmat = "Material insuficiente" royalcapture = "Captura real" allroyalscaptured = "Todas las piezas reales capturadas" allpiecescaptured = "Todas las piezas capturadas" koth = "King of the hill" resignation = "Resignación" agreement = "Acuerdo" time = "Perdió por tiempo." aborted = "Abortado" # Game was cancelled (no elo exchanged) disconnect = "Abandonado" # A player left [play.javascript.results] you_checkmate = "¡Ganas por jaque mate!" you_time = "¡Ganas por tiempo!" you_resignation = "¡Ganas por resignación!" you_disconnect = "¡Ganas por abandono!" you_royalcapture = "¡Ganas por captura real!" you_allroyalscaptured = "¡Ganas al capturar todas las piezas reales!" you_allpiecescaptured = "¡Ganas al capturar todas las piezas!" you_koth = "¡Ganas por king of the hill!" you_generic = "¡Ganas!" draw_stalemate = "¡Tablas por ahogamiento!" draw_repetition = "¡Tablas por repetición!" draw_moverule = ["Tablas por la regla de los ", "movimientos"] # The game inserts a number inbetween these two strings draw_insuffmat = "¡Tablas por material insuficiente!" draw_agreement = "¡Tablas por acuerdo!" draw_generic = "¡Tablas!" aborted = "Partida abortada." opponent_checkmate = "¡Pierdes por jaque mate!" opponent_time = "¡Pierdes por tiempo!" opponent_resignation = "¡Pierdes por resignación!" opponent_disconnect = "¡Pierdes por abandono!" opponent_royalcapture = "¡Pierdes por captura real!" opponent_allroyalscaptured = "¡Pierdes por captura real total!" opponent_allpiecescaptured = "¡Pierdes por captura de todas las piezas!" opponent_koth = "¡Pierdes por king of the hill!" opponent_generic = "¡Pierdes!" white_checkmate = "¡Las blancas ganan por jaque mate!" black_checkmate = "¡Las negras ganan por jaque mate!" white_time = "¡Las blancas ganan por tiempo!" black_time = "¡Las negras ganan por tiempo!" white_resignation = "¡Las blancas ganan por abandono!" black_resignation = "¡Las negras ganan por abandono!" white_disconnect = "¡Las blancas ganan por desconexión!" black_disconnect = "¡Las negras ganan por desconexión!" white_royalcapture = "¡Las blancas ganan por captura real!" black_royalcapture = "¡Las negras ganan por captura real!" white_allroyalscaptured = "¡Las blancas ganan por captura real total!" black_allroyalscaptured = "¡Las negras ganan por captura real total!" white_allpiecescaptured = "¡Las blancas ganan por captura de todas las piezas!" black_allpiecescaptured = "¡Las negras ganan por captura de todas las piezas!" white_koth = "¡Las blancas ganan por king of the hill!" black_koth = "¡Las negras ganan por king of the hill!" bug_generic = "¡Esto es un bug, por favor repórtalo!" [terms] title = "Términos del Servicio" warning = ["ESTE DOCUMENTO NO ES VINCULANTE JURÍDICAMENTE. Solo somos responsables de la versión en inglés de este documento. Esta traducción se proporciona solo con un propósito informativo. Puedes acceder a la versión inglesa oficial ", "aquí", "."] consent = "Al usar esta página web, aceptas los siguientes términos. Si no aceptas, debes dejar de usar la página inmediatamente" guardian_consent = "Si eres menor de 18, debes recibir consentimiento de un pariente o tutor legal para usar esta página web y para crear una cuenta." parents_header = "Padres" parents_paragraphs = [ "Hay un algoritmo funcionando para evitar que los usuarios establezcan su nombre de usuario a palabras malsonantes comunes. Por el momento no hay ningún método de comunicación entre usuarios.", "Por el momento, los miembros no pueden establecer su imagen de perfil. Hay planes para permitir esta función. En ese momento ahremos lo posible para prevenir imágenes inapropiadas.", ] fair_play_header = "Juego limpio" fair_play_paragraph1 = ["No puedes crear mas de una cuenta. Si te gustaría cambiar la dirección de email asociada con tu cuenta, ", "contacta con nosotros."] fair_play_paragraph2 = "Para mantener el juego limpio y justo para todos, NO debes:" fair_play_rules = [ "Modificar o manipular el código de cualquier manera, incluyendo, pero no solo: Utilizar comandos de consola, controles locales, scripts personales, modificar peticiones http, mandar mensajes de websocket, etc. Esto puede hacerse intencionalmente para romper el juego, jugar movimientos ilegales, o para darte una ventaja.", "Abusar errores para abortar la partida, conseguir una ventaja, o para darte una ventaja.", "En partidas por puntos, recibir ayuda/consejos de otra persona o programa sobre lo que deberías hacer. (Crear un bot está bien y te animamos a hacerlo, pero debes limitar su uso a partidas amistosas, sin puntos)", "Intercambiar puntos elo con otras personas al perder de manera intencionada para subir su nivel, o recibiendo puntos elo de un oponente que quiere perder para subir tu nivel. Esto abusa el sistema y crea niveles de puntuación poco precisos que no representan tu nivel de habilidad." ] cleanliness_header = "Lenguaje y imágenes" cleanliness_rules = [ "En todo el lenguaje que uses en esta página, debes permanecer limpio y educado, sin vulgaridades o palabras malsonantes. No puedes molestar, insultar o amenazar a nadie, o hacer nada ilegal. No puedes hacer spam a otros usuarios o foros.", "No puedes subir imágenes a tu perfil. que sean inapropiadas, sugestivas o gore. Hacerlo resultará en un ban o terminación de tu cuenta." ] privacy_header = "Privacidad" privacy_rules = [ "Actualmente, la única información personal que guardamos es tu dirección de email. Esto tiene el propósito de verificar las cuentas de usuario, y proporcionar una manera de demostrar quiénes son cuando pidan un reinicio de contraseña. No mandamos ningún email promocional o ofertas. No compartimos las direcciones de correo de los usuarios con nadie", "InfiniteChess.org puede guardar datos sobre tu uso del sitio, incluyendo tu dirección ip. Esto tiene la intención de prevenir ataques de bots y otras entidades no deseadas, y para mantener estadísticas precisas en la base de datos. Esto NO es tu dirección real.", "Todas las partidas que juegues en esta web se convierten en información pública. Si deseas mantener tu anonimidad, no compartas tu nombre de usuario con amigos o familia. Si ese es tu deseo, es tu responsabilidad asegurarte de que nadie descubre que tu nombre de usuario esta asociado con tu identidad humana.", "El estado de actividad de tu cuenta, y el tiempo aproximado desde la última vez que estuviste activo en la web, también en información pública.", ["Aunque InfiniteChess.org siempre intentará mantener la información personal y de la cuenta segura de todo el mundo el máximo posible, en el evento de un hackeo o filtración de datos, no podrás presentar cargos. Si alguna vez ocurriese una filtración, los usuarios serán notificados en la página ", "Noticias", "."], "No hay contenido disponible para la compra en esta web. Cualquier otro tipo de información persona no se guarda.", "Para eliminar tu información personal de nuestros servidores, elimina tu cuenta desde tu página de perfil. La única cosa con relación a tu nombre de usuario que NO eliminaremos, son las partidas que juegues, porque son información pública.", ] cookie_header = "Política de Cookies" cookie_paragraphs = [ "Esta web utiliza cookies, que son pequeños ficheros de texto que se guardan en tu navegador, y mandadas a los servidores cuando se realiza una conexión. El propósito de estas cookies es: Validar tu inicio de sesión, validar que tu navegado pertenece a la partida en la que dice que está, y guardar preferencias de partida del usuario para que puedan guardarlas la próxima vez que visiten el sitio. Esta web no utiliza cookies de terceros, y las cookies no se comparten con grupos externos.", "Las cookies son necesarias para el correcto funcionamiento de esta web y del juego. Si no deseas que el sitio guarde cookies, debes parar de usarlo. Puedes navegar a las preferencias de tu navegador para eliminar cookies ya existentes. Al continuar usando este sitio, estas consintiendo al uso de cookies." ] conclusion_header = "Conclusión" conclusion_paragraphs = [ "Cualquier violación de estos términos puede resultar en un ban o terminación de tu cuenta. ¡InfiniteChess.org quiere ser capaz de dar a todo el mundo la oportunidad de jugar y divertirse! Pero reservamos el derecho a, en cualquier momento, banear o terminar las cuentas de cualquier usuario, por razones que no necesitan ser hechas públicas. No podrás presentar cargos.", ["Estos términos de servicio pueden ser modificados en cualquier momento. ¡Es TU responsabilidad asegurarte de que te mantienes actualizado con los últimos cambios! Cuándo estos términos del servicio reciban una actualización, esa información sera publicada en la página ", "Noticias", ". Si en el momento de una actualización de los términos de servicio, no estás de acuerdo con los nuevos términos, debes dejar de usar la web inmediatamente. Puedes eliminar tu cuenta en la página de perfil. Si eliminas tu cuenta, toda tu información privada y datos de la cuenta serán eliminados, EXCEPTO el registro de partidas jugadas con tu cuenta, que es información pública."], ["Esta web es de código abierto. Puedes copiar o distribuir cualquier cosa en esta web, ¡Siempre y cuando sigas las condiciones especificadas en","los términos de la licencia","! Si este link no funciona, es tu responsabilidad encontrar los términos."], "No podemos garantizar que la página vaya a estar funcionando el 100% del tiempo. Tampoco podemos garantizar que los datos nunca estarán corruptos.", "No puedes realizar ninguna actividad ilegal en esta página web.", ["Si tienes alguna pregunta sobre estos términos, o cualquier otra pregunta sobre la página,", "¡Mándanos un correo!"] ] thanks = "¡Gracias!" [login] title = "Inicio de Sesión" # The tab name username = "Nombre de usuario:" password = "Contraseña:" login_button = "Iniciar sesión" send_reset_link = "Enviar link de reinicio" forgot_question = "Olvidaste tu contraseña?" back_to_login = "Volver a la pagina de inicio de sesión" forgot_instruction = "Por favor introduzca el correo electronico asociado a su cuenta." [login.javascript] network-error = "Un error de conexión ha ocurrido. Por favor intentalo otra vez." [reset_password] title = "Reinicio de contraseña" instruction = "Por favor introduze y confirma tu contraseña" new_password = "Nueva contraseña" confirm_password = "Confirma tu contraseña" submit_button = "Reiniciar contraseña" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Parámetros no válidos recibidos" 409_message = ["Puede que haya un nombre de usuario o correo conflictivo. Por favor ", "recarga", " la página."] 500_message = "Esto no debería pasar ¡Hay algo de debugging por hacer!" [news] title = "Noticias" # The tab name more_dev_logs = ["¡Mas devlogs se publican en ", "el discord oficial", " y en los ", "foros de chess.com!"] [server.javascript] ws-invalid_username = "El nombre de usuario no es válido" ws-incorrect_password = "La contraseña es incorrecta" ws-login_failure_retry_in = "Inicio de sesión fallido, inténtalo de nuevo en" ws-seconds = "segundos" # unit of time ws-second = "segundo" # unit of time ws-username_length = "El nombre de usuario debe tener entre 3 y 20 caracteres" ws-username_letters = "El nombre de usuario debe contener solo las letras A-Z y los números 0-9" ws-username_taken = "Ese nombre de usuario ya existe" ws-username_bad_word = "Ese nombre de usuario contiene una palabra que no está permitida" ws-username_reserved = "Ese nombre de usuario está reservado" ws-email_too_long = "Tu email es demasiaaaaado largo" ws-email_invalid = "Eso no es un email válido" ws-email_in_use = "Ese email ya ha sido registrado" ws-email_domain_invalid = "Dominio no válido" ws-email_blacklisted = "Tu email está en la lista negra." ws-password_length = "La contraseña debe tener entre 6 y 72 caracteres" ws-password_password = "La contraseña no debe ser 'password'" ws-password-reset-link-sent = "Si una contraseña con ese email existe, un link de reinicio de contraseña ha sido enviado." ws-password-change-success = "La contraseña ha sido reiniciada correctamente. Serás redirigido a la página de inicio de sesión en breve." ws-password-reset-token-invalid = "El token de reinicio de contraseña no es válido o ha expirado." ws-forbidden_wrong_account = "Prohibido. Esta no es tu cuenta" ws-deleting_account_not_found = "Error al eliminar la cuenta. No se ha encontrado la cuenta." ws-deleting_account_in_game = "No puedes eliminar tu cuenta mientras estás conectado a un a partida." ws-server_error = "Lo sentimos, ¡Ha habido un error de servidores! Por favor vuelve atrás." ws-not_found = "404 Página no encontrada" ws-forbidden = "Prohibido." ws-already_in_game = "Ya estas en una partida." ws-server_restarting = "El servidor se reiniciará en" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "El servidor esta bajo mantenimiento. ¡Vuelve en un rato!" # Can be changed at will to change the display message. ws-minutes = "minutos" # unit of time ws-minute = "minuto" # unit of time ws-you_cheated = "¡Ups! Has jugado algo ilegal. La partida ha sido abortada. ¡Si esto ha sido un error, por favor reportalo!" ws-opponent_cheated = "Tu oponente ha hecho algo ilegal. La partida ha sido abortada." ws-cannot_resign_finished_game = "No se puede conceder la partida, ya ha acabado." ws-invalid_code = "¡Código invalido!" # Invite code doesn't match any existing invites ws-game_aborted = "Partida abortada." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "Para jugar partidas por puntos, necesitas iniciar sesión con una cuenta verificada." [rate-limiting] generic = "Has hecho muchas peticiones, por favor intentalo mas tarde." ================================================ FILE: translation/fi-FI.toml ================================================ name = "Suomi" # Name of language english_name = "Finnish" direction = "ltr" # Change to "rtl" for right to left languages version = "90" maintainer = "ThisIsNotAvailable" [header] home = "Koti" play = "Pelaa" news = "Uutiset" login = "Kirjaudu" profile = "Profiili" createaccount = "Luo tili" logout = "Kirjaudu ulos" leaderboard = "Tulostaulukko" [header.settings] language = "Kieli" appearance = "Ulkonäkö" # Board color/theme and visual effects appearance-theme = "Teema" appearance-starfield = "Tähtikenttä" # The Starfield space animation underneath void appearance-advanced-effects = "Edistyneet tehosteet" # Post processing and board tile effects at extreme distances legalmoves = "Sallitut siirrot" # Legal moves shape legalmoves-squares = "Neliöt" legalmoves-dots = "Pisteet" # Dots and 4 corner triangles selection = "Valitseminen" selection-drag = "Vetäminen" selection-premove = "Aikaissiirrot" selection-animations = "Animaatiot" selection-lingering_annotations = "Pysyvät merkinnät" perspective = "Perspektiivi" # Perspective-mode perspective-mouse-sensitivity = "Hiiren herkkyys" perspective-fov = "Näkökenttä" sound = "Ääni" sound-master-volume = "Äänenvoimakkuus" sound-ambience = "Tunnelmaääni" ping = ["Ping", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Palauta oletusasetuksiin" [footer] contact = "Ota yhteyttä" terms_of_service = "Käyttöehdot" source_code = "Lähdekoodi" language = "Kieli" [member.javascript] js-confirm_delete = "Oletko varma, että haluat poistaa tilisi? Tätä EI VOI peruuttaa! Paina OK syöttääksesi salasanasi." js-enter_password = "Syötä salasanasi poistaaksesi PYSYVÄSTI sinun tilisi:" [leaderboard.javascript] supported_variants = "Tämä tulostaulukko on käytössä seuraaville varianteille:" rank = "Sijoitus" player = "Pelaaja" rating = "Luokitus" [index] title = "Infinite Chess | Koti - Virallinen Nettisivu" # The tab title secondary_title = "Virallinen nettisivu livenä pelaamiseen!" what_is_it_title = "Mitä se on?" what_is_it_pargaraphs = [ "Infinite Chess on shakkivariantti, jossa ei ole reunoja, mikä tekee siitä paljon suuremman kuin tuttu 8x8 shakkilauta. Kuningattarilla, torneilla, ja lähettiläillä ei ole rajoja niiden liikkumiseen yhden vuoron aikana. Valitse mikä tahansa kokonaisluku äärettömään asti!", "Ilman rajoituksia sille, kuinka pitkälle voit liikkua, on olemassa asemia, joissa tuomiopäivän kello, tai shakkimatti-jossain-siirrossa, numero on edustettuna ensimmäisellä äärettömällä järjestysluvulla, omegalla ω. Tutkijat ovat todistaneet, että mikä tahansa laskettava järjestysluku on saavutettavissa shakkimattikellossa!", "Kuten voit kuvitella, on olemassa äärettömästi mahdollisuuksia aloituskokoonpanoille, joista monta voi pelata kilpailullisesti! Pyrit edelleen shakkimattiin, mikä tarvitsee uusia taktiikoita, koska ei ole olemassa reunoja, joihin vastustajan kuninkaan voisi vangita. Pelit eivät yleensä kestä paljon pidempään kuin tavalliset shakkipelit. Sotilaat edelleen korontuvat riveillä 1 ja 8!", ] how_to_title = "Miten voin pelata?" how_to_paragraph = ["Tämän hetkinen versio on 1.10 ","Pelaa"," sivulla!"] about_title = "Tietoa projektista" about_paragraphs = [ "Minä olen Naviary. Siitä lähtien, kun löysin äärettömän shakin (konsepti oli olemassa kauan ennen tätä nettisivua), minua on kiehtonut sen mahdollisuudet! Viime aikoihin asti pelaaminen on ollut todella vaikeaa, vaatien chess.com jäsenien luomaan kuvia sen hetkisestä shakkilaudasta ja lähettämään ne toiselle pelaajalle jokaisella siirrolla. Sen takia harva tietää tästä tai on pelannut tätä.", ["Minun tavoitteeni on luoda tapa, joka tekee tästä helposti saavutettavan kaikille ja kasvattaa yleisöä sen ympärille. Olen käyttänyt lukemattomia tunteja omasta ajastani tämän nettisivun ylläpitämiseen ja sen kehittämiseen. Minulla on monen monta ideaa, jotka ovat pitäneet minut kiireellisinä. Vaikka haluankin pitää tämän ilmaisena, elämällä on vaatimuksensa. Tukeaksesi minua taloudellisesti harkitse liityväsi minun ", "Patreoniin"] # Patreon receives a hyperlink, here ] patreon_title = "Patreon tukijat" github_title = "Github avustajat" [index.javascript] contribution_count_singular = ["", " avustus"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " avustusta"] [credits] title = "Krediitit" copyright = "Kaikki tällä nettisivulla, jota ei alla mainita on www.InfiniteChess.org omaisuutta" variants_heading = "Variantit" variants_credits = [ "Ytimen suunnitteli Andreas Tsevas.", "Avaruuden suunnitteli Andreas Tsevas.", "Avaruus klassisen suunnitteli Andreas Tsevas.", "Coaip (Shakkia äärettömällä alustalla) suunnitteli Vickalan.", "Sotilaslauman suunnitteli Inaccessible Cardinal.", "Runsauden suunnitteli Clicktuck Suskriberz.", "Panttimiehen suunnitteli SexyLexi.", "Klassinen+ suunnitteli SexyLexi.", "Heppalinjan suunnitteli Inaccessible Cardinal.", "Hepoitetun shakin suunnitteli cycy98.", "suunnitteli Cory Evans ja Joel Hamkins.", "suunnitteli Andreas Tsevas.", "suunnitteli Cory Evans ja Joel Hamkins.", "suunnitteli Cory Evans, Joel Hamkins, ja Norman Lewis Perlmutter.", "Shakkia äärettömällä alustalla - Huygen variantit suunnitteli V. Reinhart.", "Rajoitettu klassinen suunnitteli Andreas Tsevas.", "4x4x4x4 Shakki suunnitteli Andreas Tsevas.", "5D Shakki suunnitteli Jace.", ] textures_heading = "Tekstuurit" textures_licensed_under = "tekstuurit on lisentoitu" sounds_heading = "Äänet" sounds_credits = [ ["Jotkin äänet tarjoaa ", "projekti lisenssillä"], "Muut äänet on luonut Naviary.", ] code_heading = "Koodi" code_credits = [ "ohjelmoi Brandon Jones ja Colin MacKenzie IV.", "ohjelmoi Andreas Tsevas ja Naviary.", ] language_heading = "Kääntäjät" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Ranskan käänsi ", "Life Enjoyer", " ja ", "cycy98", ".", "Yksinkertaistetun kiinan käänsi ", "Heinrich Xiao", ".", "Perinteisen kiinan käänsi ", "Heinrich Xiao", ".", "Puolan käänsi ", "Tymon Becella", ".", # Apsurt "Portugalin käänsi ", "The_Skeleton", ".", # The_Skeleton on discord "Espanja käänsi ", "xa31er", ".", "Saksan käänsi ", "Estetique", "." ] [member] title = "Jäsen" # The tab name verify_message = "Tarkista sähköpostisi vahvistaaksesi tilisi. Vahvistamattomat tilit poistetaan 3 päivän jälkeen." resend_message = ["Etkö saanut sähköpostia? Tarkista roskapostisi. Jos et löydä sitä sieltä, ", "lähetä se uudelleen.", " Jos et vieläkään löydä sitä, ", "ota meihin yhteyttä."] verify_confirm = "Kiitos! Tilisi on vahvistettu." joined = "Liityi:" seen = "Nähty:" # Last seen: ____ practice_progress = "Harjoitustilan edistys:" ranked_elo = "Luokitus:" infinity_leaderboard_position = "Sijoitus:" infinity_leaderboard_rating_deviation = "Epävarmuus:" reveal_info = "Näytä Tilitiedot" account_info_heading = "Tilitiedot" email = "Sähköpostiosoite:" delete_account = "Poista tili" [member.badge-tooltips] checkmate_bronze = "Shakkimatti veteraani: Läpäise 50% kaikista harjoitusshakkimateista." checkmate_silver = "Shakkimatti ammattilainen: Läpäise 75% kaikista harjoitusshakkimateista." checkmate_gold = "Shakkimatti mestari: Läpäise 100% kaikista harjoitusshakkimateista." [create-account] title = "Luo tili" # The tab name username = "Käyttäjänimi:" email = "Sähköposti:" password = "Salasana:" create_button = "Luo tili" agreement = ["Minä suostun ", "Käyttöehtoihin", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_reserved = "Tämä käyttäjänimi on varattu" js-username_length = "KKäyttäjänimen täytyy olla 3-20 kirjainta pitkä" js-username_tooshort = "Käyttäjänimen täytyy olla vähintään 3 kirjainta pitkä" js-username_wrongenc = "Käyttäjänimen täytyy sisältää vain kirjaimia A-Z ja numeroita 0-9" js-email_invalid = "Tämä ei ole oikea sähköpostiosoite" js-email_too_long = "Tämä sähköpostiosoite on liian pitkä" js-email_inuse = "Sähköpostiosoite on jo käytössä" [reset-password.javascript] js-pwd_no_match = "Salasanat eivät täsmää." reset-password = "Palauta salasana" processing = "Käsitellään..." network-error = "Verkkovirhe tapahtui. Yritä uudelleen." [password-validation] js-pwd_too_short = "Salasana ei saa olla alle 6 kirjainta pitkä" js-pwd_too_long = "Salasana ei saa olla yli 72 kirjainta pitkä" js-pwd_not_pwd = "Salasana ei saa olla 'password'" [leaderboard] title = "Tulostaulukko" inactive_players = ["Passiiviset pelaajat, joilla on epävarmuus korkeampi kuin ", ", suljetaan pois tulostaulukosta."] # A number is inserted between these two quotes your_global_ranking = "Sinun sijoituksesi:" show_more = "Näytä lisää..." [play] title = "Infinite Chess - Pelaa" # The tab title loading = "LATAUTUU" error = "VIRHE" [play.main-menu] credits = "Krediitit" play = "Pelaa" practice = "Harjoittele" guide = "Opas" editor = "Laudan muokkaaja" [play.editor] # Sidebar section headers position = "Asema" tools = "Työkalut" selection = "Valitseminen" palette = "Paletti" color = "Väri" # Sidebar button tooltips tooltip_reset = "Palauta asema" tooltip_clear = "Tyhjennä asema" tooltip_load = "Avaa asema" tooltip_save_as = "Tallenna asema nimellä" tooltip_save = "Tallenna asema" tooltip_copy_notation = "Kopioi merkintä" tooltip_paste_notation = "Liitä merkintä" tooltip_gamerules = "Pelisäännöt" tooltip_start_local = "Aloita paikallinen peli asemasta" tooltip_start_engine = "Aloita tietokonepeli asemasta" # Tool tooltips tooltip_normal = "Tavallinen (F)" tooltip_eraser = "Pyyhekumi (G)" tooltip_selection_tool = "Valitseminen (H)" tooltip_specialrights = "Erityisoikeuksien vaihto (J)" # Selection tooltips tooltip_select_all = "Valitse kaikki (Ctrl+A)" tooltip_clear_selection = "Tyhjennä valinta (Del)" tooltip_copy_selection = "Kopioi valinta (Ctrl+C)" tooltip_paste_selection = "Liitä valinta (Ctrl+V)" tooltip_invert_color = "Vaihda valinnan väri" tooltip_rotate_left = "Käännä valinta vasemmalle" tooltip_rotate_right = "Käännä valinta oikealla" tooltip_flip_horizontal = "Käännä valinta vaakasuorassa" tooltip_flip_vertical = "Käännä valinta pystysuunnassa" # Reset Position window reset_header = "Palauta asema" reset_message = "Haluatko palauttaa aseman ja luoda uuden? Tallentamattomat muutokset menetetään." # Clear Position window clear_header = "Tyhjennä asema" clear_message = "Haluatko tyhjentää aseman ja luoda uuden? Tallentamattomat muutokset menetetään." # Load Position window enter_position_name = "Syötä aseman nimi:" save_button = "Tallenna" name_header = "Nimi" pieces_header = "Nappulat" # Represent piece count date_header = "Päiväys" # Represents date last modified # Game Rules window gamerules_header = "Pelisäännöt" player_to_move = "Aloittava pelaaja:" white = "Valkoinen" black = "Musta" en_passant = "Ohestalyönti neliö:" move_rule = "Siirtosäännön tila:" promotion_ranks_white = "Ylennysrivit (Valkoinen):" promotion_ranks_black = "Ylennysrivit (Musta):" promotion_pieces = "Ylennettävät nappulat:" global_special_rights = "Yleiset erityisoikeudet:" pawn_double_push = "Sotilaan kaksoissiirto" castling_label = "Linnoittuminen" win_conditions = "Voittoehdot:" checkmate = "Shakkimatti" royal_capture = "Kuninkaallisen kaappaus" all_royals_captured = "Kaikkien kuninkaalisten syönti" all_pieces_captured = "Kaikkien nappuloiden syönti" world_border = "Laudan reuna:" # Start Local Game window start_local_game = "Aloita paikallinen peli" start_local_game_message = "Haluatko poistua laudan muokkaajasta ja aloittaa paikallisen pelin tästä asemasta? Muutokset tallentuvat." # Start Engine Game window start_engine_game = "Aloita tietokonepeli" play_as = "Pelaa:" time_control = "Aika (tyhjä = ääretön aika):" engine_difficulty = "Tietokoneen taso:" easy = "helppo" medium = "Keskivaikea" hard = "Vaikea" use_default_border = "Käytä tavallista tietokoneen kentän kokoa:" start_engine_game_message = "Haluatko poistua laudan muokkaajasta ja aloittaa tietokonepelin tästä asemasta? Muutokset tallentuvat." # Common yes = "Kyllä" no = "Ei" [play.guide] title = "Opas" rules = "Säännöt" rules_paragraphs = [ "Äärettömän shakin säännöt ovat melkein identtiset tavallisen shakin kanssa, paitsi että shakkilauta on ääretön kaikissa suunnissa! Nämä ovat ainoat huomiot ja muutokset, joista sinun pitää tietää:", "Nappuloilta, jotka liikkuvat suorassa linjassa, kuten tornit, lähetit, ja kuningatar, on poistettu niiden tavalliset etäisyysrajoitukset! Niin kauan, jun niiden reitty on esteetön, sinä voit liikkua miljoonia ruutuja!", ["\"Klassisessa\" variantissa, valkoiset sotilaat ylentyvät rivillä 8, ja mustat sotilaat rivillä 1. Tässä kuvassa, tämä hainnollistetaan ohuilla mustilla viivoilla. Ne ovat todella ohuesti piirretty, näetkö ne? Sotilaiden täytyy päästä vastakkaiselle viivalle ylentyäkseen, ", "ei", " sen yli."], "Ruutujen merkintätapa ei enää perustu kirjaimeen ja rivinumeroon (esim. a1), vaan jokainen ruutu määritellään x- ja y-koordinaattiparilla. a1-ruutu on nyt (1,1) ja h8-ruutu on nyt (8,8). Tietokoneella koordinaatti, jonka päällä hiiri on, näytetään näytön yläreunassa.", "Kaikki muut säännöt ovat samat kuin tavallisessa shakissa, kuten shakkimatti, tasapeli, kolminkertainen toisto, 50-siirron sääntö, linnoittuminen, en passant, jne.!" ] careful_heading = "Ole varovainen!" careful_paragraphs = [ "Äärettömän laudan avonaisuus tarkoitta sitä, että on todella helppoa hyväksikäytää haarukoita ja vartaita, koska sinun taaimmainen puoli on usein todella haavoittuvainen. Yritä huomata tälläiset taktiikat! Ole luova suojelun luomisessa sinun kuninkaalle ja torneille! Avausstrategia on todella erilainen kuin tavallisessa shakissa.", "Monet muut variantit on luotu sinun taaimmaisen puolen vahvistamiseen." ] controls_heading = "Ohjaimet" controls_paragraph = "Paina ja siirrä pelilautaa liikkuaksesi ympäriinsä. Scrollaa zoomataksesi sisään ja ulos. Paina mitä tahansa nappulaa, mukaan lukien vastustajan nappulat, nähdäksesi kaikki niiden mahdolliset siirrot! Ylimääräiset ohjaimet ovat:" keybinds = [ " liikkuaksesi ympäriinsä.", ["Välilyönti", " ja ", "Vaihto", " zoomataksesi sisään ja ulos."], ["Escape", " pysäyttääksesi pelin."], ["Tabulaattori", " vaihtaa näytön reunoilla olevien nuolien tilan, jotka osoittavat ruudun ulkopuolella oleviin nappuloihin. Oletuksena tämä tila on \"Puolustus\", jolloin nuolet osoittavat kaikkiin nappuloihin, jotka voivat siirtyä sinun näytöllesi niiden liikesuunnassa. Mutta ", "Tabulaattori", " voi vaihtaa tilan \"Kaikkiin\" tai \"Pois\"; \"Kaikki\" näyttää nuolet kaikille nappuloille, riippumatta siitä, voivatko ne siirtyä sinun näytöllesi. Tätä asetusta voi myös vaihtaa taukovalikossa. Nuolta klikkaamalla siirryt suoraan siihen nappulaan, johon nuoli osoittaa."], ["Control", " pakottaa laudan siirtämisen nappulan siirtämisen sijaan, jos vetäminen on käytössä asetuksissa."], " vaihtaa \"Muokkaustilan\" paikallisissa peleissä. Tämän avulla voit siirtää mitä tahansa nappulaa mihin tahansa laudalla! Hyödyllinen analysointiin." ] controls_paragraph2 = "Nämä ovat tärkeimmät ohjaimet, jotka sinun tulee tietää. Tässä vielä muutama lisävinkki, jos joskus tarvitset niitä!" keybinds_extra = [ " palauttaa nappuloiden renderöinnin. Tämä on hyödyllistä, jos ne muuttuvat näkymättömiksi. Tämä virhe voi tapahtua, jos liikutat äärimmäisiä etäisyyksiä (esim. 1e21).", " vaihtaa navigointi- ja pelitietopalkkien renderöinnin, mikä voi olla hyödyllistä tallennuksessa. Striimaaminen ja videoiden tekeminen pelistä on tervetullutta!", " vaihtaa FPS-mittarin. Tämä näyttää kuinka monta kertaa peli päivittyy sekunnissa, ei aina renderöityjen kuvien määrää, sillä peli ohittaa renderöinnin kun mikään näkyvä ei muutu, säästääkseen laskentatehoa.", " vaihtaa kuvake-renderöinnin. Nämä ovat klikattavia minikuvia nappuloista, kun zoomaat tarpeeksi kauas. Tuoduissa peleissä, joissa on yli 50 000 nappulaa, tämä kytketään automaattisesti pois päältä suorituskyvyn vuoksi, mutta sen voi kytkeä takaisin päälle ", [" (käänteinen heittomerkki, eli sama näppäin kuin ", ") vaihtaa Debug-tilan."], ] fairy_heading = "Erikoisnappulat" fairy_paragraph = "Osaat jo pelata oletusvarianttia \"Klassinen\". Erikoisnappuloita ei käytetä tavallisessa shakissa, mutta ne ovat mukana muissa varianteissa! Jos kohtaat variantin, jossa on uusia nappuloita, opi miten ne toimivat täällä!" back = "Takaisin" [play.guide.pieces] chancellor = {name="Kansleri", description="Liikkuu kuin torni ja ratsu yhdistettynä."} archbishop = {name="Arkkipiispa", description="Liikkuu kuin lähetti ja ratsu yhdistettynä."} amazon = {name="Amazon", description="Liikkuu kuin kuningatar ja ratsu yhdistettynä. Tämä on pelin vahvin nappula!"} guard = {name="Vartija", description="Liikkuu kuin kuningas, mutta ei voi joutua shakkiin tai shakkimattiin."} hawk = {name="Haukka", description="Hyppää tarkalleen 2 tai 3 ruutua mihin tahansa suuntaan."} centaur = {name="Centauri", description="Liikkuu kuin ratsu ja vartija yhdistettynä."} knightrider = {name="Ratsuratsastaja", description="Hyppää kuin ratsu äärettömästi yhteen suuntaan, kunnes este tulee vastaan."} huygen = {name="Huygen", description="Hyppää äärettömästi johonkin neljästä pääsuunnasta, käyden vain ruuduilla, joiden etäisyys aloitusruudusta on alkuluku, kunnes este tulee vastaan."} rose = {name="Ruusu", description="Ympyrämäinen ratsuratsastaja. Liikkuu myötä- ja vastapäivään ympyränmuotoisia reittejä hyppäämällä kuin ratsu ja kääntymällä 45 astetta jokaisen hypyn jälkeen. Sen voi estää muut nappulat, minkä vuoksi punainen ruutu kuvassa on ruusulle saavuttamaton."} obstacle = {name="Este", description="Neutraali nappula (ei kummankaan pelaajan ohjaama), joka estää liikkumisen, mutta voidaan kaapata."} void = {name="Tyhjyys", description="Neutraali nappula (ei kummankaan pelaajan ohjaama), joka edustaa laudan puuttuvaa osaa. Nappulat eivät voi liikkua sen läpi tai sen päälle."} [play.practice-menu] title = "Harjoittele - Shakkimatti" play = "Pelaa" back = "Takaisin" difficulty = "Vaikeus" [play.play-menu] title = "Pelaa - Verkossa" colors = "Värit" online = "Verkossa" local = "Paikallinen" computer = "Tietokone" variant = "Variantti" Classical = "Klassinen" Confined_Classical = "Rajoitettu klassinen" Classical_Plus = "Klassinen+" CoaIP = "Shakkia äärettömällä alustalla" Pawndard = "Pawndard" Knighted_Chess = "Hepoittetu shakki" Palace = "Palatsi" Knightline = "Heppalinja" Core = "Ydin" Standarch = "Standarch" Pawn_Horde = "Sotilaslauma" Space_Classic = "Klassinen avaruus" Space = "Avaruus" Obstocean = "Estemeri" Abundance = "Runsaus" Amazon_Chandelier = "Amazon chandelier" Containment = "Hillitseminen" Classical_Limit_7 = "Klassinen - 7 rajoitus" CoaIP_Limit_7 = "Coaip - 7 rajoitus" Chess = "Shakki" Classical_KOTH = "Kokeellinen: Klassinen - KOTH" CoaIP_KOTH = "Kokeellinen: Coaip - KOTH" CoaIP_HO = "Shakkia äärettömällä alustalla - Huygen variantti" CoaIP_RO = "Shakkia äärettömällä alustalla - Ruusu variantti" CoaIP_NO = "Shakkia äärettömällä alustalla - Ratsuratsastaja variantti" Omega = "Näyte: Omega" Omega_Squared = "Näyte: Omega^2" Omega_Cubed = "Näyte: Omega^3" Omega_Fourth = "Näyte: Omega^4" 4x4x4x4_Chess = "4×4×4×4 Shakki" 5D_Chess = "5D Shakki" no_clock = "Ei kelloa" clock = "Kello" minutes = "m" seconds = "s" infinite_time = "Ääretön aika" color = "Väri" piece_colors = ["Satunnainen", "Valkoinen", "Musta"] private = "Yksityinen" no = "Ei" yes = "Kyllä" rated = "Arvioitu" casual = "Rento" easy = "Helppo" medium = "Keskivaikea" hard = "Vaikea" join_games = "Liity valmiiseen - Aktiiviset pelit:" private_invite = "Yksityinen kutsu:" your_invite = "Sinun kutsukoodisi:" create_invite = "Luo kutsu" join = "Liity" copy = "Kopioi" back = "Takaisin" code = "Koodi" [play.gamebuttontooltips] undo_transition = "Kumoa siirtymä" expand_fit_all = "Laajenna nähdäksesi kaiken" recenter = "Keskitä" annotations = "Piirrä merkintöjä" erase = "Poista merkintöjä" collapse = "Supista merkinnät" rewind_move = "Kelaa taaksepäin siirto" forward_move = "Eteenpäin siirto" undo_edit = "Kumoa muokkaus (Ctrl+Z)" # Board editor redo_edit = "Tee muokkaus uudelleen (Ctrl+Y)" # Board editor pause = "Pysäytä" undo = "Kumoa siirto" # Checkmate practice game restart = "Käynnistä peli uudelleen" # Checkmate practice game [play.pause] title = "Pysäytetty" resume = "Palaa" arrows = "Nuolet: Puolustus" perspective = "Perspektiivi: Pois" copy = "Kopioi peli" paste = "Liitä peli" offer_draw = "Ehdota tasapeliä" practice_menu = "Harjoitteluvalikko" main_menu = "Päävalikkoon" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Hyväksystö tasapelin?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Vieras)" you_indicator = "(Sinä)" engine_indicator = "Tietokone" player_name_white_generic = "Valkoinen" player_name_black_generic = "Musta" white_to_move = "Valkoisen vuoro" black_to_move = "Mustan vuoro" your_move = "Sinun siirto" their_move = "Hänen siirto" lost_network = "Yhteys menetetty." failed_to_load = "Yksi tai enemmän resurssia ei latautunut. Lataa sivu uudelleen." planned_feature = "Tämä on tulossa!" main_menu = "Päävalikko" resign_game = "Luovuta" abort_game = "Keskeytä peli" offer_draw = "Ehdota tasapeliä" # Offer draw button text in the pause menu accept_draw = "Hyväksy tasapeli" # Offer draw button text in the pause menu arrows_off = "Nuolet: Pois" arrows_defense = "Nuolet: Puolustus" arrows_all = "Nuolet: Kaikki" arrows_all_hippogonals = "Nuolet: Kaikki (hippogonaalit)" toggled = "Vaihdettu" menu_online = "Pelaa - Verkossa" menu_local = "Pelaa - Paikallinen" menu_computer = "Pelaa - Tietokone" invite_error_digits = "Kutsukoodin täytyy olla 5 pitkä." invite_copied = "Kutsukoodi kopioitui." move_counter = "Siirto:" constructing_mesh = "Rakennetaan meshiä" rotating_mesh = "Käännetään meshiä" lost_connection = "Yhteys menetetty." please_wait = "Odota hetki suorittaaksesi tämän." webgl_unsupported = "Sinun nettiselain ei tue WebGL2:ta. Päivitä nettiselaimesi!" bigints_unsupported = "BigInts ei ole tuettu. Päivitä nettiselaimesi.\nBigIntsiä tarvitaan, jotta lauta olisi ääretön." # Checkmate Practice versus = "vs" easy = "Helppo" medium = "Keskitaso" hard = "Vaikea" insane = "Hullu" checkmate_logged_out = "Sinun tulee olla kirjautunut ansaitaksesi merkkejä." checkmate_bronze = "Shakkimatti veteraani: Läpäise 50% kaikista harjoitusshakkimateista." checkmate_silver = "Shakkimatti ammattilainen: Läpäise 75% kaikista harjoitusshakkimateista." checkmate_gold = "Shakkimatti mestari: Läpäise 100% kaikista harjoitusshakkimateista." checkmate_bronze_unearned = "Läpäise 50% kaikista harjoitusshakkimateista ansaitaksesi tämän." checkmate_silver_unearned = "Läpäise 75% kaikista harjoitusshakkimateista ansaitaksesi tämän." checkmate_gold_unearned = "Läpäise 100% kaikista harjoitusshakkimateista ansaitaksesi tämän." coords-invalid = "Virheellinen koordinaattimuoto. Anna vain kokonaislukuja tai e-merkintä (esim. 1.23e4)." coords-exceeded = "Et voi siirtä niin kauas! Se olisi liian helppoa ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Tyhjyys" obstacle = "Este" king = "Kuningas" giraffe = "Kirahvi" camel = "Kameli" zebra = "Seepra" knightrider = "Ratsuratsastaja" amazon = "Amazon" queen = "Kuningatar" royalQueen = "Kuninkaallinen kuningatar" hawk = "Haukka" chancellor = "Kansleri" archbishop = "Arkkipiispa" centaur = "Centauri" royalCentaur = "Kuninkaallinen centauri" rose = "Ruusu" knight = "Ratsu" guard = "Vartija" huygen = "Huygen" rook = "Torni" bishop = "Lähetti" pawn = "Sotilas" [play.javascript.copypaste] copied_game = "Peli kopioitu leikepöydälle!" cannot_paste_in_public = "Peliä ei voi liittää julkisessa pelissä!" cannot_paste_in_rated = "Peliä ei voi liittää arvioidussa pelissä!" cannot_paste_in_engine = "Peliä ei voi liittää tietokonepelissä!" cannot_paste_after_moves = "Peliä ei voi liittää siirron jälkeen!" clipboard_denied = "Leikepöytä-oikeus evätty. Tämä saattaa olla nettiselaimesi syytä." clipboard_invalid = "Leikepöytä ei ole oikeassa ICN merkinnässä." game_needs_to_specify = "Pelin täytyy määritellä joko 'Variant' metadata, tai 'position' ominaisuus." invalid_wincon = "Pelaajalla on virheellinen voittoehto" pasting_game = "Liitetään peliä..." pasting_in_private = "Pelin liittäminen yksityiseen otteluun aiheuttaa synkronoinnin katkeamisen, jos vastustajasi ei tee samoin!" piece_count = "Nappuloiden määrä" exceeded = "ylitettiin" changed_wincon = "Shakkimattivoiton ehdot muutettiin kuninkaalliseksi vangitsemiseksi ja kuvakkeiden renderöinti otettiin pois päältä. Paina 'P' ottaaksesi sen uudelleen käyttöön (ei suositella)." loaded_from_clipboard = "Peli ladattu leikepöydältä!" copied_position = "Asema kopioitiin leikepöydälle!" loaded_position_from_clipboard = "Asema ladattiin leikepöydältä!" reset_position = "Asema asetettiin oletukseen!" clear_position = "Asema tyhjennettiin!" [play.javascript.rendering] on = "Päällä" off = "Pois" icon_rendering_off = "Vaihdettu pois kuvakerenderöinti." icon_rendering_on = "Vaihdettu päälle kuvakerenderöinti." perspective = "Perspektiivi" perspective_mode_on_desktop = "Perspektiivi on saatavilla tietokoneella!" movement_tutorial = "WASD liikkuaksesi. Välilyönti ja vaihto zoomataksesi." regenerated_pieces = "Uudelleenluotiin nappulat." [play.javascript.invites] move_mouse = "Liikuta hiirtäsi yhdistääksesi uudelleen." cannot_cancel = "Kutsua, jonka tunnus on määrittelemätön, ei voi peruuttaa." you_are_white = "Olet: Valkoinen" you_are_black = "Olet: Musta" random = "Satunnainen" accept = "Hyväksy" cancel = "Peruuta" create_invite = "Luo kutsu" cancel_invite = "Peruuta kutsu" start_game = "Aloita peli" join_existing_active_games = "Liity valmiiseen peliin - Aktiiviset pelit:" [play.javascript.onlinegame] afk_warning = "Olet AFK." opponent_afk = "Vastustaja on AFK." opponent_disconnected = "Vastustaja katkaisi yhteyden." opponent_lost_connection = "Vastustaja menetti yhteyden." auto_resigning_in = "Automaattisesti luovutetaan" auto_aborting_in = "Automaattisesti keskeytetään" not_logged_in = "Et ole kirjautunut sisään. Kirjaudu sisään yhdistääksesi uudelleen tähän peliin." game_no_longer_exists = "Peli ei ole enään olemassa." another_window_connected = "Toinen ikkuna yhdisti." server_restarting = "Palvelin uudelleenkäynnistyy kohta..." server_restarting_in = "Palvelin uudelleenkäynnistyy " minute = "minuutissa" minutes = "minuutissa" [play.javascript.websocket] no_connection = "Ei yhteyttä." reconnected = "Yhdistettiin uudelleen." unable_to_identify_ip = "IP-osoitetta ei tunnistettu." online_play_disabled = "Verkossa pelaaminen on pysäytetty. Evästeitä ei tueta. Yritä toisella selaimella." too_many_requests = "Liian monta pyyntöä. Yritä uudelleen kohta." message_too_big = "Viesti liian suuri." too_many_sockets = "Liian monta socketia" origin_error = "Alkuperä virhe." connection_closed = "Yhteys katkaistiin yllättäen. Palvelimen viesti:" please_report_bug = "Tämän ei pitäisi tapahtua, ilmoita tästä bugista!" malformed_message = "Vastaanotettu odottamaton websocket-viesti. Ilmoita tästä bugista!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Shakkimatti" stalemate = "Patti" repetition = "Kolminkertainen toisto" moverule = ["", "-siirron sääntö"] # The game inserts a number inbetween these two strings insuffmat = "Ei tarpeeksi materiaalia" royalcapture = "Kuninkaallinen kaappaus" allroyalscaptured = "Kaikki kuninkaaliset kaapattu" allpiecescaptured = "Kaikki nappulat kaapattu" koth = "Kukkulan kuningas" resignation = "Eroaminen" agreement = "Yhteisymmärrys" time = "Aika loppui" aborted = "Keskeytetty" # Game was cancelled (no elo exchanged) disconnect = "Hylätty" # A player left [play.javascript.results] you_checkmate = "Sinä voitit shakkimatilla!" you_time = "Sinä voitit ajassa!" you_resignation = "Sinä voitit eroamisella!" you_disconnect = "Sinä voitit hylkäyksellä!" you_royalcapture = "Sinä voitit kaappaamalla kuninkaalisen!" you_allroyalscaptured = "Sinä voitit kaappaamalla kaikki kuninkaaliset!" you_allpiecescaptured = "Sinä voitit kaappaamalla kaikki nappulat!" you_koth = "Sinä voitit olemalla kukkulan kuningas!" you_generic = "Sinä voitit!" draw_stalemate = "Tasapeli patista!" draw_repetition = "Tasapeli kertaamisesta!" draw_moverule = ["Tasapeli ", "-siirron-säännollä!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Tasapeli tarvitulla materiaalilla!" draw_agreement = "Tasapeli yhteisymmärryksestä!" draw_generic = "Tasapeli!" aborted = "Peli keskeytetty." opponent_checkmate = "Sinä hävisit shakkimatilla!" opponent_time = "Sinä hävisit ajassa!" opponent_resignation = "Sinä hävisit eroamisella!" opponent_disconnect = "Sinä hävisit hylkäyksellä!" opponent_royalcapture = "Sinä hävisit kuninkaalisen kaappaamisella!" opponent_allroyalscaptured = "Sinä hävisit kaikkien kuninkaalisten kaappaamisella!" opponent_allpiecescaptured = "Sinä hävisit kaikkien nappuloiden kaappaamisella!" opponent_koth = "Sinä hävisit kukkulan kuninkaalle!" opponent_generic = "Sinä hävisit!" white_checkmate = "Valkoinen voittaa shakkimatilla!" black_checkmate = "Musta voittaa shakkimatilla!" white_time = "Valkoinen voittaa ajassa!" black_time = "Musta voittaa ajassa!" white_resignation = "Valkoisen vetäytyminen" black_resignation = "Mustan vetäytyminen" white_disconnect = "Valkoisen yhteyden katkeaminen" black_disconnect = "Mustan yhteyden katkeaminen" white_royalcapture = "Valkoinen voittaa kaappaamalla kuninkaalisen!" black_royalcapture = "Musta voittaa kaappaamalla kuninkaalisen!" white_allroyalscaptured = "Valkoinen voittaa kaappaamalla kaikki kuninkaaliset!" black_allroyalscaptured = "Musta voittaa kaappaamalla kaikki kuninkaaliset!" white_allpiecescaptured = "Valkoinen voittaa kaappaamalla kaikki nappulat!" black_allpiecescaptured = "Musta voittaa kaappaamalla kaikki nappulat!" white_koth = "Valkoinen voittaa olemalla kukkulan kuningas!" black_koth = "Musta voittaa olemalla kukkulan kuningas!" bug_generic = "Tämä on bugi, ilmoita meille!" [play.javascript.editor] # Sidebar toggle expand_sidebar = "Avaa sivuvalikko" collapse_sidebar = "Sulje sivuvalikko" # Position header new_position = "Uusi asema" # Load/Save Position window headers load_position_header = "Avaa asema" save_position_as_header = "Tallenna asema nimellä" # Confirmation modal delete_title = "Poista asema?" delete_message = ["Oletko varma, että haluat poistaa tämän aseman \"", "\"? Tätä ei voi perua."] load_title = "Avaa asema?" load_message = ["Oletko varma, että haluat avata tämän aseman \"", "\"? Tallentamattomat muutokset tämänhetkiseen asemaan menetetään."] overwrite_title = "Korvaa asema?" overwrite_message = ["Oletko varma, että haluat korvata tämän aseman \"", "\"? Tätä ei voi perua."] # Save list row tooltips tooltip_load_position = "Avaa asema" tooltip_save_to_cloud = "Tallenna pilveen" tooltip_remove_from_cloud = "Poista pilvestä" tooltip_delete_position = "Poista asema" # Toast messages position_loaded = "Asema avattu onnistuneesti." cannot_start_local_empty = "Paikallista peliä ei voi aloittaa tyhjästä asemasta!" cannot_start_engine_empty = "Tietokonepeliä ei voi aloittaa tyhjästä asemasta!" position_not_supported = "Asemaa ei tueta syystä:" saved_in_browser = "Asema on tallennettu selaimeen." position_corrupted = "Asema on vioittunut." failed_to_load = "Aseman avaaminen epäonnistui:" failed_to_convert_icn = "Aseman muuntaminen ICN-muotoon pilvilatausta varten epäonnistui." too_large_for_cloud = "Asema on liian suuri pilvitallennusta varten." failed_to_upload = "Aseman lataus pilveen epäonnistui:" saved_to_cloud = "Asema tallennettu pilveen." no_changes = "Muutoksia ei tapahtunut." failed_to_load_cloud = "Aseman avaaminen pilvestä epäonnistui:" failed_to_delete_cloud = "Aseman postaminen pilvestä epäonnistui:" failed_to_remove_cloud = "Aseman siirto pilvestä epäonnistui:" saved_locally = "Asema tallennettu paikallisesti." failed_to_fetch_cloud = "Pilvitallennusten hakeminen epäonnistui:" [terms] title = "Käyttöehdot" warning = ["TÄMÄ DOKUMENTTI EI OLE OIKEUDELLISESTI SITOVA. Olemme vastuussa vain tämän dokumentin englanninkielisestä versiosta. Tämä käännös on tarkoitettu vain yleistä tiedotusta varten. Voit käyttää virallista englanninkielistä versiota ", "täällä", "."] consent = "Käyttämällä tätä sivustoa hyväksyt seuraavat ehdot. Jos et hyväksy, sinun tulee lopettaa sivuston käyttö välittömästi." guardian_consent = "Jos olet alle 18-vuotias, sinun tulee saada vanhemman tai laillisen huoltajan suostumus käyttääksesi tätä sivustoa ja luodaksesi tilin." parents_header = "Vanhemmille" parents_paragraphs = [ "Sivustolla on algoritmi, joka estää käyttäjiä asettamasta nimekseen yleisiä kirosanoja. Tällä hetkellä sivustolla ei ole jäsenten välistä viestintämahdollisuutta.", "Tällä hetkellä jäsenet eivät voi asettaa omaa profiilikuvaa. Tämän ominaisuuden lisäämistä suunnitellaan. Silloin pyrimme estämään sopimattomat profiilikuvat parhaamme mukaan.", ] fair_play_header = "Reilu peli" fair_play_paragraph1 = ["Et voi luoda useampaa kuin yhtä tiliä."] fair_play_paragraph2 = "Jotta pelaaminen olisi hauskaa ja reilua kaikille, ET SAA:" fair_play_rules = [ "Muokata tai manipuloida koodia millään tavalla, mukaan lukien mutta ei rajoittuen: Konsolikomennot, paikalliset ohitukset, omat skriptit, http-pyyntöjen muokkaaminen jne. Tätä voidaan tehdä tarkoituksella pelin rikkomiseksi tai saadakseen etua.", "Hyödyntää bugeja tai virheitä pelin keskeyttämiseksi, edun saamiseksi tai pelin tekemiseksi muuten pelikelvottomaksi.", "Arvostelluissa peleissä saada apua/toisen henkilön tai ohjelman neuvoja siitä, mitä sinun tulisi pelata. (Algoritmin luominen on ok ja kannustettua, mutta sen käyttö tulee rajoittaa arvostelemattomiin, rentoihin peleihin)", "Vaihtaa elo-pisteitä muiden kanssa häviämällä tarkoituksellisesti, jotta vastustajan elo nousee, tai saamalla elo-pisteitä vastustajalta, joka aikoo hävitä nostaakseen sinun arvoasi. Tämä vääristää järjestelmää ja luo epätarkkoja luokituksia taitotasoon nähden." ] cleanliness_header = "Siisteys" cleanliness_rules = [ "Kaikki sivustolla käyttämässäsi kieli tulee pysyä siistinä, ei kiroilua tai rumaa kieltä. Et saa kiusata, häiritä tai uhkailla ketään, etkä tehdä mitään laitonta. Et saa spämmätä muita käyttäjiä tai foorumeita.", "Et saa ladata profiiliisi kuvia, jotka ovat sopimattomia, vihjailevia tai verisiä. Tällainen toiminta voi johtaa tilin estämiseen tai poistoon." ] privacy_header = "Yksityisyys" privacy_rules = [ "Tällä hetkellä ainoa keräämämme henkilötieto on sähköpostiosoite. Tämän tarkoituksena on vahvistaa käyttäjien tilit ja tarjota keino todistaa henkilöllisyys salasanan palautuspyynnön yhteydessä. Emme lähetä mainosviestejä tai tarjouksia. Emme jaa käyttäjän sähköpostiosoitetta kenellekään.", "InfiniteChess.org saattaa kerätä tietoja sivuston käytöstäsi, mukaan lukien IP-osoitteesi. Tämä on tarkoitettu bottien ja muiden ei-toivottujen tahojen estämiseen sekä tilastojen ylläpitoon tietokannassa. Tämä EI ole kotiosoitteesi.", "Kaikki pelit, joita pelaat tällä sivustolla, ovat julkista tietoa. Jos haluat pysyä anonyyminä, älä jaa käyttäjätunnustasi ystävien tai perheen kanssa. Jos tämä on toiveesi, sinun vastuullasi on varmistaa, ettei kukaan saa tietää käyttäjätunnuksesi liittyvän henkilöllisyyteesi.", "Tilisi aktiivisuus ja arvioitu viimeisin aktiivisuusaika sivustolla ovat myös julkista tietoa.", ["Vaikka InfiniteChess.org pyrkii parhaansa mukaan pitämään kaikkien tilit ja henkilötiedot turvassa, mahdollisen hakkeroinnin tai tietovuodon sattuessa et voi nostaa syytteitä meitä vastaan. Jos tietovuoto tapahtuu, käyttäjille ilmoitetaan ", "Uutiset", "sivulla."], "Sivustolla ei ole ostettavaa sisältöä. Muita henkilötietoja ei kerätä.", "Voit poistaa yksityiset tietosi palvelimiltamme poistamalla tilisi profiilisivulta. Ainoa asia, jota emme poista ja joka liittyy käyttäjätunnukseesi, on pelihistoria, koska kaikki pelit ovat julkista tietoa.", ] cookie_header = "Evästekäytäntö" cookie_paragraphs = [ "Tämä sivusto käyttää evästeitä, jotka ovat pieniä tekstitiedostoja, jotka tallennetaan selaimeesi ja lähetetään palvelimelle pyyntöjen yhteydessä. Evästeiden tarkoitus on: Vahvistaa kirjautumissessiosi, varmistaa selaimesi kuuluvan oikeaan peliin ja tallentaa käyttäjän peliasetukset, jotta ne säilyvät seuraavalla vierailulla. Sivusto ei käytä kolmannen osapuolen evästeitä, eikä evästeitä jaeta ulkopuolisille.", "Evästeet ovat välttämättömiä sivuston ja pelin toiminnalle. Jos et halua sivuston tallentavan evästeitä, sinun tulee lopettaa sivuston käyttö. Voit poistaa olemassa olevat evästeet selaimesi asetuksista. Jatkamalla sivuston käyttöä hyväksyt evästeiden käytön." ] conclusion_header = "Yhteenveto" conclusion_paragraphs = [ "Kaikkien näiden ehtojen rikkominen voi johtaa tilin estämiseen tai poistoon. InfiniteChess.org haluaa tarjota kaikille mahdollisuuden pelata ja pitää hauskaa! Varaamme kuitenkin oikeuden milloin tahansa estää tai poistaa käyttäjien tilejä syistä, joita ei tarvitse ilmoittaa. Meitä vastaan ei voi nostaa syytteitä.", ["Näitä käyttöehtoja voidaan muuttaa milloin tahansa. On SINUN vastuullasi pysyä ajan tasalla viimeisimmistä muutoksista! Kun käyttöehdot päivittyvät, siitä ilmoitetaan ", "Uutiset", "sivulla. Jos et hyväksy uusia ehtoja päivityksen yhteydessä, sinun tulee lopettaa sivuston käyttö välittömästi. Voit poistaa tilisi profiilisivulta. Jos poistat tilisi, kaikki yksityiset tietosi ja tilitietosi poistetaan, PAITSI pelihistoriaa ei poisteta, koska se on julkista tietoa."], ["Tällä sivustolla on avoin lähdekoodi. Voit kopioida tai jakaa mitä tahansa tältä sivustolta, kunhan noudatat ", "lisenssiehtoja", "! Jos tämä linkki on rikki, on sinun vastuullasi etsiä ehdot."], "Emme voi taata, että sivusto toimii 100% ajasta. Emme myöskään voi taata, ettei data koskaan korruptoidu.", "Et saa tehdä mitään laitonta sivustolla.", ["Jos sinulla on kysyttävää näistä ehdoista tai muusta sivustoon liittyvästä, ", "lähetä meille sähköpostia!"] ] thanks = "Kiitos!" [login] title = "Kirjaudu sisään" # The tab name username = "Käyttäjänimi:" password = "Salasana:" login_button = "Kirjaudu sisään" send_reset_link = "Lähetä palautuslinkki" forgot_question = "Unohditko salasanasi?" back_to_login = "Takaisin kirjautumaan" forgot_instruction = "Unohditko salasanasi? Syötä käyttäjätunnuksesi ja lähetämme sinulle palautuslinkin." [login.javascript] network-error = "Verkkovirhe tapahtui. Kokeile uudestaan." [reset_password] title = "Palauta salasanasi" instruction = "Syötä ja vahvista uusi salasanasi." new_password = "Uusi salasana" confirm_password = "Vahvista salasana" submit_button = "Palauta salasana" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Väärä pyyntö oli vastaanotettu." 409_message = ["On ehkä olemassa samanniminen käyttäjä tai sähköposti. ", "Lataa uudelleen", ", tämä sivu."] 500_message = "Tämän ei ole tarkoitus tapahtua. Jotain täytyy korjata!" [news] title = "Uutiset" # The tab name more_dev_logs = ["Enemmän kehittäjälogeja on ", "discord-kanavalla", ", ja ", "chess.com foorumeilla!"] [server.javascript] ws-invalid_username = "Käyttäjänimi on väärä" ws-incorrect_password = "Salasana on väärä" ws-login_failure_retry_in = "Sisäänkirjautuminen epäonnistui, yritä uudelleen" ws-seconds = "sekunnissa" # unit of time ws-second = "sekunnissa" # unit of time ws-username_letters = "Käyttäjänimessä saa sisältää vain kirjaimia A-Z ja numeroita 0-9" ws-username_taken = "Käyttäjänimi on otettu" ws-username_bad_word = "Käyttäjänimi sisältää sanan, joka on kielletty" ws-email_too_long = "Sinun sähköpostiosoitteesi on liian piiitkä." ws-email_invalid = "Tämä ei ole oikea sähköpostiosoite" ws-email_in_use = "Sähköposti on jo käytössä" ws-email_domain_invalid = "Vääränlainen nettiosoite." ws-email_blacklisted = "Sinun sähköpostiosoitteesi on estolistalla." ws-password_length = "Salasanan tulee olla 6-72 kirjainta pitkä" ws-password_password = "Salasana ei saa olla 'password'" ws-password-reset-link-sent = "Jos kyseisellä sähköpostiosoitteella on jo tili, salasanan palautuslinkki on lähetetty." ws-password-change-success = "Salasanan palauttaminen onnistui. Sinut ohjataan pian kirjautumissivulle." ws-password-reset-token-invalid = "Salasanan palautustunnus on virheellinen tai vanhentunut." ws-forbidden_wrong_account = "Kielletty. Tämä ei ole sinun tilisi." ws-deleting_account_not_found = "Tilin poisto epäonnistui. Tiliä ei löydetty." ws-deleting_account_in_game = "Et voi poistaa tiliäsi, kun olet vielä pelissä." ws-server_error = "Anteeksi, palvelinvirhe tapahtui! Mene takaisin." ws-not_found = "404 Ei Löydetty" ws-forbidden = "Kielletty." ws-already_in_game = "Olet jo pelissä." ws-server_restarting = "Palvelin uudelleenkäynnistyy" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "Palvelin on huollossa. Tule pian takaisin!" # Can be changed at will to change the display message. ws-minutes = "minuutissa" # unit of time ws-minute = "minuutissa" # unit of time ws-you_cheated = "Hups! Sinä pelasit jotain sääntöjen vastaista. Tämä peli on keskeytetty. Jos tämä on vahinko, ilmoita meille!" ws-opponent_cheated = "Vastustajasi pelasi jotain sääntöjen vastaista. Tämä peli on keskeytetty." ws-cannot_resign_finished_game = "Pelistä ei voi erota, se on jo ohi." ws-invalid_code = "Vääränlainen koodi!" # Invite code doesn't match any existing invites ws-game_aborted = "Peli keskeytettiin." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "Pelataksesi arvosteltuja pelejä sinun on kirjauduttava sisään vahvistetulla tilillä." [rate-limiting] generic = "Teit liian monta pyyntöä, kokeile myöhemmin uudelleen." error = "Liian monta pyyntöä" ================================================ FILE: translation/fr-FR.toml ================================================ name = "Français" # Name of language english_name = "French" direction = "ltr" version = "17" maintainer = "Life Enjoyer,cycy98,Heinrich Xiao" [header] home = "Accueil" play = "Jouer" news = "Actualités" login = "Connexion" createaccount = "Créer un compte" [footer] contact = "Contactez nous" terms_of_service = "Conditions d'utilisation" source_code = "Code Source" language = "Langue" [header.javascript] js-profile = "Profil" js-logout = "Déconnexion" js-login = "Connexion" js-createaccount = "Créer un compte" [member.javascript] js-confirm_delete = "Êtes vous sûr de vouloir supprimer votre compte ? Cette action est DÉFINITIVE ! Cliquez sur 'OK' pour rentrer votre mot de passe." js-enter_password = "Entrez votre mot de passe pour supprimer votre compte DÉFINITIVEMENT:" [index] title = "Infinite Chess | Accueil - Le Site Officiel" # The tab title secondary_title = "Le site officiel pour jouer en direct !" what_is_it_title = "Qu'est ce que c'est ?" what_is_it_pargaraphs = [ "Infinite Chess est une variante des échecs dans laquelle il n'y pas de bordures, on est bien loin du plateau de 8x8 cases classique. La dame, les tours et les fous n'ont pas de limites à leur distance de déplacement. Vous pouvez vous déplacez sur n'importe quel case, de 0 à l'infini !", "Sans limite à la distance que vous pouvez parcourir, il y a des positions dans lesquelles l'horloge de la mort, ou le nombre de coups avant le mat, est représenté par le premier ordinal infini: omega ω. En fait, les recherches ont prouvé que le nombre de coups avant un mat peut être représenté par tout ordinal dénombrable !", "Comme vous pouvez l'imaginer, il y a une infinité de positions de départ possibles dont beaucoup que vous pouvez jouer de façon compétitive ! Le but final reste de faire un échec et mat, il est donc nécessaire de développer des nouvelles techniques vu qu'il n'y pas de murs pour coincer le roi ennemi. Normalement, les parties ne durent pas plus longtemps que dans un jeu d'échec classique. Les pions font leur promotions sur les lignes 1 et 8 respectivement !", ] how_to_title = "Comment Jouer ?" how_to_paragraph = ["Le jeu est actuellement en version 1.10 sur la page ","Jouer","!"] about_title = "À Propos" about_paragraphs = [ "Je m'apelle Naviary. Dès que j'ai découvert Infinite Chess (le concept existait bien avant ce site), J'ai été très intrigué par ce jeu et par les possibilités qu'il débloque ! Jusqu'à aujourd'hui, y jouer était plutôt difficile, les membres de chess.com devaient créer des images du plateau eux même et se les envoyer à chaque coup. À cause de ça, peu de gens connaissent cette variante.", ["Mon objectif est de créer un site qui permettrait à tout le monde d'y jouer facilement, et de développer une communauté autour du jeu. J'ai passé un nombre incalculable d'heures de mon temps libre à faire ce site, à le maintenir et à developper le jeu et j'ai encore beaucoup d'idées qui vont m'occuper pendant pas mal de temps. Bien que je souhaite garder ce site gratuit, la vie a un coût et pour aider à me supporter financiérement vous pouvez rejoindre mon ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Contributeurs Patreons" [credits] title = "Credits" copyright = "Tout le contenu du site qui n'est pas listé ci dessous est la propriété de www.InfiniteChess.org" variants_heading = "Variantes" variants_credits = [ "Core crée par Andreas Tsevas.", "Space crée crée par Andreas Tsevas.", "Space Classic crée par Andreas Tsevas.", "Coaip (Chess on an Infinite Plane) crée par V. Reinhart.", "Pawn Horde crée par Inaccessible Cardinal.", "Abundance crée par Clicktuck Suskriberz.", "Pawndard crée par SexyLexi.", "Classical+ crée par SexyLexi.", "Knightline crée par Inaccessible Cardinal.", "Knighted Chess crée par cycy98.", "crée par Cory Evans et Joel Hamkins.", "crée par Andreas Tsevas.", "crée par Cory Evans et Joel Hamkins.", "crée par Cory Evans, Joel Hamkins, et Norman Lewis Perlmutter.", ] textures_heading = "Textures" textures_licensed_under = "textures sous la licence" textures_credits = [ "Gold coin par Quolte.", ] sounds_heading = "Sons" sounds_credits = [ ["Certains sons viennent du", "project under the"], "D'autres ont été crées par Naviary.", ] code_heading = "Code" code_credits = [ "par Brandon Jones et Colin MacKenzie IV.", "par Andreas Tsevas et Naviary.", ] language_heading = "Traductions de langue" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Français par ", "Life Enjoyer", " et ", "cycy98", ".", "Chinois simplifié par ", "Heinrich Xiao", ".", "Chinois traditionnel par ", "Heinrich Xiao", ".", "Polonais par ", "Tymon Becella", ".", # Apsurt "Portugais par ", "Emerson P. Machado", "." # The_Skeleton on discord ] [member] title = "Membre" # The tab name verify_message = "Validez l'email pour vérifier votre compte." resend_message = ["Vous n'avez pas reçu d'email ? Vérifiez vos spams. Aussi, ", "renvoyer.", " Si vous ne recevez toujours rien,", "envoyez moi un message."] verify_confirm = "Merci ! Votre compte est désormais vérifié." rating = "Classement elo:" joined = "A rejoint le:" seen = ["Vu il y a:", ""] reveal_info = "Voir les informations du compte" account_info_heading = "Informations" email = "Email:" delete_account = "Supprimer le compte" password_reset_message = ["Pour changer votre nom d'utilisateur, votre mot de passe ou votre email vous pouvez, ", "nous contacter."] [create-account] title = "Créer un compte" username = "Nom d'utilisateur:" email = "Email:" password = "Mot de Passe:" create_button = "Créer un compte" agreement = ["J'accepte les ", "Conditions d'utilisation", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_specs = "Le nom d'utilisateur doit être d'au moins 3 caractères et ne doit contenir que des lettres ou des chiffres" js-username_tooshort = "Le nom d'utilisateur doit faire au moins caractères" js-username_wrongenc = "Le nom d'utilisateur ne peut contenir que des lettres ou des chiffres" js-email_invalid = "Cette adresse mail est invalide" js-email_inuse = "Cette adresse mail est déjà utilisée" js-pwd_incorrect_format = "Le mot de passe est dans un format incorrect" js-pwd_too_short = "Le mot de passe doit contenir au moins 6 caractères" js-pwd_too_long = "Le mot de passe doit contenir moins de 72 caractères" js-pwd_not_pwd = "Le mot de passe ne doit pas être 'password'" [play] title = "Infinite Chess - Jouer" loading = "CHARGEMENT" error = "ERREUR" [play.main-menu] credits = "Credits" play = "Jouer" guide = "Guide" editor = "Editeur de plateau" error = "ERREUR" [play.guide] title = "Guide" rules = "Règles" rules_paragraphs = [ "Les règles de Infinite Chess sont presques identiques à celles des échecs classiques, la seule différence étant que le plateau est infini dans toutes les directions ! Les seuls changements que vous devez prendre en compte sont listés ci-dessous:", "Les pièces with avec des déplacements coulissants, comme les tours, les fous et les dames, n'ont pas de limite à la distance qu'elles peuvent parcourir en un tour. Tant que leur chemin n'est pas obstrué, elles peuvent traverser des millions de cases!", ["Dans la variante par défaut \"Classique\", les pions blancs font leur promotion ligne 8, et les pions noirs ligne 1. Sur cette image, ces lignes sont indiquées par les fines lignes noires. Elles sont discrètes, regardez si vous pouvez les voir ! Les pions ont seulement besoin d'atteindre la ligne opposée à leur position de départ pour faire promotion, ", "pas", " de la dépasser."], "Les cases ne sont plus décrites par leur lettre et leur numéro de ligne (ex: a1) mais plutôt comme une paire de coordonnées x et y. La case a1 devient (1,1), et la case h8 devient (8,8). Sur ordinateur, les coordonées de la case sur laquelle est votre souris sont affichées en haut au milieu de l'écran.", "Toutes les autres règles sont les mêmes que dans le jeu d'échec classique, comme les échecs-et-mat, les pats, les répétitions de positions, la règle des 50 coups, rocs, prise au passant, etc.." ] careful_heading = "Faites Attention !" careful_paragraphs = [ "L'ouverture donnée par un plateau infini fait qu'il est très facile d'exploiter les fourchettes, les clouages et les enfilades. La partie du plateau derrière votre Roi est souvent très vulnérable. Faites attention aux tactiques qui l'exploite ! Soyez créatif sur la protection que vous formez autour de votre roi et de vos tours! Les ouvertures sont très différentes de celles des échecs classiques.", "Beaucoup d'autres variantes ont été crées avec pour but de renforcer l'arrière du roi." ] controls_heading = "Contrôles" controls_paragraph = "Vous pouvez cliquer et faire glisser le plateau pour vous y déplacer, ou scroller pour zoomer, vous pouvez également cliquer sur n'importe quelle pièce (y compris celles de votre adversaire) pour voir leurs déplacements légaux. Il y a d'autres contrôles un peu plus avancés, les voici: " keybinds = [ " pour se déplacer.", ["Espace", " et ", "Maj", " pour zoomer et dézoomer."], ["Échap", " pour mettre pause."], ["Tab", " pour activer et désactiver les flèches pointant vers les pièces hors champ sur les bords de l'écran. Par défaut elles sont en mode \"Défense\", une flèche n'est alors affichée que pour les pièces qui peuvent bouger de leur emplacement à une case visible sur l'écran. Mais, appuyer sur ", "tab", " permet de désactivé totalement ces flèches ou de les passer en mode \"Toutes\". Le mode \"Toutes\" révèle toutes les pièces qui passent par des lignes ou des diagonales visibles à l'écran, selon si ces pièces se déplacent en ligne ou en diagonale. Le mode choisit peut également être changé depuis le menu pause. Cliquer sur les flèches vous téléportera à la pièce vers laquelle elles pointent."], " va activer/désativer le \"mode d'édition\" dans les parties locales. Le mode d'édition vous permet déplacer n'importe quelle pièce n'importe où sur le plateau. Très utile pour faire des analyses." ] controls_paragraph2 = "Ce sont les principaux contrôles que vous devez connaître. Mais il y en d'autres qui pourraient vous être utiles !" keybinds_extra = [ " rénitialise l'affichage des pièces. C'est utile si elles deviennent invisibles. Ce qui peut arriver quand elles sont à des distances très éloignées (à partir de 10²¹ notamment).", " active/désactive l'affichage des barres de navigation et d'informations, ce qui peut être utile pour record. Les streams et les vidéos sur le jeu sont les bienvenus!", " active/désactive le compteur de FPS. Qui affiche le nombre de mises à jour de la partie par seconde (ce qui ne correspond pas toujours au nombre de frames affichées, étant donné que l'écran n'est rafraichi que quand quelque chose de visible change, pour optimiser les performances).", " active/désactive l'affichage des icônes. Les icônes sont des petites images cliquables des pièces quand vous dézoomez suffisament. Dans les parties importées avec plus de 50 000 pièces, ce paramètre est automatiquement désactivé, étant donné qu'il prend beaucoup de performances, il peut cependant être réactivé.", [" (backtick, ou la même touche que ", ") active/désactiver le debug mode."], ] fairy_heading = "Pièces fées" fairy_paragraph = "Vous savez déjà ce que vous devez savoir pour jouer à la variante par défaut \"Classique\". Les pièces fées ne sont pas utilisés dans les échecs conventionels, mais elles sont incorporées dans d'autres variantes de ce site ! Si vous vous trouvez dans une variante avec des pièces que vous n'avez jamais vu avant vous pouvez apprendre comment elles se déplacent ici !" editing_heading = "Editeur de Plateau" editing_paragraphs = [ ["Il y a un ", "éditeur de plateau", " externe, pour l'instant disponible dans un google sheet publique ! Il contient toutes les instructions sur comment l'utiliser (en anglais) et demande un peu de connaissances du fonctionnement des tableurs. Après ça, vous serez capable de créer et d'importer des positions personnalisées dans le jeu via le bouton \"Coller la Position\" dans le menu des options !"], "Pour jouer sur une position personnalisée avec un ami vous devez le rejoindre via une invitation privée, puis vous devez tous les deux copier et coller le code de la partie avant de commencer à jouer !", "Un editeur de plateau en jeu est prévu.", ] back = "Retour" [play.guide.pieces] chancellor = {name="Le Chancelier", description="Il se déplace comme une tour et un cavalier combinés."} archbishop = {name="L'Archifou", description="Il se déplace comme un fou et un cavalier combinés."} amazon = {name="L'Amazone", description="Elle se déplace comme une dame et un cavalier combinés. C'est la pièce fée la plus forte du jeu !"} guard = {name="Le Garde", description="Il se déplace comme le roi mais ne peut pas être mis en échec ou en échec et mat."} hawk = {name="Le Faucon", description="Il se déplace d'exactement 2 ou 3 cases dans chaque direction. Il peut sauter au dessus d'autres pièces comme un cavalier."} centaur = {name="Le Centaure", description="Il se déplace comme un cavalier et un garde combinés."} knightrider = {name="Le Cavalier Sauteur", description="Saute comme un cavalier mais jusqu'à l'infini, tant qu'il ne rencontre pas d'obstacles."} obstacle = {name="Obstacle", description="Une pièce neutre (controlées par aucun des joueurs) qui bloque les mouvements mais peut être capturée."} void = {name="Vide", description="Une pièce neutre (controlées par aucun des joueurs) qui représente l'absence d'une case sur le plateau. Les pièces ne peuvent se déplacer ni au dessus ni à travers du vide."} [play.play-menu] title = "Jouer - En Ligne" colors = "Couleurs" online = "En Ligne" local = "Local" computer = "Ordinateur" variant = "Variante" Classical = "Classique" Classical_Plus = "Classique+" CoaIP = "Chess on an Infinite Plane" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Pawn Horde" Space_Classic = "Space Classic" Space = "Space" Obstocean = "Obstocean" Abundance = "Abundance" Amazon_Chandelier = "Amazon Chandelier" Containment = "Containment" Classical_Limit_7 = "Classical - Limit 7" CoaIP_Limit_7 = "Coaip - Limit 7" Chess = "Chess" Classical_KOTH = "Experimental: Classical - KOTH" CoaIP_KOTH = "Experimental: Coaip - KOTH" Omega = "Showcase: Omega" Omega_Squared = "Showcase: Omega^2" Omega_Cubed = "Showcase: Omega^3" Omega_Fourth = "Showcase: Omega^4" no_clock = "Pas d'horloge" clock = "Horloge" minutes = "m" seconds = "s" infinite_time = "Temps Infini" color = "Couleur" piece_colors = ["Aléatoire", "Blanc", "Noir"] private = "Privé" no = "Non" yes = "Oui" rated = "Classé" casual = "Non classé" join_games = "Rejoindre des Parties:" private_invite = "Invitation Privée:" your_invite = "Votre code d'invitation:" create_invite = "Créer une invitation" join = "Rejoindre" copy = "Copier" back = "Retour" code = "Code" [play.footer] white_to_move = "Tour des Blancs" player_white = "Joueur Blanc" player_black = "Joueur Noir" [play.pause] title = "En Pause" resume = "Reprendre" arrows = "Flèches: Defense" perspective = "Perspective: Off" copy = "Copier la position" paste = "Coller une position" offer_draw = "Proposer une nulle" main_menu = "Menu Principal" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Accepter la nulle ?" [play.javascript] guest_indicator = "(Guest)" you_indicator = "(Vous)" white_to_move = "Au tour des blancs" black_to_move = "Au tour des noirs" your_move = "Votre coup" their_move = "Son coup" lost_network = "Connexion perdue." failed_to_load = "Une ressource ou plus n'a pas plus charger. Rafraîchissez la page s'il vous plaît." planned_feature = "Cette fonctionnalité est prévue !" main_menu = "Menu Principal" resign_game = "Abandonner la Partie" abort_game = "Abandonner la Partie" arrows_off = "Flèches: Off" arrows_defense = "Flèches: Défense" arrows_all = "Flèches: Toutes" toggled = "Activé" menu_online = "Jouer - En Ligne" menu_local = "Jouer - Local" invite_error_digits = "Les codes d'invitation doivent être composés de 5 chiffres." invite_copied = "Le code d'invitation a été copié dans le presse papier." move_counter = "Tours:" constructing_mesh = "Construction du mesh" rotating_mesh = "Rotation du mesh" lost_connection = "Connexion perdue." please_wait = "Attendez un moment s'il vous plaît." webgl_unsupported = "Votre navigateur ne supporte pas WebGL. Ce jeu ne peut pas fonctionner sans. Mettez à jour votre navigateur s'il vous plaît." bigints_unsupported = "Votre navigateur ne supporte pas les BigInts. Mettez à jour votre navigateur.\nLes BigInts sont nécessaire pour rendre le plateau infini." shaders_failed = "Les shaders n'ont pas pu être initialisés:" failed_compiling_shaders = "Une erreur s'est produite lors de la compilation des shaders:" offer_draw = "Proposer une nulle" accept_draw = "Accepter la nulle" [play.javascript.copypaste] copied_game = "La position a été copiée dans le presse-papier !" cannot_paste_in_public = "Vous ne pouvez pas coller une position dans une partie publique !" cannot_paste_after_moves = "Vous ne pouvez pas coller une position après que des coups aient été joués !" clipboard_denied = "Erreur de permission avec le presse-papier. Ceci pourrait être du à votre navigateur." clipboard_invalid = "Le presse-papier ne contient pas de notation ICN valide." game_needs_to_specify = "La partie doit spécifier la metadata 'Variant' ou la propriété 'position'." invalid_wincon_white = "Les Blancs ont une condition de victoire invalide" invalid_wincon_black = "Les Noirs ont une condition de victoire invalide" pasting_game = "Collage de la position..." pasting_in_private = "Coller une position dans une partie privée causera une désynchronisation si votre adversaire ne fait pas de même !" piece_count = "Nombre de pièces" exceeded = "dépassé" changed_wincon = "Les condition de victoire par mat ont étés passé à 'capture royale', et l'affichage des icones a été désactivé. Appuyez sur 'P' pour les réactiver (déconseillé)." loaded_from_clipboard = "La position a été chargée depuis le presse-papier !" loaded = "Position chargée !" slidelimit_not_number = "La gamerule 'slideLimit' doit être un nombre." [play.javascript.rendering] on = "On" off = "Off" icon_rendering_off = "L'affichage des icones a été désactivé." icon_rendering_on = "L'affichage des icones a été activé." toggled_edit = "Le mode d'édition a été activé:" perspective = "Perspective" perspective_mode_on_desktop = "Le mode perspective n'est pas disponible sur téléphone !" movement_tutorial = "WASD pour se déplacer. Espace et maj pour zoomer." regenerated_pieces = "Les pièces ont été régénérés." [play.javascript.invites] move_mouse = "Déplacez votre souris pour vous reconnecter." unknown_action_received_1 = "Action inconnue" unknown_action_received_2 = "reçu par le serveur dans l'abonnement d'invitations !" cannot_cancel = "Impossible d'annuler l'invitation ou identifiant non défini." you_indicator = "(Vous)" you_are_white = "Vous êtes: Les blancs" you_are_black = "Vous êtes: Les noirs" random = "Aléatoire" accept = "Accepter" cancel = "Annuler" create_invite = "Créer une invitation" cancel_invite = "Annuler l'invitation" start_game = "Commencer" join_existing_active_games = "Rejoindre des parties existantes - actives:" [play.javascript.onlinegame] afk_warning = "Vous êtes AFK." opponent_afk = "Votre adversaire est AFK." opponent_disconnected = "Votre adversaire s'est déconnecté." opponent_lost_connection = "Votre adversaire est hors ligne." auto_resigning_in = "Abandon automatique dans" auto_aborting_in = "Abandon automatique dans" not_logged_in = "Vous n'êtes plus connecté. Reconnectez vous pour pouvoir retourner dans cette partie s'il vous plaît." game_no_longer_exists = "Cette partie n'existe plus." another_window_connected = "Une autre fenêtre a établi une connexion." server_restarting = "Le serveur redémarre bientôt..." server_restarting_in = "Le serveur redémarrera dans" minute = "minute" minutes = "minutes" [play.javascript.websocket] no_connection = "Pas de connexion." reconnected = "Reconnecté." unable_to_identify_ip = "L'IP n'a pas pu être identifiée." online_play_disabled = "Le jeu en ligne est désactivé. Les cookies ne sont pas supportés. Essayez avec un autre navigateur." too_many_requests = "Trop de requêtes. Réessayez dans quelques minutes." message_too_big = "Message trop gros." too_many_sockets = "Too many sockets" origin_error = "Origin error." connection_closed = "La connection a été fermé de façon imprévue. Message du serveur:" please_report_bug = "Ceci ne devrait jamais arriver, reportez ce bug s'il vous plaît !" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Échec et Mat" stalemate = "Pat" repetition = "Répétition de coups" # Needs translation moverule = ["Regle des ", " coups"] # The game inserts a number inbetween these two strings insuffmat = "Matériel insuffisant" royalcapture = "Capture royale" # Needs translation allroyalscaptured = "Toutes les pièces royales ont été capturées" allpiecescaptured = "Toutes les pièces ont été capturées" threecheck = "Triple échec" koth = "KOTH" resignation = "Abandon" agreement = "Accord" time = "Défaite au temps" aborted = "Annulation" # Game was cancelled (no elo exchanged) disconnect = "Déconnexion" # A player left [play.javascript.results] you_checkmate = "Victoire par échec-et-mat !" you_time = "Victoire par le temps!" you_resignation = "Victoire par abandon !" you_disconnect = "Victoire par déconnexion !" you_royalcapture = "Victoire par capture du Roi !" you_allroyalscaptured = "Victoire par capture de tous les Rois!" you_allpiecescaptured = "Victoire par capture de toutes les pièces!" you_threecheck = "Victoire par triple échec !" you_koth = "Victoire par KOTH !" you_generic = "Victoire !" draw_stalemate = "Pat !" draw_repetition = "Égalité par répétition !" draw_moverule = ["Égalité par la règle des ", " coups"] # The game inserts a number inbetween these two strings draw_insuffmat = "Égalité par manque de matériel !" draw_agreement = "Égalité par accord !" draw_generic = "Égalité!" aborted = "Partie annulée." opponent_checkmate = "Défaite par échec-et-mat !" opponent_time = "Défaite par le temps!" opponent_resignation = "Défaite par abandon !" opponent_disconnect = "Défaite par déconnexion !" opponent_royalcapture = "Défaite par perte du Roi !" opponent_allroyalscaptured = "Défaite par perte de tous les Rois!" opponent_allpiecescaptured = "Défaite par perte de toutes les pièces!" opponent_threecheck = "Défaite par triple échec !" opponent_koth = "Défaite par KOTH !" opponent_generic = "Défaite !" white_checkmate = "Les Blancs gagnent par échec-et-mat !" black_checkmate = "Les Noirs gagnent par échec-et-mat !" bug_checkmate = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un échec-et-mat." white_time = "Les Blancs gagnent par le temps!" black_time = "Les Noirs gagnent par le temps!" bug_time = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie à cause du temps." white_royalcapture = "Les Blancs gagnent par capture du Roi !" black_royalcapture = "Les Noirs gagnent par capture du Roi !" bug_royalcapture = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par capture d'un Roi." white_allroyalscaptured = "Les blancs gagnent par capture de tous les Rois!" black_allroyalscaptured = "Les noirs gagnent par capture de tous les Rois!" bug_allroyalscaptured = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par la capture de tous les Rois d'un camp." white_allpiecescaptured = "Les Blancs gagnent par capture de toutes les pièces!" black_allpiecescaptured = "Les Noirs gagnent par capture de toutes les pièces!" bug_allpiecescaptured = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par la capture de toutes les pièces d'un camp." white_threecheck = "Les Blancs gagnent par triple-échec !" black_threecheck = "Les Noirs gagnent par triple-échec !" bug_threecheck = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un triple-échec." white_koth = "Les Blancs gagnent par KOTH !" black_koth = "Les noirs gagnent par KOTH !" bug_koth = "Ceci est un bug, reportez le s'il vous plaît. La partie s'est finie par un KOTH." bug_generic = "Ceci est un bug, reportez le s'il vous plaît." [terms] title = "Conditions d'Utilisation" warning = ["CE DOCUMENT N'A PAS DE VALEUR LÉGALE. Notre responsabilité n'est engagée que par la version anglaise de ce texte. Cette traduction n'a qu'une valeur informationelle et peut contenir des erreurs ou inexactitudes. Vous pouvez accèder à la version Anglaise ", "ici", "."] consent = "En utilisant ce site, vous vous engager à respecter les conditions suivantes. Si vous ne les acceptez pas, vous devez immédiatement quitter le site." guardian_consent = "Si vous avez moins de 18 ans vous devez recevoir l'accord de vos responsables légaux pour utiliser ce site ou vous créer un compte." parents_header = "Parents" parents_paragraphs = [ "Il y a un système qui empêche les utilisateurs de mettre des certaines insultes en nom d'utilisateur. Pour l'instant il n'y pas de possibilité de communication entre les membres du site.", "Actuellement, les membres ne peuvent pas mettre de photos de profil personalisées, c'est prévu cependant. Au moment où cette fonctionnalité arrivera nous ferons de notre mieux pour empêcher les photos inappropriées.", ] fair_play_header = "Fairplay" fair_play_paragraph1 = ["Vous ne pouvez pas créer plus d'un compte. Si vous voulez changer l'adresse mail associée à votre compte, ", "contactez nous."] fair_play_paragraph2 = "Pour garder le jeu amusant et équilibré pour tous, vous ne devez PAS:" fair_play_rules = [ "Modifier ou manipuler le code ce qui inclut mais n'est pas limité à: l'utilisation de commandes dans la console, la réecriture de données locales, les scripts personnalisés, modifier les requêtes http et tout ce qui peut être fait intentionellement pour empêcher le fonctionnement normal du jeu ou vous donner un avantage.", "Dans les parties classées, recevoir de l'aide ou des conseils d'une autre personne ou d'un programme sur ce que vous devriez jouer. (Créer un programme est autorisé et encouragé mais vous devez vous limitez à l'utiliser dans des parties non classées).", "Échanger des points d'elo avec d'autres personnes en perdant volontairement dans l'objectif d'augmenter le classement de votre adversaire, ou en recevant des points d'elo d'un adversaire qui cherche à perdre pour augmenter votre classement. Ces situations abusent du système et créent des classements non représentatifs de la réalité." ] cleanliness_header = "Respect" cleanliness_rules = [ "Vous devez rester respecteux sur tout le site, pas de vulgarité ou d'injures. Vous n'êtes pas autorisés à insulter, à harceler ou à menacer quiconque, ou à commettre un quelconque acte illégal. Vous n'êtes pas autorisé à spammer d'autres utilisateurs ou d'autres forums.", "Vous n'êtes pas autorisé à uploader des images qui peuvent être inappropriés, suggestives ou gores sur votre profil. Le faire pourrait résulter en un bannissement ou en une suppression de votre compte." ] privacy_header = "Confidentialité" privacy_rules = [ "Pour l'instant la seule information personnelle que nous collectons est l'adresse mail. Elle nous permet de vérifier les comptes des utilisateurs et nous donne un moyen de prouver qui ils sont lorsqu'ils font une demande de changement de mot de passe. Nous n'envoyons pas de mails ou d'offres promotionelles et nous ne partageons pas vos adresses email avec des tiers.", "InfiniteChess.org peut collecter des données sur votre utilisation du site comme votre adresse IP. Cette collecte nous aide à empêcher les attaques automatisées ou autres connexions néfastes, et nous permet de collecter des statistiques précises sur l'utilisation du site. Votre adresse IP n'est PAS votre adresse réelle.", "Toutes les parties que vous jouez sur le site sont publiques. Si vous souhaitez rester anynome, ne partagez pas votre nom d'utilisateur avec vos amis ou votre famille. Si c'est ce que vous souhaitez, il est de votre responsabilité de vous assurer que personne ne trouve de lien entre votre nom d'utilisateur et votre identité réelle.", "Le status d'activité de votre compte et la durée approximative depuis votre dernière connexion sur le site sont également publiques.", ["Bien qu'InfiniteChess.org fasse tout son possible pour garder toutes les données personnelles et les comptes sécurisés, dans le cas d'un hack ou d'une fuite de donnée, vous ne pourrez pas nous en tenir responsable. Si une fuite de donnée a lieu, les utilisateurs en seront informés sur la page ", "Actualités", "."], "Il n'y pas d'achats intégrés au site. Toute information personnelle non précisée dans cette liste n'est pas collectée.", "Pour avoir toutes vos informations personnelles supprimées de nos serveurs, vous pouvez supprimer votre compte depuis votre page de profil. La seule chose en lien avec votre nom d'utilisateur que nous ne supprimerons PAS, est votre historique de parties, car toutes les parties jouées sont publiques.", ] cookie_header = "Utilisation de Cookies" cookie_paragraphs = [ "Ce site utilise des cookies, qui sont des petits fichiers textes stockés dans votre navigateur, et envoyés au serveur quand vous vous connectez. Ces cookies servent à: vous authentifier, vérifier que votre navigateur est dans la bonne partie et stocker les préférences de votre compte pour que vous puissez les conserver entre deux visites. Le site n'utilise pas de cookies tierces, les cookies ne sont pas partagé avec des tiers.", "Les cookies sont nécessaires pour le bon fonctionnement du site. Si vous ne souhaitez pas que ce site stocke des cookies, vous devez arrêter de l'utiliser. Vous pouvez supprimer les cookies existants dans les paramètres de votre navigateur. En continuant d'utiliser ce site, vous acceptez l'utilisation de cookies." ] conclusion_header = "Conclusion" conclusion_paragraphs = [ "Toute violation de ces termes peut résulter en un bannissement ou en une suppression de votre compte. InfiniteChess.org a pour souhait de donner à tout le monde l'opportunité de jouer et de prendre du plaisir ! Mais, nous nous réservons le droit de bannir ou de supprimer des comptes, pour des raisons que nous ne sommes pas tenus de justifier ou de communiquer et ne ne pourrons légalement pas en être tenu responsables.", ["Ces termes peuvent être modifiés à tout moment. Il est de VOTRE responsabilité de vous tenir au courant des mises à jour de ces derniers ! Quand les conditions d'utilisations seront modifiées nous vous en informerons sur la page ", "Actualités", ". Si, vous n'adhérez plus aux conditions après une mise à jour de ces dernières vous devez immédiatement quitter le site. Vous pouvez supprimer votre compte depuis votre profil. Si vous supprimez votre compte, toutes vos informations personnelles et les données qui vont concernent seront supprimées SAUF l'historique de vos parties, qui sont associées à votre nom d'utilisateur. Car ces parties sont publiques."], ["Ce site est open-source. Vous pouvez récuperer ou distribuer toutes les ressources qui s'y trouvent tant que vos respectez les conditions décrites dans les ", "licences", " ! Si le lien ci dessus n'est pas fonctionnel il est de votre responsabilité de trouver les licences."], "Nous ne garantissons pas que ce site sera en ligne 100% du temps. Nous ne garantissons pas non plus que les données ne seront jamais corrompues.", "Vous n'êtes pas autorisé à performer des activités illégales sur ce site.", ["Si vous avez des questions concernant ces conditions, ou à propos du site de manière générale,", "envoyez nous un email !"] ] update = "(Dernière mise à jour: 13 Juillet 2024. Ajout de l'avertissement sur le fait que toutes les parties jouées sur le site sont des informations publiques, ce qui inclut une approximation de la durée depuis la dernière activité de votre compte. Ces conditions peuvent être changées à tout instant et il est de votre responsabilité de vous assurez que vous restez à jour.)" thanks = "Merci !" [login] title = "Se Connecter" username = "Nom d'utilisateur:" password = "Mot de Passe:" forgot_password = ["Mot de passe oublié ? ", "Envoyez nous un mail."] login_button = "Se connecter" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Des paramètres invalides ont été reçus." 409_message = ["Il y a peut être eu un conflit de nom d'utilisateur ou d'email. ", "Rafraichissez", " la page s'il vous plaît."] 500_message = "Ceci n'est pas censé arriver. Du debugage doit être fait !" ########### NEWS ########### [news] title = "Actualités" more_dev_logs = ["Plus de devlogs sont publiés sur le ", "discord officiel", ", et sur les ", "forums de chess.com"] [server.javascript] ws-invalid_username = "Nom d'utilisateur invalide" ws-incorrect_password = "Mot de passe incorrect" ws-username_and_password_required = "Le nom d'utilisateur et le mot de passe sont requis." ws-username_and_password_string = "Le nom d'utilisateur et le mot de passe doivent être des chaines de caractères." ws-login_failure_retry_in = "Authentification impossible, réessayez dans" ws-seconds = "secondes" ws-second = "seconde" ws-username_length = "Le nom d'utilisateur doit faire entre 3 et 20 caractères" ws-username_letters = "Le nom d'utilisateur ne doit contenir que des lettres et des chiffres" ws-username_taken = "Ce nom d'utilisateur est déjà pris" ws-username_bad_word = "Ce nom d'utilisateur contient un mot interdit" ws-username_reserved = "Ce nom d'utilisateur est réservé" ws-email_too_long = "Votre email est trop looooong." ws-email_invalid = "Votre email n'est pas valide" ws-email_in_use = "Cette adresse est déjà utilisée" ws-you_are_banned = "Vous êtes banni." ws-password_length = "Le mot de passe doit fair entre 6 et 72 caractères" ws-password_format = "Le mot de passe n'est pas dans le bon format" ws-password_password = "Le mot de passe doit être différent de 'password'" ws-refresh_token_not_found_logged_out = "Aucun utilisateur n'a ce token de rafraichissement (déjà déconnecté)" ws-refresh_token_not_found = "Aucun utilisateur n'a ce token de rafraichissement" ws-refresh_token_expired = "Pas de token de rafraichissement trouvé (session expirée)" ws-refresh_token_invalid = "Le token de rafraichissement a expiré ou a été altéré" ws-member_not_found = "Utilisateur introuvable" ws-forbidden_wrong_account = "Interdit. Ceci n'est pas votre compte." ws-deleting_account_not_found = "Erreur lors de la suppression du compte. Le compte est introuvable." ws-server_error = "Désolé, une erreur de serveur s'est produite ! Revenez en arrière s'il vous plaît." ws-unable_to_identify_client_ip = "L'adresse ip du client n'a pas pu être identifiée" ws-you_are_banned_by_server = "Vous êtes banni" ws-too_many_requests_to_server = "Trop de requêtes. Réessayez dans quelques minutes." ws-bad_request = "Mauvaise Requête" ws-not_found = "404 Introuvable" ws-forbidden = "Interdit." ws-unauthorized_patron_page = "Non autorisé. Cette page est réservé aux patreons." ws-already_in_game = "Vous êtes déjà dans une partie." ws-server_restarting = "Ce serveur redémarre dans" ws-server_under_maintenance = "Le serveur redémarre bientôt." # Can be changed at will to change the display message. ws-minutes = "minutes" # unit of time ws-minute = "minute" # unit of time ws-no_abort_game_over = "Vous ne pouvez pas annuler la partie, elle est déjà finie." ws-no_abort_after_moves = "Vous ne pouvez pas annuler la partie, plus de 2 coups ont déjà été joués." ws-game_aborted_cheating = "Annulation de la partie à cause d'une forte probabilité de triche." ws-cannot_resign_finished_game = "Vous ne pouvez pas abandonner, la partie est déjà finie." ws-invalid_code = "Code invalide !" # Invite code doesn't match any existing invites ws-game_aborted = "Partie annulée." # Invite was cancelled as you clicked on it ================================================ FILE: translation/news/en-US/2024-01-29.md ================================================ New video released today! ================================================ FILE: translation/news/en-US/2024-05-14.md ================================================ Update 1.3 released today! This includes MANY new speed and user experience improvements. Just a few are: - The transition to websockets, decreasing the delay when your opponent moves. - No longer getting disconnected when you switch tabs. - Audible cues when you or someone else creates an invite, or makes a move. - Added the 50-move rule. - A drum countdown effect is now played at 10 seconds left on the clock. - An auto-resignation timer will start if you're opponent goes AFK (with an audible warning). And many others! For the full list, check out [the discord!](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997) ================================================ FILE: translation/news/en-US/2024-05-24.md ================================================ Update 1.3.1 released! This includes the guide, pop-up tooltips when hovering over the navigation buttons, and links to the discord and game credits on the title page! ================================================ FILE: translation/news/en-US/2024-05-27.md ================================================ 1.3.2: Added showcase variants for Omega^3 and Omega^4 that were shown in my latest video. Also, the checkmate algorithm is now compatible with multiple kings per side. ================================================ FILE: translation/news/en-US/2024-07-09.md ================================================ Infinite Chess is Now Open Source! See, and contribute, to the project [on GitHub!](https://github.com/Infinite-Chess/infinitechess.org) ================================================ FILE: translation/news/en-US/2024-07-13.md ================================================ The [Terms of Service](https://www.infinitechess.org/termsofservice) have been updated. Changes made: All games you play on the website may become public information, including the approximate time your account was last active. The terms may be updated at any time, and it is your responsibility to make sure you're up-to-date on them. Your game history may become available on your profile at a future time. ================================================ FILE: translation/news/en-US/2024-07-22.md ================================================ If you have not verified your account, please do so on your profile page! All unverified accounts will soon be deleted!! ================================================ FILE: translation/news/en-US/2024-08-01.md ================================================ Update 1.4 is released! There have been many collaborative features added since we open sourced! - Knightriders have been added, which hop infinitely like a knight until they're obstructed! The 'Knighted Chess' variant has been upgraded to replace the knights with knightriders! - Click your or your opponent's pieces at any time to view their possible moves! - Right-click at any time to deselect the currently selected piece. - Hovering over arrow indicators on the edge of the screen now renders the legal moves of the piece they are pointing to! - The game now automatically declares a draw if there's insufficient material on the board to force checkmate. - Translated the website into French! You can change the language by visiting the footer on any page. - Improved the loading time of the website. - New website icon, Ω! This automatically matches your preferred light or dark device theme. - The game code's, or the ICN's, metadata has been reformatted to more closely match PGN norms. - Users can now delete their account on their profile page, if they so choose, without having to email us. ================================================ FILE: translation/news/en-US/2024-09-11.md ================================================ The first-ever Infinite Chess tournament is now open for sign-ups!!! It will be played on the Classical variant, and the time control will be 10m+6s (this will be added soon). The winner will be given a special flare and/or role on the [community discord](https://discord.gg/NFWFGZeNh5)! Here's the [sign-up form](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! The deadline to sign up is **Friday, Sept 27th!** The full rules are located [here](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). For future updates about the tournament, join the [discord](https://discord.gg/NFWFGZeNh5)! **Update v.1.4.1 has been released!** - Draw offers have been added! Find the offer draw button in the pause menu! - Added languages for the following: Chinese, Polish, Portuguese! - Fixed bug where spamming the Create Invite button gave you messages such as you already have an invite, or you can't accept your own invite. ================================================ FILE: translation/news/en-US/2024-11-22.md ================================================ ## Themes Update - v1.5 - Released! - Adjust the color of the board from a wide variety of options inside the new settings dropdown menu! - Choose whether legal moves are represented by dots or squares! - On desktop, adjust your perspective-mode mouse sensitivity and field of view! - The settings dropdown also includes a ping meter, so you can tell how fast your connection is! - The language selection has been moved from the footer to the settings dropdown. - Preferences are saved both on the browser, and on the server, so it will remember them wherever you go! - Completely redesigned the header bar! Added the Infinite Chess logo, vector graphics for each link, the settings gear dropdown. Also, there's no more horizontal scrolling needed on mobile, because the links adapt to the available space! - The board now retains momentum when you throw it with the mouse or your finger! - Login sessions are now automatically renewed when you reconnect 1 day after the previous renewal! No more abruptly being logged out when you are in the middle of a game. - Clocks now match exactly what the server says, subtract half your ping, instead of going off of your system clock, which may or may not be out of sync with the server machine's system clock. This was the cause of a bug displaying incorrect clock values. - Migrated members' account storage to a SQLite Database system. - Each member has been given a unique identifier. This cannot be changed, and cannot be reused when the account is deleted. Now, even when players change their name, their id will forever point to the same account. Game notation now includes the id of each player. ================================================ FILE: translation/news/en-US/2025-03-12.md ================================================ ## Tournament announcement! We are now opening the signups for the second ever Infinite Chess tournament since the creation of infinitechess.org, and the first tournament played on the “Chess on an Infinite Plane” starting position! The time control will be 10m+6s and the format will consist of an initial group stage and a subsequent elimination stage, with games being played on a flexible schedule at a roughly weekly basis. The winner will be given a special unique role on the [community discord server](https://discord.gg/NFWFGZeNh5)! [Here's the sign-up form](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog). The deadline to sign up is **Friday, April 4, 2025** and the tournament will begin on the following day. [The full rules are located here](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true). For future updates about the tournament, join the [discord](https://discord.gg/NFWFGZeNh5)! ================================================ FILE: translation/news/en-US/2025-03-17.md ================================================ # Big Update Release - Infinite Chess 1.6! We're excited to announce the release of an update that's been long in progress! The main focuses are a new **checkmate practice mode**, the ability to **drag pieces**, and new **variants**! ## Practice Mode - There's a new **Practice** menu on the title screen. Play against an engine to practice your checkmating skills against a lone king in lots of endgame configurations! Can you master every single checkmate known? - Earn radiant badges the more practice checkmates you complete! The highest badge you earn is displayed on your profile page for visitors to see. ## Dragging Pieces - Pieces may now be dragged to move them. This is toggleable in the settings menu. Holding control will force drag the board instead of a piece. - Drag pieces onto arrow indicators on the edge of the screen to capture the piece the arrow is pointing to, if it is legal! ## Variants - Piece movements and behavior are now highly customizable per variant. This has opened the door for new and exotic variants below. In addition, compatibility has been added for 4 dimensional variants of any size/depth/space! - New: **Confined Classical** by tsevasa. A wall of obstacles covers your rear, offering some protection against overpowered flank attacks, without inflating the number of pawns in the game. - New: **Chess on an Infinite Plane - Huygens Option** by V. Reinhart. This features a new piece, the Huygen. The Huygen moves in the same direction as rooks, except it is a _prime_ rider, meaning it only skips on squares which are a prime distance from its starting location. This has interesting mathematical implications on the infinite chessboard. Can you master its movement and dominate your opponents? - New: **4x4x4x4 Chess** by tsevasa. In this 4 dimensional variant, all pieces have gained the ability to jump across boards in different dimensions! The queen's movement imitates the princess, and the pawn's movement imitate the brawn, which are both found in 5D Chess with Multiverse Time Travel. - New: **5D Chess** by Jace. 64 squares. 64 boards. Chessboard inception! This variant was designed to be a reflection of 5D Chess with Multiverse Time Travel. Move interdimensionally across space and time to other worldly boards! The checkmate algorithm is disabled in this one, be careful not to step into check! The game ends when just one of the many kings are captured. - Deleted: Amazon Chandelier, Containment, Classical - Limit 7, and CoaIP - Limit 7. These were among the least played. ## Other additions - Arrow indicators pointing to pieces off-screen are now animated with the moves. The mini images visible of the pieces when you are zoomed out are also now animated! - Added compatibility with the **Royal Queen**, and **Rose** piece. The Royal Queen is similar to a queen, but it will lose you the game if it is checkmated. The Rose piece behaves like a knightrider that leaps in circles. No variant features these yet (people are welcome to submit variant suggestions!). - All contributors to the infinitechess.org source code are now listed on the homepage. Thank you all! - Fixed the auto-aborting for "cheating" that ocurrs when you move a piece a distance of 1e21 or greater in an online game. You can now move as far in online games as you can in local games! Although you will still experience graphical glitches, those will be patched later. - Added a spinny-pawn animation while each game loads. - Your coordinates are now editable in local games. - Several other user experience improvements and bug fixes. Too many to list here! ================================================ FILE: translation/news/en-US/2025-05-21.md ================================================ ### Updated the [Terms of Service](https://www.infinitechess.org/termsofservice). Players may not abuse bugs or glitches in order to abort the game, play otherwise illegal moves, give you an advantantage, or make the game otherwise unplayable. ================================================ FILE: translation/news/en-US/2025-06-16.md ================================================ # Infinite Chess Update 1.7! ## Ranked + Leaderboard - Users can now choose to play rated games from the lobby! Win games to boost your own rating! Get estimates on your opponent's skill level. - Added a leaderboard page. How high can you climb? New players aren't immediately displayed on the leaderboard, only after their approximately first four rated games. - Updated username containers to display their rating, and hyperlink to their profile. Usernames are also now visible on mobile. ## Annotations - Right-click the board to highlight squares, and right-click-drag to draw arrows! These can be used to help you analyze positions, or for streamers to show their chat what moves they're thinking of! - Double-right-click-drag to draw _rays_, which are an infinite line of square highlights in one direction. These can be used to quickly and efficiently line up long-distance attacks, without having to perform mental math to calculate what squares you need to land on. - Left click to collapse annotations. By default, this erases all square and arrow highlights, but if you have any drawn rays, then instead square highlights are added at all ray intersection points, and all rays are erased. - When zoomed out, annotations are rendered the same size as the pieces, and squares and rays can be clicked to automatically zoom into them. - Added a lingering annotations toggle in the settings dropdown. When enabled, selecting pieces will not automatically erase your existing annotations. This allows your annotations to persist from move to move, allowing you to remember key squares or line up attacks beforehand. You can still erase annotations by clicking an empty region of the board. - Mobile users have a new annotations button on the navigation bar, which when enabled will treat all touches as their right-click counterpart, allowing you to draw annotations without requiring a mouse. This means mobile users are not disadvantaged against pc users by not being able to draw rays to help line up long distance attacks. ## Snapping - When you are zoomed out, and you hover the mouse over any legal move line or ray, your mouse will snap to points on that line that cardinally intersect with other pieces, or annotations, and clicking will immediately zoom you into that point! This makes it quick and easy to line up attacks without having to meticulously find the exact square you need. ## Other additions - Added a piece-animations toggle in the settings dropdown. When disabled, pieces instantly teleport from their start square to their end square. - Mini images, even when they are disabled in large variants, now _always_ render pieces above square highlights, and the piece last moved. This is useful for keeping track of important pieces while zoomed out in large showcase variants. - Preset rays and squares have been added to the Omega^2 showcase to emphasize the important lines and squares for the main line of play. These are permanent and cannot be erased. Game notation also now supports preset squares and rays. - Holding alt and left clicking can be used to simulate a right click. - Added an automated password reset system on the login page. ================================================ FILE: translation/news/en-US/2025-11-28.md ================================================ # New video released + Infinite Chess Update 1.8! ## Infinite Board - The size of the board has been HUGELY increased! Removed soft zoom limits, allowing players to move much, much further, without experiencing glitches! - Added special board effects when you travel to extreme distances from the origin, amplifying a feeling of peeling back layers of reality. How far can you travel? - Added ambiences over 1,000 squares away from the origin. - Added a screen shake effect for large moves. - Added a new move sound and visual effects when moving extremely far. - Added a world border in Checkmate Practice games, and in the following variants: Obstocean, 4x4x4x4 Chess, 5D Chess, and Chess. Engines are not capable of infinite move distance, so this prevents them from breaking this update. - Added a Starfield effect inside VOID. - Added a Sound dropdown menu in the settings. Control the master volume of the game, and toggle on and off ambiences. - Added two toggles in the Appearance (renamed from Board) dropdown menu in the settings to toggle the Starfield or Advanced Board Effects. ## Premoves - Added premoving! Move your piece while it's your opponent's turn to register it to be auto-submitted as soon as it's your turn again, assuming legality. - Disable premoves in the settings. ## Other - Added new variants: Chess on an Infinite Plane - Roses Option, Chess on an Infinite Plane - Knightriders Option, and Palace. These feature the Rose (NEW), Knightrider, and Amazon! The Rose piece moves like a circular knightrider, turning 45 degrees after each jump. Also, deleted the Knighted Chess variant. - Added news post notifications. A red bubble appears next to the News hyperlink when you are logged in and have unread news posts. New news posts also have the "NEW" tag next to their date. - Patched the reverb effect on large moves commonly being abruptly cut off. ================================================ FILE: translation/news/en-US/2026-01-08.md ================================================ # Infinite Chess Update 1.9! ## Computer Games - Practice anytime by playing against a strong engine in various variants, time control, and difficulty. Created by FirePlank. See the source code [here](https://github.com/FirePlank/infinite-chess-engine)! ================================================ FILE: translation/news/en-US/2026-03-09.md ================================================ # Infinite Chess Update 1.10 - Board Editor! The highly requested board editor is finally here! ## Board Editor - Place, move, and erase pieces freely to create new positions. - Selection tool gives you powerful ways to manipulate large groups of pieces at once, inspired by spreadsheet software. - Fully configurable gamerules. - Start a local or engine game directly from the editor. - Save and load positions in your browser or to the cloud (requires login). ================================================ FILE: translation/news/en-US/2026-04-24.md ================================================ # Infinite Chess Update 1.10.1! ## Arrow Dragging - Click and drag an arrow pointing to one of your own off-screen pieces to move it directly, without needing to pan or zoom to it first! This is the complement to the existing feature of dragging your pieces onto arrows pointing to off-screen opponent pieces to capture them if legal. ## Board Coordinates - Added a toggle in the settings to show rank and file coordinate labels along the edges of the board! This mostly benefits the sharing of puzzles, as previous board screenshots had no indication of piece coordinates. ================================================ FILE: translation/news/es-ES/2024-01-29.md ================================================ ¡Nuevo vídeo publicado hoy! ================================================ FILE: translation/news/es-ES/2024-05-14.md ================================================ ¡La actualización 1.3 ha sido publicada hoy! Esto incluye muchas mejoras relacionadas con la velocidad y experiencia de usuario, como: - Transicionado a websockets, reduciendo el delay cuando tu oponente mueve. - Ya no te desconectamos cuando cambias de pestaña. - Sonidos cuando alguien crea una invitación, o hace un movimiento. - Añadida la regla de los 50 movimientos. - Un sonido de tambor sonará cuando queden 10 segundos. - Un timer de auto-resignación comenzará si tu oponente se va AFK (Con una advertencia sonora). ¡Y muchas más! ¡La lista completa está en [el discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)! ================================================ FILE: translation/news/es-ES/2024-05-24.md ================================================ ¡Se ha publicado la actualización 1.3.1! Esto incluye la guía, notas pop-up al poner el ratón encima de los botones de navegación, ¡Y links al discord y los créditos en la página principal! ================================================ FILE: translation/news/es-ES/2024-05-27.md ================================================ 1.3.2: Se ha añadido una posición de ejemplo para Omega^3 y Omega^4 que aparecen en mi último video. También, el algoritmo de jaque mate es ahora compatible con varios reyes de cada color. ================================================ FILE: translation/news/es-ES/2024-07-09.md ================================================ ¡Infinite Chess es ahora de Código Abierto! mira, y contribuye, al proyecto ¡[En GitHub](https://github.com/Infinite-Chess/infinitechess.org)! ================================================ FILE: translation/news/es-ES/2024-07-13.md ================================================ Los [Terminos de Servicio](https://www.infinitechess.org/termsofservice) han sido actualizados. Los cambios son: Todas las partidas que juegues en la web se convertirán en información pública, incluyendo aproximadamente la última vez que estuviste activo. Los términos podrán ser actualizados en cualquier momento, y es tu responsabilidad asegurare de que estás actualizado. Tu historia de partidas puede volverse disponible en tu perfil en cualquier momento. ================================================ FILE: translation/news/es-ES/2024-07-22.md ================================================ Si no has verificado tu cuenta, ¡Hazlo en tu página de perfil! ¡¡Todas las cuentas sin verificar serán eliminadas!! ================================================ FILE: translation/news/es-ES/2024-08-01.md ================================================ ¡La actualización 1.4 ha sido publicada! ¡Se han añadido muchas funcionalidades colaborativas desde que nos convertimos a código abierto! - ¡Se han añadido los Jinetes, que se mueven infinitamente como un caballo hasta que son obstruidos!¡La variante 'Knighted Chess' ha sido actualizada para que use jinetes en vez de caballos. - ¡Haz clic en las piezas de tu oponente en cualquier momento para ver su movimientos legales! - Haz clic derecho en cualquier momento para deseleccionar la pieza seleccionada. - ¡Pasar el ratón por encima de la flechas del borde de la pantalla ahora muestra los movimientos legales de esa pieza! - El juego ahora declara tablas automáticamente si no hay material suficiente en el tablero para forzar el jaque mate. - ¡Se ha traducido la página al francés! Puedes cambiar el idioma en el rodapié de cualquier página. - Se ha mejorado el tiempo de carga de la web. - ¡Nuevo icono de página, Ω! Este cambia automáticamente para adecuarse a tu tema preferido,claro o oscuro. - Los metadatos de los códigos de partida, o ICN's, ha sido reformateado para ajustarse mas a las normas de PGN. - Los usuarios ahora pueden eliminar su cuenta en sus páginas de perfil, si así lo desean, sin tener que mandarnos un correo. ================================================ FILE: translation/news/es-ES/2024-09-11.md ================================================ ¡¡¡El primer torneo de Infinite Chess está abierto para anotarse!!! ¡Se jugará en la variante Clásica, y el reloj estará en 10m+6s (Se añadirá pronto). El ganador recibirá un rol especial en el [discord de la comunidad](https://discord.gg/NFWFGZeNh5)! ¡Aquí está el [formulario de registración](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! ¡La fecha límite para apuntarse es el **Viernes, 27 de Septiembre!** Las bases completas están [aquí](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). ¡Para saber más sobre el torneo, únete al [discord](https://discord.gg/NFWFGZeNh5)! **Se ha publicado la actualización v1.4.1!** - ¡Se han añadido las ofertas de tablas!¡ Encontrarás el botón en el menú de pausa! - ¡Se han añadido idiomas para los siguientes: Chino, Polaco y Portugués! - Arreglado un bug en el que al spammear el botón de crear invitación, saltaban mensajes como ya tienes una invitación o no puedes aceptar tu propia invitación. ================================================ FILE: translation/news/es-ES/2024-11-22.md ================================================ ## v1.5 - ¡Actualización de Temas! - ¡Ajusta el color de el tablero a una gran variedad de opciones en el nuevo menú de opciones! - ¡Escoge si quieres que los movimientos legales se representen con puntos o casillas! - ¡En sistemas de escritorio, ajusta la sensibilidad de tu ratón y campo de visión (FOV) de el modo perspectiva! - ¡El menú de opciones también contiene un medidor de latencia (ping), para saber como de rápida es tu conexión! - El menú de selección de Idioma ahora se encuentra en el menú de opciones. - ¡Las opciones se guardan tanto en tu navegador como en el servidor, así que siempre las recordaremos allá donde vallas (Y hayas iniciado sesión)! - ¡Hemos rediseñado completamente la cabecera! Hemos añadido el logo de Infinite Chess, Iconos para cada link y el menú de opciones. Además, ya no es necesario hacer scroll horizontal en dispositivos móviles, ¡Porque la página se ajusta automáticamente! - ¡El tablero ahora tiene inercia al moverlo con el ratón o tu dedo! - ¡Las sesiones de inicio ahora se renuevan automáticamente un día después de la sesión! Ya no se cerrará tu sesión en medio de una partida. - Los relojes ahora se ajustan exactamente a lo que dice el servidor, menos la mitad de tu ping, en vez de basarse en tu reloj local, que puede estar desincronizado con el del servidor. Esto causaba un error que mostraba valores del reloj incorrectos. - Hemos migrado el almacenamiento de las cuentas de usuario a una base de datos SQLite. - Se le ha dado a cada miembro un identificador único. Este no puede cambiar, y no puede ser reutilizado cuando se elimina la cuenta. Ahora, aunque los usuarios cambien su nombre, su id hará referencia a la misma cuenta. La notación de juego ahora incluya el id de cada jugador. ================================================ FILE: translation/news/es-ES/2025-03-12.md ================================================ ## ¡Torneo de InfiniteChess! Estamos abriendo la inscripción del segundo torneo organizado desde la creación de infinitechess.org, ¡Y el primer torneo jugado en la variante "Ajedrez en un plano infinito"! El reloj estará en 10m+6s, y el formato consistirá en una fase inicial de grupos y una fase de eliminación posterior, con partidas organizadas en un horario flexible, más o menos cada semana. El ganadaor recibirá un rol especial en el [servidor de Discord de la comunidad](https://discord.gg/NFWFGZeNh5)! [Este](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog) es el formulario de inscripción. La fecha límite para anotarse es el **Viernes, 4 de Abril del 2025**, y el torneo comenzará al día siguiente. Las bases completas se encuentran [aquí](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true). ¡Para saber maś sobre el torneo, únete al [discord](https://discord.gg/NFWFGZeNh5)! ================================================ FILE: translation/news/es-ES/2025-03-17.md ================================================ # ¡Nueva actualización - Infinite Chess 1.6! ¡Estamos encantados de anunciar el lanzamiento de una nueva actualización en la que llevamos mucho tiempo trabajando! Los puntos principales son: Un nuevo modo de **práctica de jaque mate**, la habilidad de **arrastrar piezas**, y **nuevas variantes** ## Modo Práctica - Hay un nuevo menú de **Práctica** en la página principal. ¡Juega contra el ordenador para practicar tus habilidades de jaque mate contra un solo rey! ¿Puedes conquistar todos los mates conocidos? - ¡Consigue brillantes insignias cuantos más mates completes! La insignia de mayor valor que obtengas sera mostrada en tu página de perfíl. ## Arrastrar piezas - Ahora las piezas pueden ser arrastradas para moverlas. Esto se puede activar o desactivar en el menu de opciones. Mantener control fuerza que se mueva el tablero, en vez de una pieza. - Arrastra las piezas a los indicadores de las flechas del borde de la pantalla para capturar esa pieza, ¡Si es legal! ## Variantes - Los movimientos de las piezas y su comportamiento ahora son altamente personalizables. Esto ha abierto las puertas a nuevas y exóticas variantes, como las que veremos a continuación. ¡Adicionalmente, se han implementado variantes de 4D de cualquier tamaño/profundidad/espacio! - Nueva: **Clasica confinada** por tsevasa. Un muro de obstaculos cubren tu parte trasera, ofreciendo algo de protección contra ataques de flanco, sin incrementar el número de peones en juego. - Nueva: **Ajedrez en un plano infinito - Opciónes Huygens** por V. Reinhart. Esta variante contiene una nueva pieza, el Huygen. El Huygen se mueve en la misma dirección que las torres, excepto que solo cae en casillas que estén a una distancia prima del origen. Esto tiene implicaciones matemáticas interesantes en el tablero infinito. ¿Puedes dominar su movimiento y conquistar a tus oponentes? - Nueva: **Ajedrez 4x4x4x4** por tsevasa. En esta variante cuatridimensional, ¡Todas las piezas han ganado la habilidad de saltar a traves de los tableros en diferentes dimensiones! El movimiento de la dama imita al de la princesa, y el peon imita al brawn, que originan desde _"5D Chess with Multiverse Time Travel"_. - Nueva: **Ajedrez 5D** por Jace. 64 casillas. 64 tableros. Esta variante fue diseñada para ser un reflejo de _"5D Chess with Multiverse Time Travel."_ M¡uevete interdimensionalmente a través del espacio y del tiempo a tableros de otro mundo! El algoritmo de jaque mate está desactivado en esta variante, ¡Ten cuidado de no caer en jaque! La partida acaba cuando uno de los muchos reyes es capturado. - Eliminadas: Candelabro Amazona, Containment, Clasica - Límite 7, y AeuPI - Límite 7. Estas estaban entre las menos jugadas. ## Otros cambios - Los indicadores de flechas que apuntan a piezas fuera de plano estan ahora animados con los movimientos. ¡Las miniaturas que son visibles cuando haces zoom tabién estan animadas! - Implementadas las piezas de la **Dama Real** y la **Rosa**. La Dama Real es similar a una dama, pero perderás la partida si le hacen jaque mate. La rosa se comporta como un jinete que va en círculos. No hay ninguna variante que las incluya aún. (¿Las sugerencias son bienvenidas!) - Todos los contribuidores al código fuente de infinitechess.org ahora aparecen en la página principal. ¡Gracias a todos! - Arreglado el auto-aborte por "trampas" que ocurría cuando movías una pieza una distancia de 1e21 o mayor en una partida en linea. ¡Ahora puedes mover tan lejos como en las partidas locales! Puede que aún experiencies algunos errores visuales, esos serán arreglados más adelante. - Se ha añadido una animación de un peón girando mientras la posición se carga. - Tus coordenadas ahora son editables en las partidas locales. - Algunos otros arreglos de experiencia del usuario y de errores. ¡Demasiados para listar aquí! ================================================ FILE: translation/news/es-ES/2025-05-21.md ================================================ ### Se han actualizado los [Terminos del Servicio](https://www.infinitechess.org/termsofservice). Los jugadores no deben abusar de errores o problemas del juego para abortar la partida, jugar movimientos que de otro modo serían ilegales, conseguir una ventaja, o hacer que no sea posible jugar. ================================================ FILE: translation/news/es-ES/2025-06-16.md ================================================ # Actualización 1.7 de Infinite Chess! ## Partidas por puntos + Tabla de puntuación - ¡Los usuarios ahora pueden escojer jugar una partida por puntos en el menú! ¡Gana partidas para mejorar tu puntuación! Ahora puedes ver una estimación del nivel de tu rival. - Se ha añadido una página con la tabla de puntuación. ¿Como de alto puedes llegar? Los jugadores nuevos no aparecen de inmediato en la tabla, solo tras jugar aproximadamente cuatro partidas por puntos. - Se han actualizado los contenedores de nombre de usuario para que muestren su puntuación, y un link a su perfíl. Ahora también son visibles en dispositivos móviles. ## Anotaciones - ¡Haz clic derecho en el tablero para mostrar casillar, y arrastra para dibujar flechas! ¡Puedes utilizarlas para analizar posiciones, o para que los streamers puedan decirle a sus espectadores lo que están pensando! - Haz doble-clic-derecho y arrastra para dibujar _rayos_, que son una linea de cuadrados subrayados en una dirección. Estos pueden usarse para alinear las piezas as larga distancia, sin tener que calcular mentalmente el cuadrado de destino. - Haz clic izquierdo para ocultar las anotaciones. Por defecto, esto borra todas las flechas y los cuadrados subrayados, pero si has dibujado uno o más rayos, entonces todos los puntos en los que un rayo interseca con una pieza se añade un cuadrado subrayado, y se borran todos los rayos. - Cuando se hace zoom out, las anotaciones aparecerán del mismo tamaño que las piezas, y puedes clicar los cuadrados y los rayos para hacer zoom en ellos. - Se ha añadido una opción de anotaciones persistentes en el menú de opciones. Cuando se activa, seleccionar piezas no borrará tus anotaciones. Esto permite que tus anotaciones persistan entre jugadas, ayudando a hacer estrategias. Siempre puedes borrar las anotaciones haciendo clic en cualquier parte del tablero que esté vacía. - Los usuarios de dispositivos móviles ahora tienen un nuevo botón de anotaciones en la barra de navegación, que si está activado tratará todas las pulsaciones como si fuesen un (clic-derecho), permitiendo dibujar anotaciones sin un ratón. Esto ayuda a reducir la desventaja de los jugadores en dispositivos móviles ya que permite que dibujen rayos y que planeen ataques a larga distancia. ## Alineado automático - Cuando se hace zoom out, si pones el ratón encima de una linea de movimiento legal o un rayo, tu ratón se alineará a los puntos que coincidan con otras piezas, o anotaciones, ¡y hacer clic hará zoom en ese punto! Esto hace que sea facil alinear ataques sin tener que encontrar la casilla exacta. ## Otros cambios - Se ha añadido una opción para desactivar las animaciones de piezas e el menú de opciones. Cuando se activa, las piezas se mueven instantaneamente de una casilla a otra. - Las miniaturas, incluso cuando están desactivas en variantes grandes, _siempre_ se renderizan cuando la pieza está sobre una cuasilla subrayada, o sea la última pieza que ha sido movida. Este cámbio es útil para no perder las piezas importantes cuando hay muchas. - Se han añadido cuasillas subrayadas y rayos a la variante Omega^2 para enfatizar las lineas de juego importantes. Estas son permanentes y no se pueden borrar. La notación de partida ahora puede contener casillas subrayadas y rayos. - Mantener alt y hacer clic izquierdo puede usarse para simular un clic derecho. - Se ha añadido un sistema de reinicio de contraseña automático en la página de inicio de sesión. ================================================ FILE: translation/news/es-ES/2025-11-28.md ================================================ # ¡Nuevo vídeo publicado + Actualización 1.8 de Infinite Chess! ## Tablero Infinito - ¡El tamaño del tablero ha aumentado MUCHÍSIMO! ¡Se han eliminado los límites de zoom, permitiendo a los jugadores mover las piezas, mucho, muchísimo mas lejos sin experimentar errores! - Se han añadido efectos de tablero especiales cuando viajas a distancias extremas del origen, amplificando el sentimiento de desvelar las capas de la realidad. ¿Cuan lejos podrás llegar? - Se ha añadido ambiencia a más de 1000 casillas del origen. - Se ha añadido un efecto de temblor de pantalla para movimientos grandes. - Se han añadido un sonido de movimiento nuevo y efectos visuales cuando el jugador mueve extremadamente lejos. - Se ha añadido un borde de mundo en partidas de práctica de jaque mate, y en las siguientes variantes: Obstocean, Ajedrez 4x4x4x4, Ajedrez 5D, y Ajedrez. Los motores no son capaces de mover infinitamente, asi que esto previene que se rompan en esta actualización. - Se ha añadido un efecto de campo de estrellas dentro de VACÍO. - Se ha añadido un menu desplegable de sonido en las opciones. Controla el volumen general del juego, y habilita o deshabilita la ambientación. - Se han añadido dos controles al menu desplegable "Apariencia" (Renombrado de "Tablero") para habilitar o dehabilitar el campo de estrellas o los efectos de tablero avanzados. ## Premovimientos - ¡Ahora puedes hacer premovimientos! Mueve tus piezas mientras sea el turno de tu oponente para que se ejecute instantaneamente en tu próximo turno, asumiendo que el movimiento sea legal. - Puedes desactivar los premovimientos en las opciones. ## Otros - Nuevas variantes: Ajedrez en un plano infinito - Rosas, Ajedrez en un plano infinito - Jinetes, y Palacio. ¡Estas contienen la Rosa (NUEVA), Jinete y Amazona! La rosa se mueve como un jinete circular, girando 45 grados tras cada salto. También se ha eliminado la variante Ajedrez a caballo. - Se han añadido notificaciones de publicaciones de noticias. Una burbuja roja aparece al lado del link "Noticias" cuando has iniciado sesión y tienes publicaciones sin leer. Las nuevas publicaciones también tienen la nota "NUEVO" al lado de la fecha. - Se ha arreglado un error que hacía que el efecto de reverberación en movimientos grandes se cortase. ================================================ FILE: translation/news/fi-FI/2026-01-08.md ================================================ # Infinite Chess päivitys 1.9! ## Tietokonepelit - Harjoittele milloin vain pelaamalla todella hyvää tietokonetta vastaan monessa erilaisessa variantissa, aikakontrollilla, ja vaikeudessa. FirePlankin luoma. Katso lähdekoodi [täältä](https://github.com/FirePlank/infinite-chess-engine)! ================================================ FILE: translation/news/fi-FI/2026-03-09.md ================================================ # Infinite Chess päivitys 1.10 - Laudan muokkaaja! Kauan toivottu laudanmuokkain on vihdoin täällä! ## Laudan muokkaaja - Luo uusia asemia asettamalla, siirtämällä ja poistamalla nappuloita vapaasti. - Valintatyökalu tarjoaa tehokkaita tapoja käsitellä suuria nappularyhmiä kerralla taulukkolaskentaohjelmista tutulla tavalla. - Täysin muokattavat pelisäännöt. - Aloita paikallinen tai tietokonepeli suoraan muokkaimesta. - Tallenna ja lataa asemia selaimeesi tai pilveen (vaatii kirjautumisen). ================================================ FILE: translation/news/fi-FI/2026-04-24.md ================================================ # Infinite Chess päivitys 1.10.1! ## Nuolien siirtäminen - Valitse ja siirrä nuolta, joka osoittaa ruudun ulkopuolella olevaa nappulaa, ilman että kameraa tarvitsisi liikuttaa sen luokse! Tämä täydentää olemassa olevaa ominaisuutta, jossa nappuloitasi voi vetää nuolille, jotka osoittavat ruudun ulkopuolella oleviin vastustajan nappuloihin, kaapataksesi ne, jos se on sallittua. ## Laudan koordinaatit - Asetuksiin on lisätty valinta, jolla voi näyttää rivien ja sarakkeiden koordinaatit laudan reunoilla! Tämä hyödyttää erityisesti pulmien jakamista, sillä aiemmissa lautakuvissa ei ollut merkintöjä nappuloiden sijainneista. ================================================ FILE: translation/news/fr-FR/2024-01-29.md ================================================ Nouvelle vidéo sortie aujourd'hui ! ================================================ FILE: translation/news/fr-FR/2024-05-14.md ================================================ La version 1.3 est sortie aujourd'hui ! Elle comprend BEAUCOUP d'améliorations de vitesse et de l'expérience utilisateur. Dont notamment : - Le changement vers les websockets, diminuant le délai entre les coups. - La fin des déconnexions lorsque l'on change de fenêtre pendant une partie. - Des effets sonores lorsque vous ou quelqu'un d'autre crée une invitation ou joue un coup. - L'ajout de la règle des 50 coups. - Un compte à rebours sonore qui est joué quand il ne vous reste plus que 10 secondes à la pendule. - Un minuteur pour l'abandon automatique qui démarrera si votre adversaire s'en va (avec avertissement sonore). Et plein d'autres choses ! Pour la liste complète, rendez vous sur [le Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997) ! ================================================ FILE: translation/news/fr-FR/2024-05-24.md ================================================ La version 1.3.1 est sortie ! Elle comprend le guide, des info-bulles au survol des boutons de navigation ainsi que des liens vers le Discord et les crédits sur le menu du jeu ! ================================================ FILE: translation/news/fr-FR/2024-05-27.md ================================================ 1.3.2 : Ajout des variantes de showcase pour Omega^3 et Omega^4 qui ont été présentées dans la dernière vidéo. L'algorithme de mat est maintenant compatible avec plusieurs rois. ================================================ FILE: translation/news/fr-FR/2024-07-09.md ================================================ Infinite Chess est désormais Open Source ! Regardez, et contribuez au projet [sur le GitHub](https://github.com/Infinite-Chess/infinitechess.org) ! ================================================ FILE: translation/news/fr-FR/2024-07-13.md ================================================ Les [Conditions d'Utilisation](https://www.infinitechess.org/termsofservice) ont été mises à jour. Changements : Toutes les parties que vous jouez sur le site peuvent devenir publiques, et il en va de même pour la durée approximative depuis votre dernière activité. Les conditions d'utilisation peuvent être modifiées à tout instant, et il est de votre responsabilité de vous assurer que vous êtes à jour sur ces dernières. L'historique de vos parties pourrait être accessible depuis votre profil dans le futur. ================================================ FILE: translation/news/fr-FR/2024-07-22.md ================================================ Si vous n'avez pas vérifié votre compte, faites-le dès maintenant depuis votre profil s'il vous plaît ! Tous les comptes non vérifiés seront bientôt supprimés !! ================================================ FILE: translation/news/fr-FR/2024-08-01.md ================================================ La mise à jour 1.4 est sortie ! Il y a eu de nombreuses fonctionnalités ajoutées par la communauté depuis que nous sommes devenus Open Source ! - Les Cavaliers Sauteurs ont été ajoutés, ils sautent comme un cavalier mais jusqu'à l'infini, tant que leur chemin n'est pas bloqué ! La variante « Knighted Chess » a été modifiée pour remplacer les cavaliers par les cavaliers sauteurs ! - Vous pouvez désormais cliquer sur les pièces de votre adversaire à n'importe quel moment pour voir tous les mouvements qu'elles peuvent faire ! - Vous pouvez maintenant faire un clic droit pour désélectionner une pièce. - Survoler les flèches sur les bords de l'écran avec le curseur affiche désormais les déplacements de la pièce vers laquelle la flèche pointe ! - La partie sera automatiquement déclarée nulle lorsqu'il n'y a pas assez de matériel sur le plateau pour forcer un mat. - Le site a été entièrement traduit en français ! Vous pouvez changer la langue en bas de chaque page. - Les temps de chargement ont été améliorés sur le site. - Le site a une nouvelle icône: Ω ! Elle s'adapte automatiquement au mode clair ou sombre. - Les métadonnées des codes ICN ont été reformatées pour se rapprocher des standards PGN. - Les utilisateurs peuvent désormais supprimer leur compte depuis leur profil s'ils le souhaitent, sans avoir à nous envoyer de mail. ================================================ FILE: translation/news/fr-FR/2024-09-11.md ================================================ Le premier tournoi d'Échecs Infinis est maintenant ouvert aux inscriptions !!! Il sera joué sur la variante classique, et l'horloge sera sur 10m+6s. Le gagnant aura un badge spécial et/ou un rôle dans la communauté [Discord](https://discord.gg/NFWFGZeNh5) ! [Pour s'inscrire (en anglais)](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform). La date limite d'inscription est **le 27 septembre**! Toutes les règles sont [ici](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). Pour les futures informations concernant le tournoi (en anglais), rejoignez le [Discord](https://discord.gg/NFWFGZeNh5)! **La mise à jour 1.4.1 est sortie !** - On peut proposer nulle ! Le bouton est dans le menu pause ! - Trois nouvelles langues ont été rajoutées : Le chinois, le polonais, et le portugais ! - Correction de bug où cliquer plusieurs fois le bouton "Créer une invitation" affiche des messages disant que vous en avez déjà créé une, ou que vous ne pouvez pas accepter votre propre invitation. ================================================ FILE: translation/news/fr-FR/2024-11-22.md ================================================ ## Mise à jour des thèmes (v1.5) sortie ! - La couleur du plateau peut désormais être ajustée avec beaucoup d'options via le nouveau menu déroulant de paramètres ! - Vous pouvez désormais choisir si les coups légaux sont représentés par des points ou des carrés ! - Sur PC, il est désormais possible d'ajuster la sensibilité de la souris ainsi que le champ de vision. - La latence est maintenant affichée dans les paramètres, ce qui vous permet de connaître la vitesse de votre connexion ! - La langue a été déplacé du bas de des pages aux paramètres. - Les préférences sont enregistrées dans le navigateur et sur le serveur, de sorte qu'elles restent sauvegardées en permanence, où que vous soyez ! - Le design de l'en-tête a été complètement refait ! Le logo des Échecs Infinis, des vecteurs graphiques pour chaque lien et l'engrenage des paramètres y ont été ajoutés. De plus, sur mobile, il n'est plus nécessaire de faire défiler l'écran horizontalement, car les liens s'adaptent à la taille de l'écran ! - L'échiquier garde maintenant de l'élan lorsque vous le lancez avec la souris ou avec le doigt ! - Les sessions de connexion sont automatiquement renouvelées lorsque vous vous reconnectez le lendemain d'une précédente renouvellement ! Vous ne serez plus déconnecté au milieu d'une partie. - À présent, le temps affiché par la pendule correspond exactement au temps du serveur, en soustrayant la moitié de votre ping, au lieu de regarder l'horloge de l'appareil, qui peut ne pas avoir la même heure que celle du serveur. C'était la cause d'un bug qui faisait s'afficher des valeurs incorrectes à la pendule. - Le stockage des comptes des membres a été migré vers un système de bases de donnée SQLite. - Chaque membre a maintenant un identifiant unique. Cet identifiant est inchangeable, il ne peut pas être réutilisé après la suppression d'un compte. Même en cas de changement de pseudonyme, l'identifiant indique le même compte. La notation des parties incluront également les identifiants des joueurs. ================================================ FILE: translation/news/fr-FR/2025-03-12.md ================================================ ## Arrivée d'un tournoi ! Nous lançons désormais le deuxième tournoi des Échecs Infinis depuis la création d'infinitechess.org, et le premier joué dans la variante _Chess on an Infinite Plane_ (CoaIP, les "Échecs sur un Plan Infini"). La cadence sera de 10+6, et le tournoi consistera en une phase de groupes initiale suivie d’une phase éliminatoire, avec des parties jouées à des horaires très variables, environ une fois par semaine. Le gagnant obtiendra un rôle spécial sur le [serveur Discord de la communauté](https://discord.gg/NFWFGZeNh5) ! [Voici le formulaire pour participer](https://docs.google.com/forms/d/e/1FAIpQLSegbe4y201GQDd8h8X0nxjgsY00j-gEE2CWWo6CaHpRV7xY-g/viewform?usp=dialog). La date limite est fixée au **vendredi 4 avril 2025**, et le tournoi commencera le lendemain. Toutes les règles sont [ici](https://docs.google.com/document/d/1QsV4WBC9bpbWHiaRZ-NT2Bdb4tfdl8dp/edit?usp=sharing&ouid=114043385276125637786&rtpof=true&sd=true). Pour les informations futures sur le tournoi, rejoignez le [Discord](https://discord.gg/NFWFGZeNh5) ! ================================================ FILE: translation/news/fr-FR/2025-03-17.md ================================================ # Mise à jour majeure - les Échecs Infinis 1.6 ! Nous sommes heureux d'annoncer la sortie d'une mise à jour tant attendue ! Les nouveautés les plus importantes concernent le **mode d'entraînement aux échecs et aux mats**, la possibilité de **faire glisser les pièces**, ainsi que de nouvelles **variantes** ! ## Mode Entraînement - Un nouveau menu **Entraînement** est disponible sur l'écran principal. Jouez contre l'ordinateur pour vous entraîner aux échecs et mats contre un roi seul, dans de nombreuses configurations de finale ! Pouvez-vous maîtriser tous les échecs et mats connus ? - Obtenez des badges resplendissants en complétant les échecs et mat d'entraînement ! Le meilleur badge que vous obtenez est affiché sur votre page de profil pour que les visiteurs la voient. ## Faire glisser les pièces - Il est désormais possible de déplacer les pièces en les faisant glisser. Ça peut être activé ou désactivé dans les paramètres. Sur PC, maintenir Ctrl permet de déplacer le plateau au lieu des pièces. - Faites glisser les pièces sur les indicateurs de flèches situés au bord de l'écran pour capturer la pièce vers laquelle la flèche pointe (si c'est un coup légal) ! ## Variantes - Les mouvements et le comportement des pièces sont désormais grandement personnalisables pour chaque variante. Cela a permis l'émergence de nouvelles variantes exotiques (voir en dessous). De plus, la compatibilité a été ajoutée pour les variantes en 4 dimensions de n'importe quelle taille, profondeur ou espace ! - Nouveau : **Classique Confiné** par tsevasa. Un mur d'obstacles couvre vos arrières, vous protégeant des attaques sournoises sans augmenter le nombre de pions. - Nouveau : **Échecs sur un Plan Infini — Option Hugyen** par V. Reinhart. Cette variante contient une nouvelle pièce, le Huygen (ou Huygène). Le Huygen se déplace dans les mêmes directions que la tour, mais est un sauteur _premier_, signifiant qu'il ne peut se déplacer que sur les cases à une distance première de sa case de départ (et sautant par dessus les autres). Cela a des implications mathématiques intéressantes sur l'échiquier infini. Pouvez-vous maîtriser ses mouvements et dominer vos adversaires ? - Nouveau : **Échecs 4x4x4x4** par tsevasa. Dans cette variante à quatre dimensions, toutes les pièces peuvent sauter à travers les plateaux dans des dimensions différentes ! La dame se déplace comme la princesse et le pion comme le brawn, les deux pièces étant dans « 5D Chess with Multiverse Time Travel » (les Échecs 5D avec Voyage Temporel dans le Multivers). - Nouveau : **Échecs 5D** par Jace : 64 cases, 64 plateaux. Le commencement d'un échiquier ! Cette variante a été conçue pour être un reflet des Échecs 5D avec Voyage Temporel dans le Multivers. Déplacez-vous interdimensionnellement dans l'espace et dans le temps sur d'autres échiquiers de ce monde ! L'algorithme d'échec et mat été désactivé dans cette variante, faites attention de ne pas vous mettre en échec ! La partie se termine quand un seul des nombreux rois est capturé. - Supprimés : Chandelier d'Amazone, Endiguement, Classique - Limite 7, et CoaIP - Limite 7, car personne n'y jouait. ## Autres - Les indicateurs fléchés pointant vers des pièces situées en dehors de l'écran sont animées avec les coups. Les mini-icônes des pièces lorsque vous avez dézoomé sont aussi animées ! - Les **Dame royale** et **Rose** sont désormais compatibles. La Dame Royale est similaire à une dame, mais elle vous fera perdre la partie si elle est mise en échec et mat. La Rose se comporte comme un chevalier qui saute en cercle. Il n'y a pas encore de variante pour ces pièces (les suggestions de variantes sont les bienvenues !). - Tous les contributeurs au code source d'infinitechess.org sont désormais listés sur la page d'accueil. Merci à tous ! - Correction de l'abandon automatique pour « tricherie » qui se produit lorsque vous déplacez une pièce sur une distance de 10^21 cases ou plus dans une partie en ligne. Vous pouvez désormais vous déplacer aussi loin dans les parties en ligne que dans les parties locales ! Bien que vous allez encore rencontrer des bugs graphiques, ceux-ci seront corrigés ultérieurement. - Une animation d'un pion qui tourne pendant qu'une partie se charge a été ajouté. - Vos coordonnées peuvent désormais être modifiées dans les parties locales. - Plusieurs autres améliorations de l'expérience utilisateur et corrections de bugs. Il y en a trop pour les lister ici ! ================================================ FILE: translation/news/fr-FR/2025-05-21.md ================================================ ### Les [Conditions d'Utilisation](https://www.infinitechess.org/termsofservice) ont été mises à jour. Players may not abuse bugs or glitches in order to abort the game, play otherwise illegal moves, give you an advantantage, or make the game otherwise unplayable. Les joueurs ne sont pas autorisés à abuser de bugs pour interrompre la partie, jouer des coups autrement illégaux, obtenir un avantage ou sinon rendre le jeu injouable. ================================================ FILE: translation/news/fr-FR/2025-06-16.md ================================================ # Mise à jour 1.7 des Échecs Infinis ! ## Parties Classées + Classement - Les utilisateurs peuvent désormais choisir des parties classées depuis le menu de jeu ! Gagnez des parties pour améliorer votre propre classement ! Ayez une estimation sur le niveau de jeu de votre adversaire. - Une page avec le classement a été ajoutée. Jusqu'à où pouvez-vous aller ? Les nouveaux joueurs ne sont pas immédiatement affichés sur le classement, mais seulement après environ 4 parties classées. - Les conteneurs des pseudonymes ont été mis à jour pour contenir leur classement, et un lien vers leur profil. Les pseudonymes sont aussi visibles sur mobile. ## Annotations - Faites des clics droit pour surligner des cases, et faites glisser avec clic droit pour dessiner des flèches ! Cela peut être utile pour analyser une position, ou en streaming pour montrer à quel coups on pense ! - Faire glisser avec double clic droit pour dessiner des _rayons_, des lignes infinies de cases surlignées. Ils peuvent être utilisés pour prévoir des attaques à longue distance rapidement et efficacement, sans faire de calcul mental pour trouver la case sur laquelle se déplacer. - Faites un clic gauche pour enlever les annotations. Par défaut, ça supprime tous les surlignages de case et flèches, mais si vous avez au moins un rayon dessiné, alors à la place des surlignages de case sont ajoutés à tous les points d'intersection des rayons, et tous les rayons sont effacés. - Quand vous avez dézoomé, les annotations sont affichées avec la même taille que les pièces, et vous pouvez cliquer sur les cases et les rayons pour zoomer dessus. - Un bouton pour les annotations persistantes a été ajouté dans le menu déroulant des paramètres. Lorsque c'est activé, sélectionner des pièces ne va pas effacer automatiquement vos annotations existantes. Ça permet à vos annotations de persister de coup en coup, vous permettant de vous souvenir des cases clés ou d'organiser des attaques en avance. Vous pouvez toujours effacer les annotations en cliquant sur une zone vide du plateau. - Les utilisateurs sur mobile ont un nouveau bouton d'annotation sur la barre de navigation, qui, quand activé, va compter tous leurs contacts comme l'équivalent du clic droit, vous permettant d'ajouter des annotations sans avoir besoin d'une souris. Cela signifie que les utilisateurs sur mobile ne sont pas désavantagés par rapports aux utilisateurs sur PC par l'incapacité de dessiner des rayons pour les aider à organiser des attaques à longue distance. ## Accrochage - Lorsque vous êtes dézoomés, et que vous survolez avec votre curseur au dessus de n'importe quelle ligne de coups légaux ou rayon, votre souris s'accrochera aux points de cette ligne qui croisent cardinalement d'autres pièces ou annotations, et cliquer vous permettra d'immédiatement zoomer sur ce point ! Cela rend rapide et facile d'organiser des attaques sans devoir méticuleusement trouver la case exacte dont vous avez besoin. ## Autres ajouts - Ajout d'une option pour activer/désactiver les animations des pièces dans le menu déroulant des paramètres. Lorsqu'elle est désactivée, les pièces se téléportent instantanément de leur case de départ à leur case d'arrivée. - Les mini-images, même lorsqu'elles sont désactivées dans les grandes variantes, affichent désormais systématiquement les pièces au-dessus des cases surlignées, ainsi que la dernière pièce déplacée. C'est utile pour suivre les pièces importantes en étant dézoomé dans les grandes variantes de showcase. - Des rayons et surlignages préréglés ont été ajoutés à la variante de showcase d'Omega^2 pour mettre l'accent sur les lignes et cases importantes pour la ligne principale. Ils sont permanents et ne peuvent pas être effacés. La notation des parties supporte désormais les cases et rayons préréglés. - Maintenir Alt en faisant un clic gauche peut être utilisé pour simuler un clic droit. - Un système automatisé de réinitialisation du mot de passe a été ajouté sur la page de connexion. ================================================ FILE: translation/news/fr-FR/2025-11-28.md ================================================ # Nouvelle vidéo sortie + Mise à jour 1.8 des Échecs Infinis ! ## Plateau infini - La taille du plateau a été ÉNORMÉMENT augmentée ! Les limites de dézoom ont été supprimées, permettant aux joueurs de se déplacer beaucoup, beaucoup plus loin, sans bug. - Des effets spéciaux ont été ajoutés au plateau lorsque vous vous déplacez à des distances extrêmes de l'origine, amplifiant le sentiment de se débarrasser de couches de la réalité. Jusqu'à quelle distance pouvez-vous aller ? - Des ambiances au-delà de 1 000 cases de l'origine ont été ajoutées. - Des effets de tremblement pour les grands coups ont été ajoutés. - Un nouveau son et un nouveau effet visuel ont été ajoutés pour les déplacements très lointains. - Une limite a été ajoutée au plateau dans les parties d'entraînement aux échecs et mats, et dans les variantes suivantes : Obstocean, les échecs 4×4×4×4, les échecs en 5D, et les échecs. Les moteurs ne sont pas capables de se déplacer à des distances infinies, donc cela permet qu'ils puissent encore fonctionner après cette mise à jour. - Un effet de champ d'étoiles a été ajouté dans le VIDE. - Un menu déroulant Son a été ajouté dans les paramètres. Contrôlez le volume du jeu, and activez ou désactivez les ambiances. - Deux boutons ont été ajoutés dans le menu déroulant Apparence (anciennement Plateau) dans les paramètres pour activer ou désactiver le champ d'étoiles ou les effets avancés du plateau. ## Précoups - Les précoups on été ajoutés ! Bougez votre pièce quand c'est au tour de votre adversaire de jouer pour qu'elle soit automatiquement bougée dès que c'est de nouveau à vous de jouer (si le coup est légal). - Désactivez les précoups dans les paramètres. ## Autres - De nouvelles variantes ont été ajoutées : les échecs sur un Plan Infini — Option Rose, les échecs sur un Plan Infini — Option Cavalier sauteur, et Palais. Ces variantes présentent la Rose (NOUVEAU), le Cavalier sauteur, et l'Amazone ! La pièce Rose se déplace comme un cavalier sauteur circulaire, tournant de 45 degrés après chaque saut. Aussi, la variante Knighted Chess a été supprimée. - Des notifications de lettres de nouvelle ont été ajoutées. Une bulle rouge apparaît à côté de l'hyperlien Actualités lorsque vous êtes connecté et que vous avez des lettres de nouvelles non lues. Les nouvelles lettres de nouvelles ont aussi l'étiquette « NOUVEAU » à côté de leur date. - Le problème de l'effet de réverbération étant souvent brusquement coupé a été réglé. ================================================ FILE: translation/news/fr-FR/2026-01-08.md ================================================ # Mise à jour 1.9 des Échecs Infinis ! ## Parties contre l'ordinateur - Entraînez-vous à n'importe quelle moment en jouant contre un fort ordinateur dans des variantes, cadences et difficultés différentes. Il a été créé par FirePlank. Le code source est [ici](https://github.com/FirePlank/infinite-chess-engine) ! ================================================ FILE: translation/news/fr-FR/2026-03-09.md ================================================ # Mise à jour 1.10 des Échecs Infinis — Éditeur de position ! L'éditeur de position, beaucoup demandé, est enfin arrivé ! ## Éditeur de position - Placez, bougez et effacez les pièces librement pour créer de nouvelles positions. - L'outil de sélection puissant vous permet de manipuler de grands groupes de pièces d'un coup, inspiré par les logiciels de tableur. - Les règles de jeu sont entièrement configurables. - Démarrez une partie locale ou contre l'ordinateur directement depuis le navigateur. - Enregistrez et chargez des positions dans votre navigateur ou dans le cloud (nécessite de s'authentifier). ================================================ FILE: translation/news/pl-PL/2024-01-29.md ================================================ Wyszedł nowy film! ================================================ FILE: translation/news/pl-PL/2024-05-14.md ================================================ Aktualizacja 1.3! Zawiera DUŻO usprawnień w użytkowaniu. Między innymi: - Zmiana architektury sieciowej na websockets, zmniejszająca opóźnienie ruchu przeciwnika. - Nie będziesz już rozłączany gdy zmienisz kartę w przeglądarce. - Dodano powiadomienie dzwiękowe gdy ty lub ktoś inny stworzy zaproszenie albo wykona ruch. - Dodano zasadę 50 ruchów. - Od teraz, gdy na zegarze zostanie mniej niż 10 sekund, będzie grał efekt dzwiękowy. - Licznik automatycznego poddania się zacznie odliczać gdy twój przeciwnik będzie AFK (z dzwiękowym ostrzeżeniem). I wiele więcej! By zobaczyć pełną listę sprawdź [discorda](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)! ================================================ FILE: translation/news/pl-PL/2024-05-24.md ================================================ Aktualizacja 1.3.1! Dodano poradnik, popowiedzi po najechaniu na przyciski nawigacji, oraz linki do discorda i podziękowań na stronie tytułowej! ================================================ FILE: translation/news/pl-PL/2024-05-27.md ================================================ 1.3.2: Dodano wariant showcase Omega^3 oraz showcase Omega^4, które zostały zaprezentowane w moim ostatnim filmie. Algorytm matowania działa teraz przy grach z więcej niż jednym królem po każdej ze stron. ================================================ FILE: translation/news/pl-PL/2024-07-09.md ================================================ Infinite Chess jest teraz Open Source! Zobacz i buduj projekt z nami na [GitHubie](https://github.com/Infinite-Chess/infinitechess.org)! ================================================ FILE: translation/news/pl-PL/2024-07-13.md ================================================ [Warunki korzystania z usługi](https://www.infinitechess.org/termsofservice) zostały zaktualizowane. Zmiany: Wszystkie gry, które zagrasz na stronie, a także twój ostatni czas logowania, zostanie informacją publiczną. Warunki mogą zostać zaktualizowane w dowolnym momencie, i że twoją odpowiedzialnością jest zapoznać się ze zmianami. W przyszłości twoja historia gier będzie widoczna na twoim profilu. ================================================ FILE: translation/news/pl-PL/2024-07-22.md ================================================ Jeśli twoje konto nie jest zweryfikowane, zrób to na swoim profilu! Niedługo wszystkie niezweryfikowane konta zostaną usunięte!! ================================================ FILE: translation/news/pl-PL/2024-08-01.md ================================================ Wyszła aktualizacja 1.4 została! Pojawiło się wiele nowych funkcji od kiedy projekt jest open source! - Jeździec został dodany, może on skakać w nieskończoność dopóki nic nie stanie mu na drodze! Wariant 'Knighted Chess' został ulepszony, skoczki zostały zastąpione jeźdzcami! - Kliknij figurę twojego przeciwnika by zobaczyć jej potencjalne ruchy! - Kliknij prawym przyciskiem myszy w dowolnym momencie by odznaczyć figurę. - Przytrzymaj kursor nad strzałką na krańcu ekranu, by zobaczyć wszystike możliwe ruchy figury, na którą ona wskazuje! - Od teraz gra automatycznie ogłasza remis gdy na planszy nie ma wystarczająco materiału by dać mata. - Przetłumaczono stronę na język francuski! Możesz zmienić język na dole strony. - Przyśpieszono ładowanie strony. - Nowe logo, Ω! Automatycznie dopasowuje się do trybu jasnego i ciemnego twojego urządzenia. - Kod gry, notacja ICN's, metadata zostały przeformatowane by bardziej przypominać notację PGN. - Od teraz użytkownicy mogą usunąć sowje konto za pomocą przycisku na swoim profilu. Nie musisz już pisać e-maila do nas. ================================================ FILE: translation/news/pl-PL/2024-09-11.md ================================================ Zapisy na pierwszy turniej Infinite Chess są otwarte!!! Formułą będzie wariant klasyczny i każdy z zawodników będzie miał na swoim zegarze 10 minut + 6 sekund za każdy ruch (zostanie to dodane niedługo). Zwycięzca otrzyma unikalną odznakę i/lub rolę na [serwerze discord](https://discord.gg/NFWFGZeNh5) naszej społeczności! Tutaj znajdziesz [formularz zapisu](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! Koniec zapisów jest w **Piątek, 27-ego Września!** Pełne zasady znajdziesz [tutaj](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). By słyszeć o przyszłych aktualnościach odnośnie turnieju dołącz na [serwer discord](https://discord.gg/NFWFGZeNh5)! **Aktualizacja v.1.4.1 została wydana!** - W końcu możesz zaproponować remis swojemu przeciwnikowi! Przycisk propozycji remisu znajdziesz w menu pauzy! - Dodano lokalizację dla języków: Chińskiego, Polskiego (!) oraz Portugalskiego! - Naprawiono błąd gdy podczas wielokrotnego klikania przycisku utworzenia zaproszenia użytkownik otrzymywał wiadomości takie jak: masz już aktywne zaproszenie, bądź nie możesz zaakceptować swojego zaproszenia. ================================================ FILE: translation/news/pt-BR/2024-01-29.md ================================================ Novo vídeo lançado hoje! ================================================ FILE: translation/news/pt-BR/2024-05-14.md ================================================ A atualização 1.3 foi lançada hoje! Ela inclui MUITAS novas melhorias na velocidade e na experiência do usuário. Apenas algumas delas são: - A transição para websockets, diminuindo o atraso quando seu oponente se move. - Você não é mais desconectado ao alternar entre guias. - Avisos sonoros quando você ou outra pessoa cria um convite ou faz um lance. - Adicionado a regra dos 50 Lances. - Um efeito sonoro de bateria agora é reproduzido quando há 10 segundos restantes no relógio. - Um cronômetro de desistência automática começará se seu oponente ficar ausente (com um aviso sonoro). E muitos outros! Para ver a lista completa, confira [o discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)! ================================================ FILE: translation/news/pt-BR/2024-05-24.md ================================================ Atualização 1.3.1 lançada! Ela inclui o guia, dicas de ferramentas pop-up ao passar o mouse sobre os botões de navegação e links para o discord e os créditos do jogo na página de título! ================================================ FILE: translation/news/pt-BR/2024-05-27.md ================================================ 1.3.2: Adicionadas variantes de mostruário para Ômega^3 e Ômega^4 que foram mostradas no meu último vídeo. Além disso, o algoritmo de xeque-mate agora é compatível com vários reis para cada lado. ================================================ FILE: translation/news/pt-BR/2024-07-09.md ================================================ O Infinite Chess agora é de código aberto! Veja e contribua com o projeto [no GitHub](https://github.com/Infinite-Chess/infinitechess.org)! ================================================ FILE: translation/news/pt-BR/2024-07-13.md ================================================ O [Termos de Serviço](https://www.infinitechess.org/termsofservice) foram atualizados. Alterações feitas: Todos os jogos que você joga no site podem se tornar informações públicas, inclusive o horário aproximado em que sua conta esteve ativa pela última vez. Os termos podem ser atualizados a qualquer momento, e é sua responsabilidade certificar-se de que está em dia com eles. Seu histórico de jogos poderá ficar disponível em seu perfil em um momento futuro. ================================================ FILE: translation/news/pt-BR/2024-07-22.md ================================================ Se você ainda não verificou sua conta, faça isso em sua página de perfil! Todas as contas não verificadas serão excluídas em breve! ================================================ FILE: translation/news/pt-BR/2024-08-01.md ================================================ A atualização 1.4 foi lançada! Foram adicionados muitos recursos de colaboração desde que abrimos o código-fonte! - Knightriders foram adicionados, que saltam infinitamente como um cavalo até serem obstruídos! A variante 'Knighted Chess' foi atualizada para substituir os cavalos por knightriders! - Clique em suas peças ou nas do adversário a qualquer momento para ver os movimentos possíveis! - Clique com o botão direito do mouse a qualquer momento para desmarcar a peça selecionada. - Passar o mouse sobre os indicadores de seta na borda da tela agora renderiza os movimentos legais da peça que elas estão apontando! - O jogo agora declara automaticamente um empate se não houver material suficiente no tabuleiro para forçar o xeque-mate. - Traduzimos o site para o Francês! Você pode alterar o idioma acessando o rodapé de qualquer página. - Melhoria no tempo de carregamento do site. - Novo ícone do site, Ω! Ele corresponde automaticamente ao tema de seu dispositivo claro ou escuro preferido. - Os metadados do código do jogo, ou do ICN, foram reformatados para se aproximarem mais das normas do PGN. - Os usuários agora podem excluir suas contas na página de perfil, se assim desejarem, sem precisar nos enviar um e-mail. ================================================ FILE: translation/news/pt-BR/2024-09-11.md ================================================ O primeiro torneio de Infinite Chess já está aberto para inscrições!!! Ele será jogado na variante Classical e o controle de tempo será de 10m+6s (isso será adicionado em breve). O vencedor receberá um sinalizador especial e/ou um cargo no [discord](https://discord.gg/NFWFGZeNh5)! Aqui está o [formulário de inscrição](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)! O prazo para se inscrever é **sexta-feira, 27 de setembro!** As regras completas estão localizadas [aqui](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub). Para futuras atualizações sobre o torneio, participe do [discord](https://discord.gg/NFWFGZeNh5)! **A atualização v.1.4.1 foi lançada!** - Ofertas de empate foram adicionadas! Encontre o botão de oferecer empates no menu de pausa! - Adicionados os seguintes idiomas: Chinês, Polonês e Português! - Foi corrigido o erro que ao "spammar" o botão Criar convite gerasse mensagens como “você já tem um convite” ou “você não pode aceitar seu próprio convite”. ================================================ FILE: translation/news/zh-CN/2024-01-29.md ================================================ 仅以案由一个新视频 ================================================ FILE: translation/news/zh-CN/2024-05-14.md ================================================ 1.3更新今天发布!这包括很多的改进: - 切换到 WebSocket,减少对手移动时的延迟。 - 切换标签不再断开连接。 - 当您或其他人创建邀请或下棋时,发出声音提示。 - 加了50步规则。 - 现在在时钟剩余10秒时播放鼓式倒计时效果。 - 如果您的对手AFK(并发出声音警告),将开始自动认输计时器。 还有更多!那些都在我们的 [Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)! ================================================ FILE: translation/news/zh-CN/2024-05-24.md ================================================ 1.3更新今天发布!这包括指南、悬停导航按钮时的弹出式工具提示以及标题页上的 Discord 和游戏信用链接! ================================================ FILE: translation/news/zh-CN/2024-05-27.md ================================================ 1.3.2更新今天发布!新增了我在最新视频中展示的 Omega^3 和 Omega^4 的展示变体。此外,将军算法现在兼容每边多个国王。 ================================================ FILE: translation/news/zh-CN/2024-07-09.md ================================================ 无限棋现在开源!查看并贡献项目,请访问 [GitHub](https://github.com/Infinite-Chess/infinitechess.org)! ================================================ FILE: translation/news/zh-CN/2024-07-13.md ================================================ [服务条款](https://www.infinitechess.org/termsofservice)已更新。变更内容:您在网站上玩的所有游戏都可能成为公共信息,包括您的帐户上次活跃的大概时间。条款可能会随时更新,确保您了解最新条款是您的责任。 您的游戏历史可能在未来可在您的个人资料中查看。 ================================================ FILE: translation/news/zh-CN/2024-07-22.md ================================================ 如果您尚未验证您的账户,请在您的个人资料页面上进行验证!所有未验证的账户将很快被删除! ================================================ FILE: translation/news/zh-CN/2024-08-01.md ================================================ 更新 1.4 发布了!自从我们开源以来,添加了许多协作功能! - 新增了骑士骑行者,它们像骑士一样无限跳跃,直到被障碍物阻挡!'骑士国际象棋' 变体已经升级,将骑士替换为骑士骑行者! - 随时点击自己或对手的棋子以查看其可能的移动! - 随时右键点击以取消选择当前选中的棋子。 - 将鼠标悬停在屏幕边缘的箭头指示器上,现在会显示指示的棋子的合法移动! - 如果棋盘上没有足够的棋子来强制将死,游戏现在会自动判定平局。 - 网站已经翻译成法语!您可以通过访问任何页面的页脚来更改语言。 - 改善了网站的加载时间。 - 新的网站图标,Ω!它会自动匹配您首选的浅色或深色设备主题。 - 游戏代码或 ICN 的元数据已重新格式化,更加符合 PGN 标准。 - 用户现在可以在个人资料页面删除他们的帐户,无需通过电子邮件联系我们。 ================================================ FILE: translation/news/zh-CN/2024-09-11.md ================================================ 首届无限棋锦标赛现已开放报名!!!比赛将采用经典变体,时间控制为10分钟加6秒(此功能将很快添加)。获胜者将在 [社区Discord](https://discord.gg/NFWFGZeNh5) 上获得特殊标识和/或角色! 这是 [报名表格](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)!报名截止日期是 **2024年9月27日(星期五)!** 完整规则请查看 [这里](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub)。有关锦标赛的最新动态,请加入 [Discord](https://discord.gg/NFWFGZeNh5)! **更新 v.1.4.1 已发布!** - 已添加和棋提议功能!在暂停菜单中找到提议和棋按钮! - 已添加以下语言支持:中文、波兰语、葡萄牙语! - 修复了一个错误:重复点击创建邀请按钮时会收到如您已拥有邀请或无法接受自己的邀请等消息。 ================================================ FILE: translation/news/zh-TW/2024-01-29.md ================================================ 僅以案由一個新視頻 ================================================ FILE: translation/news/zh-TW/2024-05-14.md ================================================ 1.3更新今天發布!這包括很多的改進: - 切換到 WebSocket,減少對手移動時的延遲。 - 切換標簽不再斷開連接。 - 當您或其他人創建邀請或下棋時,發出聲音提示。 - 加了50步規則。 - 現在在時鐘剩余10秒時播放鼓式倒計時效果。 - 如果您的對手AFK(並發出聲音警告),將開始自動認輸計時器。 還有更多!那些都在我們的 [Discord](https://discord.com/channels/1114425729569017918/1114427288776364132/1240014519061712997)! ================================================ FILE: translation/news/zh-TW/2024-05-24.md ================================================ 1.3更新今天發布!這包括指南、懸停導航按鈕時的彈出式工具提示以及標題頁上的 Discord 和游戲信用鏈接! ================================================ FILE: translation/news/zh-TW/2024-05-27.md ================================================ 1.3.2更新今天發布!新增了我在最新視頻中展示的 Omega^3 和 Omega^4 的展示變體。此外,將軍算法現在兼容每邊多個國王。 ================================================ FILE: translation/news/zh-TW/2024-07-09.md ================================================ 無限棋現在開源!查看並貢獻項目,請訪問 [GitHub](https://github.com/Infinite-Chess/infinitechess.org)! ================================================ FILE: translation/news/zh-TW/2024-07-13.md ================================================ [服務條款](https://www.infinitechess.org/termsofservice)已更新。變更內容:您在網站上玩的所有游戲都可能成為公共信息,包括您的帳戶上次活躍的大概時間。條款可能會隨時更新,確保您了解最新條款是您的責任。 您的游戲歷史可能在未來可在您的個人資料中查看。 ================================================ FILE: translation/news/zh-TW/2024-07-22.md ================================================ 如果您尚未驗証您的賬戶,請在您的個人資料頁面上進行驗証!所有未驗証的賬戶將很快被刪除! ================================================ FILE: translation/news/zh-TW/2024-08-01.md ================================================ 更新 1.4 發布了!自從我們開源以來,添加了許多協作功能! - 新增了騎士騎行者,它們像騎士一樣無限跳躍,直到被障礙物阻擋!'騎士國際象棋' 變體已經升級,將騎士替換為騎士騎行者! - 隨時點擊自己或對手的棋子以查看其可能的移動! - 隨時右鍵點擊以取消選擇當前選中的棋子。 - 將鼠標懸停在屏幕邊緣的箭頭指示器上,現在會顯示指示的棋子的合法移動! - 如果棋盤上沒有足夠的棋子來強制將死,游戲現在會自動判定平局。 - 網站已經翻譯成法語!您可以通過訪問任何頁面的頁腳來更改語言。 - 改善了網站的加載時間。 - 新的網站圖標,Ω!它會自動匹配您首選的淺色或深色設備主題。 - 遊戲代碼或 ICN 的元數據已重新格式化,更加符合 PGN 標準。 - 用戶現在可以在個人資料頁面刪除他們的帳戶,無需通過電子郵件聯系我們。 ================================================ FILE: translation/news/zh-TW/2024-09-11.md ================================================ 首屆無限棋錦標賽現已開放報名!!!比賽將採用經典變體,時間控制為10分鐘加6秒(此功能將很快添加)。獲勝者將在 [社區Discord](https://discord.gg/NFWFGZeNh5) 上獲得特殊標識和/或角色! 這是 [報名表格](https://docs.google.com/forms/d/e/1FAIpQLScy5A3fDL_LduFuxy_qODx9hP1_aRip13SK37jH6ERjKWwu_w/viewform)!報名截止日期是 **2024年9月27日(星期五)!** 完整規則請查看 [這裡](https://docs.google.com/document/d/1lCc07bqYZwQbpSOkExZzY044TR5zNfyQT4IQZqqCinc/pub)。有關錦標賽的最新動態,請加入 [Discord](https://discord.gg/NFWFGZeNh5)! **更新 v.1.4.1 已發布!** - 已添加和棋提議功能!在暫停菜單中找到提議和棋按鈕! - 已添加以下語言支持:中文、波蘭語、葡萄牙語! - 修復了一個錯誤:重復點擊創建邀請按鈕時會收到如您已擁有邀請或無法接受自己的邀請等消息。 ================================================ FILE: translation/pl-PL.toml ================================================ name = "Polski" # Polish - Name of language english_name = "Polish" direction = "ltr" # Change to "rtl" for right to left languages version = "22" maintainer = "Apsurt" [header] home = "Infinite Chess" play = "Graj" news = "Aktualności" login = "Zaloguj się" profile = "Profil" createaccount = "Utwórz konto" logout = "Wyloguj się" [header.settings] language = "Język" board = "Motywy" # Board color/theme legalmoves = "Wskaźnik ruchów" # Legal moves shape legalmoves-squares = "Pola" legalmoves-dots = "Kropki" # Dots and 4 corner triangles perspective = "Widok 3D" # Perspective-mode perspective-mouse-sensitivity = "Czułość myszki" perspective-fov = "Pole widzenia" ping = ["Ping", "ms"] # A number is inserted between these 2 strings. reset-to-default = "Domyślne" [footer] contact = "Kontakt" terms_of_service = "Warunki korzystania z usługi" source_code = "Kod źródłowy" language = "Język" [member.javascript] js-confirm_delete = "Czy napewno chcesz usunąć swoje konto? Usunięcie konta jest NIEODWRACALNE! Naciśnij OK, aby wpisać hasło" js-enter_password = "Wpisz swoje hasło, aby usunąć konto NA ZAWSZE" [index] title = "Infinite Chess | Strona Główna - Oficjalna Strona" secondary_title = "Oficjalna strona do gry online!" what_is_it_title = "Jak to działa?" what_is_it_pargaraphs = [ "Infinite Chess (Nieskończone Szachy) to wariant gry, w którym plansza nie ma granic i jest znacznie większa niż typowa plansza 8 na 8. Hetman, wieże, oraz gońce mogą poruszać się bez limitu odległości w jednym ruchu. Możesz wybrac dowolną liczbę pól aż po nieskończoność!", "Bez ograniczeń co do tego, jak daleko można się poruszać, pojawia się możliwość, że mat nastąpi w omega ω ruchach, co oznacza najmniejszą nieskończoną liczbę porządkową. Zostało udowodnione, że mat może nastąpić po dowolnej liczbie porządkowej ruchów!", "Łatwo się domyślić, że istnieje nieskończona liczba możliwości dla otwarć szachowych, z których, wiele, jest w stanie zapewnić szybką przewagę w partii! Finalnie, cel jest then sam, dać mata przeciwnemu królowi. Ten warinat wymaga jednak nowych strategii, zważywszy na to, że plansza nie ma ścian, które mogą pomóc w złapaniu króla w pułapkę. Partie zazwyczaj trwają tyle co w oryginalnych szachach. Promocja pionów, również zachodzi na wierszach 1. oraz 8.", ] how_to_title = "Jak zagrać?" how_to_paragraph = ["Obecna wersja 1.10 jest dostępna na stronie ","Graj","!"] about_title = "O projekcie" about_paragraphs = [ "Jestem Naviary. Gdy odkryłem Nieskończone Szachy (koncept istniał długo przed tą stroną), byłem bardzo zaintrygowany jego możliwościami. Granie w tą wersję było bardzo trudne, gdyż wymagało od graczy na chess.com wysyłania zdjęć planszy po każdym zagranym ruchu. Ze względu na to, niewiele osób było w stanie zagrać w Nieskończone Szachy, a sam wariant gry nie był popularny.", ["Moim celem było stworzenie sposobu aby Nieskończone Szachy były łatwo grywalne dla każdego, a także stworzenie społeczności. Spędziłem niezliczone godziny nad tą stroną, aktualizowaniem i tworzeniem gry. Mam wiele innych pomysłów, które będą zajmować sporo mojego czasu. O ile chciałbym, żeby ta gra była darmowa, nic w życiu nie jest za darmo, aby wesprzeć mnie finansowo rozważ dołączenie na mój ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Wspierający" [credits] title = "Podziękowania" copyright = "Wszystko co jest na stronie (wyłączając poniżej wypisane warianty), jest własnością www.InfiniteChess.org" variants_heading = "Warianty" variants_credits = [ "Core stworzony przez Andreas Tsevas.", "Space stworzony przez Andreas Tsevas.", "Space Classic stworzony przez Andreas Tsevas.", "Coaip (Chess on an Infinite Plane) stworzony przez V. Reinhart.", "Pawn Horde stworzony przez Inaccessible Cardinal.", "Abundance stworzony przez Clicktuck Suskriberz.", "Pawndard przez SexyLexi.", "Classical+ przez SexyLexi.", "Knightline przez Inaccessible Cardinal.", "Knighted Chess przez cycy98.", "stworzony przez Cory Evans and Joel Hamkins.", "stworzony przez Andreas Tsevas.", "stworzony przez Cory Evans i Joel Hamkins.", "stworzony przez Cory Evans, Joel Hamkins, i Norman Lewis Perlmutter.", ] textures_heading = "Tekstury" textures_licensed_under = "tekstury na licencji" textures_credits = [ "Gold coin przez Quolte.", ] sounds_heading = "Dźwięki" sounds_credits = [ ["Niektóre dźwięki pochodzą z projektu", "pod licencją"], "Inne efekty dźwiękowe stworzone przez Naviary.", ] code_heading = "Kod" code_credits = [ "- Brandon Jones i Colin MacKenzie IV.", "- Andreas Tsevas i Naviary.", ] language_heading = "Tłumaczenia" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Francuski - ", "Life Enjoyer", " and ", "cycy98", ".", "Chiński uproszczony - ", "Heinrich Xiao", ".", "Chiński tradycyjny - ", "Heinrich Xiao", ".", "Polski - ", "Tymon Becella", ".", # Apsurt "Portugalski - ", "Emerson P. Machado", ".", # The_Skeleton on discord "Hiszpański - ", "xa31er", "." ] [member] title = "Twój Profil" verify_message = "Sprawdź swojego e-mail'a, aby potwierdzić rejestrację. Konto, które nie zostanie zwerifikowane w ciągu 3 dni, zostanie usunięte." resend_message = ["Nie widzisz wiadomości? Sprawdź folder spamu. Możesz również ", "wyślij ponownie.", " Jeśli dalej nie widzisz wiadomości, ", "napisz do nas."] verify_confirm = "Dziękujemy! Twoje konto zostało zarejestrowane pomyślnie." rating = "Ranking ELO:" joined = "Dołączył:" seen = ["Ostatnio widziany:", " temu"] reveal_info = "Pokaż informacje o koncie" account_info_heading = "Informacje o koncie" email = "Adres e-mail:" delete_account = "Usuń konto" password_reset_message = ["Aby zmienić nazwę użytkownika, e-mail lub hasło, ", "napisz do nas."] [create-account] title = "Utwórz konto" username = "Nazwa użytkownika:" email = "E-mail:" password = "Hasło:" create_button = "Zarejestruj się" agreement = ["Zgadzam się na ", "Warunki korzystania z usługi"] [create-account.javascript] js-username_specs = "Nazwa użytkownika musi posiadać przynajmniej 3 znaki oraz zawierać tylko litery A-Z i cyfry 0-9" js-username_tooshort = "Nazwa użytkownika musi poisadać przynajmniej 3 znaki" js-username_wrongenc = "Nazwa użytkownia może zaweirać tylko litery oraz cyfry 0-9" js-email_invalid = "Adres e-mail jest nieprawidłowy" js-email_inuse = "Adres e-mail jest już używany przez inne konto." js-pwd_incorrect_format = "Hasło ma nieprawidłowy format" js-pwd_too_short = "Hasło musi posiadać przynajmniej 6 znaków" js-pwd_too_long = "Hasło nie może być dłuższe niż 72 znaki" js-pwd_not_pwd = "Hasło nie może być 'password'" [play] title = "Infinite Chess - Graj" loading = "ŁADOWANIE" error = "BŁĄD" [play.main-menu] credits = "Podziękowania" play = "Graj" guide = "Poradnik" editor = "Edytor planszy" [play.guide] title = "Poradnik" rules = "Zasady" rules_paragraphs = [ "Zasady Nieskończonych Szachów są niemal identyczne do zwykłych szachów, z jednym wyjątkiem - plansza ma nieskończony rozmiar! To są jedyne zmiany, o których musisz wiedzieć:", "Figury, takie jak wieże, gońce, i hetman nie mają ograniczeń co do odległości ruchu. Tak długo jak nie mają przeszkód na drodze, możesz poruszyć się nawet o milion pól i więcej!", ["W \"Klasycznym\" wariancie, białe pionki awansują na wierszu 8, a czarne pionki na wierszu 1. Na zdjęciu, jest to wskazane przez cienkie czarne linie. Pionki muszą jedynie stanąć naprzeciwko linii", "nie", " jest konieczne jej przekroczenie."], "Notacja pól nie jest już opisana jako litera i cyfra (np. a1), ale jako koordynaty x i y. Na przykład, pole a1 ma teraz notację (1,1), a h8 notację (8,8). Na komputerach, koordynaty nad którymi znajduję się kursor znajdują się w górnej części ekranu.", "Pozostałe zasady klasycznych szachów, takie jak: szach-mat, pat, trzykrotne powtórzenie pozycji, zasada 50 ruchów, roszada, en passant, oraz inne, pozostają takie same!" ] careful_heading = "Uważaj!" careful_paragraphs = [ "Przestronność nieskończonej planszy oznacza, że łatwo wpaść w pułapki takie jak: podwójne bicie czy przypięcie. Tyły twojej armii są teraz bardziej podatne an ataki przeciwnika. Uważaj na te startegie! Myśl nieszablonowo w trakcie formowania obrony dla twojego króla i wież! Otwarcia mogą się znacząco róznić od tych znanych z klasycznych szachów.", "Wiele innych wariantów zostało stowrzoych by zbalansować słaby punkt twojej armii, czyli tyły. Sprawdź je!" ] controls_heading = "Sterowanie" controls_paragraph = "Kliknij i przeciągnij planszę by poruszyć kamerę. Użyj scrolla by oddalić i przybliżyć. Klkinij dowolną figurę (również przeciwnika) by zobaczyć legalne ruchy. Dodatkowe klawisze sterowania:" keybinds = [ " aby się poruszać.", ["Spacja", " i ", "Shift", " aby przybliżać i oddalać."], ["Escape", " aby zatrzymać grę."], ["Tab", " przełącza wskaźnik strzałek na granicy twojego ekranu, wskazujące położenie twoich figur poza ekranem. Domyślnie, opcja ta jest ustawiona na tryb \"Obrona\", który pokazuje strzałki dla figur, które mogą się bezpośrednio poruszyć do twojej lokalizacji. Klawisz ", "tab", " może przełączyć tą opcję na tryby \"Wszystko\" lub \"Wyłączone\", gdzie tryb \"Wszystko\" pokazuje wszystkie figury, które mogą się poruszać prostopadle lub przekątnie po planszy. Opcja ta, może być również przełączona w menu pauzy."], " przełączy \"Tryb Edycji\" w grach lokalnych. Pozwoli to na poruszanie twoich pionków w dowolne miejsce na planszy! Bardzo użyteczne podczas analizy gry." ] controls_paragraph2 = "To są najpotrzebniejsze klawisze sterowania jakie musisz znać, ale są też dodatkowe jeśli będziesz ich kiedykolwiek potrzebować!" keybinds_extra = [ " zresetuje wyświetlanie figur. Przydatne gdy figury znikną. Ten błąd może przytrafić się na dalekich dystansach (np. 1e21).", " przełączy menu nawigacji i statystyk gry. Przydatne przy nagrywaniu. Nagrywanie oraz transmisje na żywo z gry są mile widziane!", " przełączy wskaźnik klatek na sekundę. Wyświetla on ile razy na sekundę gra się odświeża, niekoniecznie jak często gra wyświetla nowy obraz, gdyż gra czasami pomija wyświtlanie nowego obrazu gdy nic się nie zmienia, by oszcządzać zasoby.", " przełączy wyświetlanie ikon. Są to miniatury figur gdy oddalisz wystarczająco daleko, możesz je kliknąć. W grach z ponad 50,000 figur jest to automatycznie wyłączone, gdyż często powoduje to spadek wydajności, natomiast można je włączyć z powrotem za pomocą ", [" (backtick, bądź ten sam klawisz co ", ") przełączy tryb debugowania."], ] fairy_heading = "Alternatywne Figury" fairy_paragraph = "Wiesz już wszytko co muszisz wiedzieć by móc grać podstawowy wariant \"Klasyczny\". Alternatywne figury nie są używane w podstawowych szachach, lecz są używane w innych wariantach! Gdy natrafisz na figurę, której nigdy nie widziałeś i nie wiesz jak działa, spójrz tutaj by się dowiedzieć!" editing_heading = "Edytowanie planszy" editing_paragraphs = [ ["Istnieje zewnętrzny ", "edytor planszy", " aktuanie dostępny na publicznym arkuszu google! zawiera instrukcje jak go używać. Wymaga podstawowej znajomości arkusza google. Po ustawieniu, będziesz w stanie tworzyć i importować niestandardowe pozycje do gry za pomocą przycisku \"Wklej Grę\" w menu opcji!"], "By zagrać niestandardową pozycję ze znajomym, zaproś go do prywatnej gry, a następnie obydwoje wklejcie grę przed rozpoczęciem rozgrywki!", "Wewnętrzny edytor jest na etapie planowania.", ] back = "Powrót" [play.guide.pieces] chancellor = {name="Kanclerz", description="Porusza się jak połączenie wieży oraz skoczka."} archbishop = {name="Arcybiskup", description="Porusza się jak połączenie gońca oraz skoczka."} amazon = {name="Amazonka", description="Porusza się jak połączenie hetmana oraz skoczka. Najsilniejsza figura w grze!"} guard = {name="Strażnik", description="Porusza się jak król, lecz nie można go szachować ani matować."} hawk = {name="Jastrząb", description="Skacze dokładnie 2 bądź 3 pola w dowolnym kierunku."} centaur = {name="Centaur", description="Porusza się jak połączenie skoczka oraz strażnika."} knightrider = {name="Jeździec", description="Skacze jak skoczek nieskończenie w jednym kierunku, dopóki nic nie stanie mu na drodze."} obstacle = {name="Blokada", description="Neutralna figura (niekantrolowana przez żadnego z graczy), która blokuje ruch, lecz może być zbita."} void = {name="Pustka", description="Neutralna figura (niekantrolowana przez żadnego z graczy), która reprezentuje \"dziurę\" w planszy. Figury nie mogą na niej stawać, ani się przez nią poruszać."} [play.play-menu] title = "Graj - Online" colors = "Kolor" online = "Online" local = "Lokalnie" computer = "Komputer" variant = "Wariant" Classical = "Klasyczny" Classical_Plus = "Klasyczny+" CoaIP = "Chess on an Infinite Plane" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Pawn Horde" Space_Classic = "Space Classic" Space = "Space" Obstocean = "Obstocean" Abundance = "Abundance" Amazon_Chandelier = "Amazon Chandelier" Containment = "Containment" Classical_Limit_7 = "Classical - Limit 7" CoaIP_Limit_7 = "Coaip - Limit 7" Chess = "Chess" Classical_KOTH = "Experimental: Classical - KOTH" CoaIP_KOTH = "Experimental: Coaip - KOTH" Omega = "Showcase: Omega" Omega_Squared = "Showcase: Omega^2" Omega_Cubed = "Showcase: Omega^3" Omega_Fourth = "Showcase: Omega^4" no_clock = "Bez czsu" clock = "Czas" minutes = "m" seconds = "s" infinite_time = "Nieskończony czas" color = "Kolor" piece_colors = ["Losowy", "Biały", "Czarny"] private = "Prywatny" no = "Nie" yes = "Tak" rated = "Rankingowy" casual = "Nierankingowy" join_games = "Dołącz do istniejącej - Trwające gry:" private_invite = "Prywatne zaproszenie:" your_invite = "Twój kod zaproszenia:" create_invite = "Stwórz zaproszenie" join = "Dołącz" copy = "Skopiuj" back = "Powrót" code = "Kod" [play.gamebuttontooltips] undo_transition = "Cofnij" expand_fit_all = "Oddal by zobaczyć wszystko" recenter = "Wyśrodkuj" rewind_move = "Cofnij" forward_move = "Następny" pause = "Pauza" [play.footer] white_to_move = "Ruch białego" player_white = "Gracz Biały" player_black = "Gracz Czarny" [play.pause] title = "Pauza" resume = "Wznów" arrows = "Strzałki: Obrona" perspective = "Perspektywa: Wyłączona" copy = "Skopiuj Grę" paste = "Wklej Grę" offer_draw = "Remis" main_menu = "Strona Główna" [play.drawoffer] question = "Akceptujesz remis?" [play.javascript] guest_indicator = "(Gość)" you_indicator = "(Ty)" white_to_move = "Ruch białego" black_to_move = "Ruch czarnego" your_move = "Twój ruch" their_move = "Ruch przeciwnika" lost_network = "Utracenie połączenia." failed_to_load = "Nie udało się załadować jednego bądź więcej zasobów. Proszę odświeżyć." planned_feature = "W planach!" main_menu = "Strona Główna" resign_game = "Poddaj się" abort_game = "Porzuć grę" offer_draw = "Remis" accept_draw = "Zaakceptuj remis" arrows_off = "Strzałki: Wyłączone" arrows_defense = "Strzałki: Obrona" arrows_all = "Strzałki: Wszystkie" toggled = "Przełączono" menu_online = "Graj - Online" menu_local = "Graj - Lokalnie" invite_error_digits = "Kod zaproszenia musi składać się z 5 cyfr." invite_copied = "Skopiowano kod zaproszenia do schowka." move_counter = "Ruch:" constructing_mesh = "Tworzenie siatki" rotating_mesh = "Obracanie siatki" lost_connection = "Utracono połączenie." please_wait = "Zaczekaj chwilę." webgl_unsupported = "Twoja przeglądarka nie wspiera WebGL. Ta gra potrzebuje tego do funkcjonowania. Zaktualizuj swoją przeglądarkę." bigints_unsupported = "BigInts nie są wspierane. Zaktualizuj swoją przeglądarkę.\nBigInts are needed to make the board infinite." shaders_failed = "Nie udało się uruchomić shadera:" failed_compiling_shaders = "Wystąpił błąd przy kompilowaniu shadera:" [play.javascript.copypaste] copied_game = "Skopiowano grę do schowka!" cannot_paste_in_public = "Nie można wkleić gry do podczas publicznej rozgrywki!" cannot_paste_after_moves = "Nie można wkleić gry gdy ruch został już wykonany!" clipboard_denied = "Zgoda na używanie schowka odrzucona. Może być to spowodowane przez twoją przeglądarkę." clipboard_invalid = "Zawartość schowka nie jest poprawną notacją ICN." game_needs_to_specify = "Gra musi określić metadatę 'Wariant', bądź właściwość 'początkowaPozycja'." invalid_wincon_white = "Biały gracz ma nieprawidłową zasadę wygranej" invalid_wincon_black = "Czarny gracz ma nieprawidłową zasadę wygranej" pasting_game = "Wklejanie gry..." pasting_in_private = "Wklejenie gry w prywatnym meczu spowoduje desynchronizację jeśli twój przeciwnik nie zrobi tego samego!" piece_count = "Liczba figur" exceeded = "przekroczono" changed_wincon = "Zmieniono zasadę wygranej na royalcapture i wyłączono wyświetlanie ikon. Wciśnij 'P' by włączyć z powrotem (nie zalecane)." loaded_from_clipboard = "Wczytano grę ze schowka!" loaded = "Wczytano grę!" slidelimit_not_number = "Zasada 'slideLimit' musi być liczbą. Otrzymano" [play.javascript.rendering] on = "Włączona" off = "Wyłączona" icon_rendering_off = "Wyłączono wyświetlanie ikon." icon_rendering_on = "Włączono wyświetlanie ikon." toggled_edit = "Przełączono tryb edytowania:" perspective = "Perspektywa" perspective_mode_on_desktop = "Tryb perspektywy dostępny na komputerze!" movement_tutorial = "WASD by poruszać planszą. Space & shift by oddalać i przybliżać." regenerated_pieces = "Odświeżono figury." [play.javascript.invites] move_mouse = "Porusz kursorem by połączyć się ponownie." unknown_action_received_1 = "Nieznana akcja" unknown_action_received_2 = "otrzymano subskrypcję zaproszenia od serwera!" cannot_cancel = "Nie można anulować zaproszenia z nieznanym ID." you_indicator = "(Ty)" you_are_white = "Jesteś: Białym" you_are_black = "Jesteś: Czarnym" random = "Losowy" accept = "Akceptuj" cancel = "Anuluj" create_invite = "Stwórz zaproszenie" cancel_invite = "Anuluj zaproszenie" start_game = "Rozpocznij grę" join_existing_active_games = "Dołącz do istniejącej - Trwające Gry:" [play.javascript.onlinegame] afk_warning = "Jesteś AFK." opponent_afk = "Przeciwnik jest AFK." opponent_disconnected = "Przeciwnik rozłączył się." opponent_lost_connection = "Przeciwnik utracił połączenie." auto_resigning_in = "Auto-rezygnowanie za" auto_aborting_in = "Auto-porzucanie za" not_logged_in = "Nie jesteś zalogowany. Zaloguj się by polączyć sie ponownie do tej gry." game_no_longer_exists = "Gra już nie istnieje." another_window_connected = "Inne okno się połączyło." server_restarting = "Serwer niedługo się zresetuje..." server_restarting_in = "Reset serwera za" minute = "minutę" minutes = "minut" [play.javascript.websocket] no_connection = "Brak połączenia." reconnected = "Połączono." unable_to_identify_ip = "Nie możemy zidentyfikować adresu IP." online_play_disabled = "Gra online wyłączona. Ciasteczka nie są wspierane. Spróbuj w innej przeglądarce." too_many_requests = "Zbyt wiele zapytań. Spróbuj ponownie później." message_too_big = "Wiadomość zbyt długa." too_many_sockets = "Too many sockets" origin_error = "Origin error." connection_closed = "Połączenie niespodziewanie przerwane. Wiadomość serwera:" please_report_bug = "Nie powinno się to nigdy zdarzyć. Zgłoś błąd!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Szach-mat" stalemate = "Pat" repetition = "Potrójne powtórzenie pozycji" moverule = ["Zasada ", " 50 ruchów"] # The game inserts a number inbetween these two strings insuffmat = "Nie wystarczający materiał" royalcapture = "Królewskie zbicie" allroyalscaptured = "Cała królewska rodzina zbita" allpiecescaptured = "Wszystkie figury zbite" threecheck = "Potrójny szach" koth = "Król wzgórza" resignation = "Poddanie się" agreement = "Remis" time = "Koniec czasu" aborted = "Przerwanie" # Game was cancelled (no elo exchanged) disconnect = "Porzucenie" # A player left [play.javascript.results] you_checkmate = "Wygrałeś przez szacha-mata!" you_time = "Wygrałeś na czas!" you_resignation = "Wygrałeś przez poddanie się przeciwnika!" you_disconnect = "Wygrałeś przez porzucenie!" you_royalcapture = "Wygrałeś przez królewskie zbicie!" you_allroyalscaptured = "Wygrałeś przez zbicie całej rodziny królewskiej!" you_allpiecescaptured = "Wygrałeś przez zbicie wszystkich figur!" you_threecheck = "Wygrałeś przez potrójnego szacha!" you_koth = "Zostałeś królem wzgórza!" you_generic = "Wygrałeś!" draw_stalemate = "Remis przez pata!" draw_repetition = "Remis przez powtórzenie pozycji!" draw_moverule = ["Remis przez zasadę ", " ruchów!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Remis przez niewystarczający materiał!" draw_agreement = "Remis!" draw_generic = "Remis!" aborted = "Gra porzucona." opponent_checkmate = "Przegrałeś przez szacha-mata!" opponent_time = "Przegrałeś na czas!" opponent_resignation = "Przegrałeś przez poddanie się!" opponent_disconnect = "Przegrałeś przez porzucenie!" opponent_royalcapture = "Przegrałeś przez królewskie zbicie!" opponent_allroyalscaptured = "Przegrałeś przez zbicie całej rodziny królewskiej!" opponent_allpiecescaptured = "Przegrałeś przez zbicie wszystkich figur!" opponent_threecheck = "Przegrałeś przez potrójnego szacha!" opponent_koth = "Przeciwnik został królem wzgórza!" opponent_generic = "Przegrałeś!" white_checkmate = "Biały wygrał przez szach-mat!" black_checkmate = "Czarny wygrał przez szach-mat!" bug_checkmate = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez szach-mat." white_time = "Biały wygrał na czas!" black_time = "Czarny wygrał na czas!" bug_time = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona na czas" white_royalcapture = "Biały wygrał przez królewskie zbicie!" black_royalcapture = "Czarny wygrał przez królewskie zbicie!" bug_royalcapture = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez królewskie zbicie." white_allroyalscaptured = "Biały wygrał przez zbicie całej rodziny królewskiej!" black_allroyalscaptured = "Czarny wygrał przez zbicie całej rodziny królewskiej!" bug_allroyalscaptured = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez zbicie całej rodziny królewskiej!" white_allpiecescaptured = "Biały wygrał przez zbicie wszystkich figur!" black_allpiecescaptured = "Czarny wygrał przez zbicie wszystkich figur!" bug_allpiecescaptured = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez zbicie wszystkich figur." white_threecheck = "Biały wygrał przez potrójnego szacha!" black_threecheck = "Czarny wygrał przez potrójnego szacha!" bug_threecheck = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez potrójnego szacha" white_koth = "Biały zostaje królem wzgórza!" black_koth = "Czarny zostaje królem wzgórza!" bug_koth = "Jest to błąd, prosimy o zgłoszenie tego. Gra skończona przez króla wzgórza." bug_generic = "Jest to błąd, prosimy o zgłoszenie tego!" [terms] title = "Warunki korzystania z usługi" warning = ["TEN DOKUMENT NIE JEST PRAWNIE WIĄŻĄCY. Jesteśmy tylko odpowiedzialni za angielską wersję tego dokumentu. To tłumaczenie powstało tylko dal celów informacyjnych. Dostęp do angielskiej wersji jest ", "tutaj", "."] consent = "Używając tej strony, wyrażasz zgodę na poniższe postanowienia. Jeśli nie chcesz wyrażać zgody, natychmiast opuść tą stronę." guardian_consent = "Jeśli jesteś niepełnoletni potrzebujesz dostać zgodę od rodzica, bądź opiekuna prawnego by korzystać z tej strony oraz założyć konto." parents_header = "Dla Rodziców" parents_paragraphs = [ "Wdrożony jest system, który zabrania użytkownikom używania popularnych przestępstw jako nazw użytkownika. Aktualnie nie ma innego sposobó na komunikowanie się pomiędzy użytkownikami.", "Aktualnie użytkownicy nie mają opcji ustawiać własnych zdjęć profilowych. Taka opcja jest w planach, lecz do tego czasu wprowadzony zostanie system, który bedzie uniemożliwiał używania nieodpowiednich zdjęć profilowych.", ] fair_play_header = "Fair Play" fair_play_paragraph1 = ["Nie możesz zakładać więcej niż 1 konta. Jeśli chcesz zmienić swój adres e-mail, ", "skontaktuj się z nami."] fair_play_paragraph2 = "By gra była przyjemna i sprawiedliwa dla wszystkich, NIE możesz:" fair_play_rules = [ "Modyfikować ani manipulować kodu źródłowego w żaden sposób, wliczając: używanie komend w konsoli, nadpisywanie lokalne, niestandardowe skrypty, modywikowanie zapytań http, itd. Może być to użyte celowo by zepsuć grę, bądź zyskać przewagę nad przeciwnikiem.", "W grach rankingowych, otrzymywać pomocy od innej osoby, bądź programu, który podpowie ci co powinieneś zrobić. (Tworzenie botów szachowych jest okej, ale możesz go używać tylko na grach nierankingowych)", "\"Handlować\" punktami elo z innymi graczami, poprzez celowe przegrywanie by zwiększyć elo towjego przeciwnika, bądź poprzez dostawanie punktów od przeciwnika, który celowo przegrywa by zwiększyć twój ranking elo. Takie praktyki wykorzystują lukę w systemie i tworzą fałszywy ranking, który nie jest reprezentacją twoich prawdziwych umiejętności." ] cleanliness_header = "Zachowanie kultury" cleanliness_rules = [ "Wsyzstkie wypowiedzi na tej stornie muszą pozostać kulturalne, nie używaj wulgaryzmów ani przekleństw. nie prześladuj, szantażuj, upokażaj nikogo. Nie groź i nie rób nic co jest nielegalne. Nie możesz \"spamować\" innym użytkownikom ani na żadnych forach.", "Nie możesz przesyłać na swój profil żadnych treści które są nieodpowiednie, wulgarne, sprośne, bądź makabryczne. Takie działanie może skutkować zablokowaniem albo usunięciem konta." ] privacy_header = "Prywatność" privacy_rules = [ "Na ten moment jedyne dane osobowe, które zbieramy to e-mail. Jest on używany w celu weryfikacji kont użytkowników oraz by potwierdzić tożsamość przy próbie zresetowania hasła. Nie wysyłamy żadnych maili reklamowych ani ofert. Nie udostępniamy danych żadnego z użytkowników nikomu.", "InfiniteChess.org może zbierać dane o użytkowaniu strony, takie jak adres IP. Jest on używany by chronić stronę przed atakami botów i innych niechcianych jednostek, a także by utrzymywać poprawne statystyki w bazie danych. NIE jest to twój adres domowy.", "Wszystkie gry, które zagrasz na tej stronie będą informacją publiczną. Jeśli chcesz pozostać anonimowy nie udostępniaj swojej nazwy użytkownika rodzinie i znajomym. Jeśli jest to twoja wola, twoją odpowiedzialnością jest by nikt nie poznał twojej nazwy użytkownika, która jest powiązana z twoją tożsamością.", "Twój status online oraz przybliżony czas ostatniego logowania również jest publiczną informacją.", ["InfiniteChess.org stara się utrzymać konta oraz dane osobowe wszystkich użytkowników bezpiecznymi z największą uwagą i troską, na wypadek włamania lub wycieku danych, nie możesz podejmować kroków prawnych. Gdyby wyciek danych kiedykolwiek się zdarzył, użytkownicy zostaną poinformowani na stronie", "Aktualności.", ""], "Na tej stronie nie ma nic dostępnego do zakupu. Wszystkie inne dane osobowe nie są zbierane.", "By wyczyścić wszystkie dane osobowe z naszych serwerów, należy usunąć konto na stronie profilu. Jedyne informacje powiązane z nazwą użytkownika, które NIE zostaną usunięte to te dotyczące historii gier, gdyż te są informacjami publicznymi.", ] cookie_header = "Polityka Cookies" cookie_paragraphs = [ "Ta strona używa plików cookies, czyli małych plików tekstowych, które są przechowywane w twojej przeglądarce i wysyłane do serwera gdy połączenie jest ustanowione. Ich celem jest: weryfikowanie twojej sesji logowania, sprawdzanie czy twoja przeglądarka jest w grze, w której twierdzi, że jest, oraz przechowywania ustawień by były takie same po ponownym włączeniu strony. Strona nie używa plików cookies z zewnętrznych stron, pliki cookies nie są nikomu udostępniane.", "Pliki cookies są wymagane by strona i gra funkcjonowały poprawnie. Jeśli nie chcesz by strona przechowywała pliki cookies musisz przestać jej używać. Następnie usuń pliki cookies w swojej przeglądarce. Używając tej strony, wyrażasz zgodę na używanie plików cookies." ] conclusion_header = "Podsumowanie" conclusion_paragraphs = [ "Jakiekolwiek naruszenie zasad może skutkować zawieszeniem bądź usunięciem konta. InfiniteChess.org chce dać każdemu możliwość by grać i dobrze spędzić czas! Niemniej jednak zachowujemy prawo, by w dowolnym czasie, zawiesić lub usunąć konta dowolnego użytkownika, bez podania przyczyny. Nie możemy ponieść za to odpowiedzialności prawnej.", ["Warunki korzystania z usługi mogą być zmienione w dowolnym momencie. To TWOJĄ odpowiedzialnością jest by sprawdzać ostatnie zmiany! Gdy warunki korzystania z usługi ulegną zmianie, informacja o tym zostani eopublikowana na stronie", "Aktualności.", "Jeśli po zmianie warunków korzystania z usługi, nie zgadzasz się z nowymi warunkami, musisz natychmiast przestać używać strony. Możesz usunąć swoje konto na stronie profilu. Gdy usuniesz swoje konto, wszystkie dane osobowe oraz dane konta zostaną usunięte, OPRÓCZ historii gier, która jest powiązana z twoją nazwą użytkownika, ponieważ jest to informacja publiczna."], ["Kod źródłowy tej strony jest publiczny. Możesz kopiować i udostępniać wszystko na tej stronie, tak długo jak tylko przestrzegasz zasad opisanych w", "licencji", "! Jeśli ten link nie działa twoją odpowiedzialnością jest odnalezienie i zastosowanie się do warunków licencji."], "Nie jesteśmy w stanie zagwarantować, że strona będzie działać cały czas. Nie jesteśmy w stanie zagwarantować, że dane nie zostaną nigdy uszkodzone.", "Nie możesz wykonywać żadnych nielegalnych działań na tej stronie.", ["Jeśli masz do nas pytania dotyczące warunków korzystania z usługi lub inne pytania dotyczące tej strony,", "napisz do nas e-mail!"] ] update = "(Ostatnio aktualizowane: 7/13/24. Dodano ostrzeżenie mówiące o tym, że wszystkie zagrane gry staną się informacją publiczną, włączając także dane o przybliżonym czasie ostatniego logowania, a także, że te warunki mogą zostać zaktualizowane w dowolnym momencie, i że twoją odpowiedzialnością jest zapoznać się ze zmianami.)" thanks = "Dziękujemy!" [login] title = "Zaloguj się" username = "Nazwa użytkownika:" password = "Hasło:" forgot_password = ["Zapomniałeś hasła? ", "Napisz do nas e-mail."] login_button = "Zaloguj" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Otrzymano błędne parametry." 409_message = ["Możliwy błąd dotyczący nazwy użytkownika lub e-mail. ", "Przeładuj", ", stronę."] 500_message = "Nie powinno się to zdarzyć!" [news] title = "Aktualności" more_dev_logs = ["Więcej wpisów deweloperskich na ", "oficjalnym serwerze discord", ", oraz na ", "forach chess.com!"] [server.javascript] ws-invalid_username = "Nieprawidłowa nazwa użytkownika" ws-incorrect_password = "Nieprawidłowe hasło" ws-username_and_password_required = "Nazwa użytkownika i hasło są wymagane." ws-username_and_password_string = "Nazwa użytkownika i hasło muszą być tekstem." ws-login_failure_retry_in = "Logowanie nie powiodło się, spróbuj ponownie za" ws-seconds = "sekund" # unit of time ws-second = "sekundę" # unit of time ws-username_length = "Nazwa użytkownika musi mieć pomiędzy 3-20 znaków" ws-username_letters = "Nazwa użytkownika może zawierać tylko znaki A-Z i cyfry 0-9" ws-username_taken = "Nazwa użytkownika jest już zajęta" ws-username_bad_word = "Nazwa użytkownika zawiera niedozwolone słowo" ws-username_reserved = "Nazwa użytkownika jest zarezerwowana" ws-email_too_long = "Twój e-mail jest za długi." ws-email_invalid = "To nie jest prawidłowy e-mail" ws-email_in_use = "Ten e-mail jest już zajęty" ws-you_are_banned = "Jestes zawieszony." ws-password_length = "Hasło musi mieć 6-72 znaków" ws-password_format = "Hasło ma nieprowidłowy format" ws-password_password = "Hasło nie może być 'password'" ws-refresh_token_not_found_logged_out = "Żaden z użytkowników nie ma takiego tokenu (już jesteś wylogowany)" ws-refresh_token_not_found = "Żaden z użytkowników nie ma takiego tokenu" ws-refresh_token_expired = "Nie znaleziono tokenu (sesja wygasłą)" ws-refresh_token_invalid = "Token wygasł lub został zmieniony" ws-member_not_found = "Użytkownik nie znaleziony" ws-forbidden_wrong_account = "Zakazane. To nie towje konto." ws-deleting_account_not_found = "Nie udało się usunąć konta. Konto nie znalezione." ws-server_error = "Przepraszamy, nastąpił błąd serwera! Wróć na poprzednią stronę." ws-unable_to_identify_client_ip = "Nie udało się zidentyfikować adresu IP klienta" ws-you_are_banned_by_server = "Jesteś zawieszony" ws-too_many_requests_to_server = "Zbyt dużo połączeń. Spróbuj później." ws-bad_request = "Bad Request" ws-not_found = "404 Not Found" ws-forbidden = "Forbidden." ws-unauthorized_patron_page = "Brak wstępu. Ta strona jest tylko dla wspierających." ws-already_in_game = "Jestś już w grze." ws-server_restarting = "Restart serwera za" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "Trwają prace nad serwerem. Wróć później!" # Can be changed at will to change the display message. ws-minutes = "minut" # unit of time ws-minute = "minutę" # unit of time ws-no_abort_game_over = "Nie możesz porzucić gry, która się skonczyła." ws-no_abort_after_moves = "Nie możesz porzucić gry gdy 2 ruchy zostały zagrane." ws-game_aborted_cheating = "Gra porzucona, podejrzenie oszustwa." ws-cannot_resign_finished_game = "Nie możesz poddać się w grze, która się skończyłą." ws-invalid_code = "Nieprawidłowy kod!" # Invite code doesn't match any existing invites ws-game_aborted = "Gra porzucona." # Invite was cancelled as you clicked on it ================================================ FILE: translation/pt-BR.toml ================================================ name = "Português" # Nome do idioma english_name = "Portuguese" direction = "ltr" # Change to "rtl" for right to left languages version = "64" maintainer = "" # No current maintainer [header] home = "Xadrez Infinito" play = "Jogar" news = "Notícias" login = "Entrar" profile = "Perfil" createaccount = "Criar conta" logout = "Sair" leaderboard = "Classificação" [header.settings] language = "Idioma" board = "Tabuleiro" # Cor/Tema do Tabuleiro legalmoves = "Lances Legais" # Legal moves shape legalmoves-squares = "Casas" legalmoves-dots = "Pontos" # Pontos e triângulos de 4 cantos selection = "Selection" selection-drag = "Arrastar peças" selection-premove = "Premoves" selection-animations = "Animações" selection-lingering_annotations = "Anotações Persistentes" perspective = "Perspectiva" # Modo da perspectiva perspective-mouse-sensitivity = "Sensibilidade do Mouse" perspective-fov = "Campo de Visão" ping = ["Ping", "ms"] # Um número é inserido dentro dessas 2 strings. reset-to-default = "Resetar para o Padrão" [footer] contact = "Contato" terms_of_service = "Termos de serviço" source_code = "Código-fonte" language = "Idioma" [member.javascript] js-confirm_delete = "Tem certeza de que deseja excluir sua conta? Isso NÃO PODE ser desfeito! Clique em OK para digitar sua senha." js-enter_password = "Digite sua senha para excluir PERMANENTEMENTE sua conta:" [leaderboard.javascript] supported_variants = "Essa tabela de Classificação é usada para seguintes variantes:" rank = "Classificação" player = "Jogador" rating = "Rating" [index] title = "Xadrez Infinito | Início - O Site Oficial" # The tab title secondary_title = "O site oficial para jogar ao vivo!" what_is_it_title = "O que é?" what_is_it_pargaraphs = [ "Xadrez Infinito é uma variante do xadrez em que não há bordas, muito maior do que o seu familiar tabuleiro 8x8. A dama, as torres e os bispos não têm limites de quão longe podem se mover por turno. Escolha qualquer número natural até o infinito!", "Sem limite de quão longe você pode se mover, existem posições possíveis onde o número do relógio do xeque-mate, ou xeque-mate em branco, é representado pelo primeiro ordinal infinito, ômega ω. Na verdade, pesquisas descobriram que qualquer ordinal contável é alcançável para o relógio do xeque-mate!", "Como você pode imaginar, existem infinitas possibilidades de posições iniciais, muitas das quais você pode jogar competitivamente! Seu objetivo final ainda é o xeque-mate, o que requer novas táticas, visto que não há paredes contra as quais possa prender o rei inimigo. Os jogos tipicamente não duram muito mais do que os jogos normais de xadrez. Peões ainda promovem nas fileiras 1 & 8!", ] how_to_title = "Como eu posso jogar?" how_to_paragraph = ["A versão atual é a 1.10 na página ","Jogar","!"] about_title = "Sobre o Projeto" about_paragraphs = [ "Eu sou Naviary. Desde que descobri o Xadrez Infinito (o conceito existia muito antes deste site), fiquei muito intrigado com ele e suas possibilidades! Até pouco tempo atrás, jogar era bastante difícil, exigindo que os membros do chess.com criassem imagens do tabuleiro atual e as enviassem de um lado para o outro a cada jogada realizada. Devido a isso, poucas pessoas conhecem ou conseguiram jogar.", ["Meu objetivo é criar uma maneira de tornar esse jogo fácil de jogar para todos e desenvolver uma comunidade em torno dele. Gastei inúmeras horas do meu tempo livre neste site, mantendo e desenvolvendo o jogo. Tenho muitas outras ideias que me manterão ocupado por algum tempo. Embora eu queira manter o jogo gratuito, a vida tem suas exigências. Para me ajudar financeiramente, considere a possibilidade de se juntar ao meu ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Apoiadores do Patreon" github_title = "Contribuidores do Github" [index.javascript] contribution_count = ["", " contribuições"] # Um número é inserido entre essas duas strings. [credits] title = "Créditos" copyright = "Tudo o que estiver no site que não estiver listado abaixo é copyright de www.InfiniteChess.org" variants_heading = "Variantes" variants_credits = [ "Core projetado por Andreas Tsevas.", "Space projetado por Andreas Tsevas.", "Space Classic projetado por Andreas Tsevas.", "Coaip (Chess on an Infinite Plane) projetado por V. Reinhart.", "Pawn Horde projetado por Inaccessible Cardinal.", "Abundance projetado por Clicktuck Suskriberz.", "Pawndard por SexyLexi.", "Classical+ por SexyLexi.", "Knightline por Inaccessible Cardinal.", "Knighted Chess por cycy98.", "projetado por Cory Evans e Joel Hamkins.", "projetado por Andreas Tsevas.", "projetado por Cory Evans e Joel Hamkins.", "projetado por Cory Evans, Joel Hamkins, e Norman Lewis Perlmutter.", "Chess on an Infinite Plane - Huygens Options por V. Reinhart.", "Confined Classical por Andreas Tsevas.", "4x4x4x4 Chess por Andreas Tsevas.", "5D Chess por Jace.", ] textures_heading = "Texturas" textures_licensed_under = "texturas licenciadas sob a licença" sounds_heading = "Sons" sounds_credits = [ ["Alguns sons são fornecidos pelo", "sob a"], "Outros sons criados por Naviary.", ] code_heading = "Código" code_credits = [ "por Brandon Jones e Colin MacKenzie IV.", "por Andreas Tsevas e Naviary.", ] language_heading = "Traduções de Idiomas" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Francês por ", "Life Enjoyer", " e ", "cycy98", ".", "Chinês Simplificado por ", "Heinrich Xiao", ".", "Chinês Tradicional por ", "Heinrich Xiao", ".", "Polonês por ", "Tymon Becella", ".", # Apsurt "Português por ", "Emerson P. Machado", ".", # The_Skeleton on discord "Espanhol por ", "xa31er", ".", "Alemão por ", "Estetique", "." ] [member] title = "Membro" # The tab name verify_message = "Por favor verifique seu e-mail para confirmar sua conta. Contas não confirmadas serão deletadas após 3 dias." resend_message = ["Não recebeu um? Verifique sua pasta de spam. Também, ", "enviar novamente.", " Se você ainda não consegue encontrar, ", "mande uma mensagem."] verify_confirm = "Obrigado! Sua conta foi verificada" joined = "Ingressou:" seen = ["Visto:", " desde de"] practice_progress = "Progresso no modo Prática:" ranked_elo = "Rating:" infinity_leaderboard_position = "Rank Global:" infinity_leaderboard_rating_deviation = "Desvio de Rating:" reveal_info = "Mostre informações da conta" account_info_heading = "Informações da conta" email = "Email:" delete_account = "Deletar conta" [member.badge-tooltips] checkmate_bronze = "Veterano do Xeque-mate: Complete 50% de todos os confrontos de prática." checkmate_silver = "Pro do Xeque-mate: Concluir 75% de todos os confrontos de prática." checkmate_gold = "Mestre do Xeque-mate: Complete 100% de todos os checkmates de prática." [create-account] title = "Criar conta" username = "Nome de usuário:" email = "Email:" password = "Senha:" create_button = "Criar conta" agreement = ["Eu concordo com os ", "Termos de Serviço", "."] # a entrada do meio é um hiperlink, as outras não são [create-account.javascript] js-username_specs = "O nome de usuário deve ter pelo menos 3 caracteres e conter apenas letras de A-Z e números de 0-9" js-username_tooshort = "O nome de usuário deve ter pelo menos 3 caracteres" js-username_wrongenc = "O nome de usuário deve conter apenas letras de A-Z e números de 0-9" js-email_invalid = "Este não é um email válido" js-email_inuse = "Este email já está em uso" [reset-password.javascript] js-pwd_no_match = "As senhas não são iguais." reset-password = "Resetar Senha" processing = "Processando..." network-error = "Ocorreu um erro de rede. Tente novamente." [password-validation] js-pwd_incorrect_format = "A senha está em um formato incorreto" js-pwd_too_short = "A senha deve ter mais de 6 caracteres" js-pwd_too_long = "A senha não pode ter mais de 72 caracteres" js-pwd_not_pwd = "A senha não deve ser 'password'" [leaderboard] title = "Classificação" inactive_players = "Jogadores inativos com alta incerteza de rating são excluídos da tabela de classificação." your_global_ranking = "Seu Rank Global:" show_more = "Mostrar mais..." [play] title = "Xadrez Infinito - Jogar" # The tab title loading = "CARREGANDO" error = "ERRO" [play.main-menu] credits = "Créditos" play = "Jogar" practice = "Práticar" guide = "Guia" editor = "Editor de tabuleiro" [play.guide] title = "Guia" rules = "Regras" rules_paragraphs = [ "As regras do Xadrez Infinito são quase idênticas às do xadrez clássico, exceto pelo fato de o tabuleiro ser infinito em todas as direções! Essas são as únicas observações e alterações das quais você precisa estar ciente:", "As peças com movimentos deslizantes, como as torres, os bispos e a dama, não têm limite para a distância que podem percorrer em um turno! Desde que o caminho delas esteja desobstruído, você pode mover milhões!", ["Na variante padrão \"Classical\", os peões brancos são promovidos na posição 8 e os peões pretos na posição 1. Nesta imagem, isso é indicado pelas linhas pretas finas. Elas são fracas, veja se você consegue identificá-las! Os peões só precisam alcançar a linha oposta para serem promovidos, ", "não", " cruzá-la."], "A notação dos quadrados não é mais descrita pela letra da coluna e pelo número da fileira (ou seja, a1); em vez disso, cada quadrado é definido por um par de coordenadas x e y. O quadrado a1 se tornou (1,1) e o quadrado h8 se tornou (8,8). Em dispositivos desktop, a coordenada sobre a qual o mouse está posicionado é exibida na parte superior da tela.", "Todas as outras regras são as mesmas do xadrez clássico, como xeque-mate, afogamento, empate por tripla repetição, regra dos 50 lances, roque, en passant, etc.!" ] careful_heading = "Cuidado!" careful_paragraphs = [ "A abertura do tabuleiro infinito significa que é muito fácil explorar garfos, cravadas e espetos. Sua retaguarda geralmente fica muito vulnerável. Cuidado com táticas como essa! Seja criativo ao criar proteção para seu rei e torres! A estratégia da abertura é muito diferente do xadrez clássico.", "Muitas outras variantes foram criadas com o objetivo de fortalecer suas costas." ] controls_heading = "Controles" controls_paragraph = "Muitos controles são intuitivos, como clicar e arrastar o tabuleiro para se movimentar e rolar para aumentar e diminuir o zoom, mas vamos dar uma olhada nos outros controles que estão à sua disposição!" keybinds = [ " para se movimentar.", ["Space", " e ", "Shift", " para aumentar e diminuir o zoom"], ["Escape", " para pausar o jogo."], ["Tab", " alterna o modo de funcionamento da seta nas bordas da tela que apontam para peças fora da tela. Por padrão, esse modo é definido como \"Defesa\", que mostra uma seta para peças que podem se mover para o seu local a partir de onde estão. Mas ", "tab", " pode mudar para o modo \"Todas\" ou \"Off\", sendo que o modo \"Todas\" revela todas as peças nessas ortogonais e diagonais, quer elas possam se mover ortogonalmente ou diagonalmente. Essa configuração também pode ser alternada no menu de pausa."], ["Control", "forçará o arrasto do tabuleiro em vez de arrastar uma peça, se o arrasto estiver ativado nas configurações."], " ativará \"Modo Editor\" em jogos locais. Isso permite que você mova qualquer peça para qualquer outro lugar do tabuleiro! Muito útil para análise." ] controls_paragraph2 = "Esses são os principais controles que você precisa conhecer. Mas aqui estão alguns extras, caso você venha a precisar deles!" keybinds_extra = [ " redefinirá a renderização das peças. Isso é útil se elas ficarem invisíveis. Essa falha pode ocorrer se você se mover a distâncias extremas (como 1e21).", " alternará a renderização das barras de navegação e de informações do jogo, o que pode ser útil para a gravação. Transmitir e fazer vídeos sobre o jogo é bem-vindo!", " alternará seu medidor de FPS. Isso exibe o número de vezes que o jogo está sendo atualizado por segundo, nem sempre o número de quadros renderizados, pois o jogo pula a renderização quando nada visível foi alterado, para economizar computação.", " alternará a renderização de ícones. Essas são os mini-avatares clicáveis das peças quando você reduz o zoom o suficiente. Em jogos importados com mais de 50.000 peças, isso é desativado automaticamente, pois é um fator de redução de desempenho, mas pode ser ativado novamente com ", [" (backtick ou a mesma tecla que ", ") alternará para modo Debug."], ] fairy_heading = "Peças Fadas" fairy_paragraph = "Você já sabe o que precisa saber para jogar a variante padrão \"Clássica\". As Peças das Fadas não são usadas no xadrez convencional, mas são incorporadas em outras variantes! Se você se encontrar em uma variante com algumas peças que nunca viu antes, vamos aprender como elas funcionam aqui!" editing_heading = "Editando o Tabuleiro" editing_paragraphs = [ ["Há um ", "editor de tabuleiro", " externo atualmente disponível em uma planilha pública do Google! Ela inclui instruções sobre como usá-la. Isso requer algum conhecimento básico do Google Sheets. Após a configuração, você poderá criar e importar posições personalizadas para o jogo por meio do botão \"Colar jogo\" no menu de opções!"], "Para jogar uma posição personalizada com um amigo, peça a ele que participe de um convite privado e, em seguida, vocês dois colarão o código do jogo antes de começar a jogar!", "Um editor de tabuleiro dentro do jogo ainda está planejado.", ] back = "Voltar" [play.guide.pieces] chancellor = {name="Chanceler ", description="Move-se como uma torre e um cavalo combinados."} archbishop = {name="Arcebispo", description="Move-se como um bispo e um cavalo combinados."} amazon = {name="Amazonas", description="Move-se como uma rainha e um cavalo combinados. Essa é a peça mais forte do jogo!"} guard = {name="Guarda", description="Move-se como um rei, exceto que não é suscetível a xeque ou xeque-mate."} hawk = {name="Falcão", description="Salta exatamente 2 ou 3 quadrados em qualquer direção."} centaur = {name="Centauro", description="Move-se como um cavalo e um guarda combinados."} knightrider = {name="Knightrider", description="Pula como um cavalo em uma direção infinitamente, até ser obstruído."} huygen = {name="Huygen", description="Pula infinitamente em uma das quatro direções cardeais, visitando apenas os quadrados com uma distância de número primo do seu quadrado inicial, até ser obstruído."} obstacle = {name="Obstáculo", description="Uma peça neutra (não controlado por nenhum dos jogadores) que bloqueia o movimento, mas pode ser capturado."} void = {name="Vazio", description="Uma peça neutra (não controlada por nenhum dos jogadores) que representa a ausência do tabuleiro. As peças não podem se mover através dele ou sobre ele."} [play.practice-menu] title = "Práticar - Xeque-mates" play = "Jogar" back = "Voltar" difficulty = "Dificuldade" [play.play-menu] title = "Jogar - Online" colors = "Cores" online = "Online" local = "Local" computer = "Computador" variant = "Variante" Classical = "Classical" Confined_Classical = "Confined Classical" Classical_Plus = "Classical+" CoaIP = "Chess on an Infinite Plane" Pawndard = "Pawndard" Knighted_Chess = "Knighted Chess" Knightline = "Knightline" Core = "Core" Standarch = "Standarch" Pawn_Horde = "Pawn Horde" Space_Classic = "Space Classic" Space = "Space" Obstocean = "Obstocean" Abundance = "Abundance" Amazon_Chandelier = "Amazon Chandelier" Containment = "Containment" Classical_Limit_7 = "Classical - Limit 7" CoaIP_Limit_7 = "Coaip - Limit 7" Chess = "Xadrez" Classical_KOTH = "Experimental: Classical - KOTH" CoaIP_KOTH = "Experimental: Coaip - KOTH" CoaIP_HO = "Chess on an Infinite Plane - Huygens Option" Omega = "Showcase: Omega" Omega_Squared = "Showcase: Omega^2" Omega_Cubed = "Showcase: Omega^3" Omega_Fourth = "Showcase: Omega^4" 4x4x4x4_Chess = "4×4×4×4 Chess" 5D_Chess = "5D Chess" no_clock = "Sem relógio" clock = "Relógio" minutes = "m" seconds = "s" infinite_time = "Tempo Infinito" color = "Cor" piece_colors = ["Aleatória", "Brancas", "Pretas"] private = "Privado" no = "Não" yes = "Sim" rated = "Com rating" casual = "Sem rating" join_games = "Participe dos jogos existentes - Jogos Ativos:" private_invite = "Convite Privado:" your_invite = "Seu código de convite:" create_invite = "Criar convite" join = "Entrar" copy = "Copiar" back = "Voltar" code = "Código" [play.gamebuttontooltips] undo_transition = "Voltar transição" expand_fit_all = "Expandir para caber tudo" recenter = "Recentralizar" annotations = "Mostrar anotações" erase = "Apagar anotações" collapse = "Colapsar anotações" rewind_move = "Lance anterior" forward_move = "Próximo lance" undo_edit = "Desfazer" # Board editor redo_edit = "Refazer" # Board editor pause = "Pausar" undo = "Voltar lance" # Checkmate practice game restart = "Recomeçar partida" # Checkmate practice game [play.pause] title = "Pausado" resume = "Fechar" arrows = "Setas: Defesa" perspective = "Perspectiva: Off" copy = "Copiar Jogo" paste = "Colar Jogo" offer_draw = "Oferecer Empate" practice_menu = "Menu Práticar" main_menu = "Menu Principal" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Aceitar oferta de empate?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Convidado)" you_indicator = "(Você)" engine_indicator = "Computador" player_name_white_generic = "Brancas" player_name_black_generic = "Pretas" white_to_move = "Brancas jogam" black_to_move = "Pretas jogam" your_move = "Seu lance" their_move = "Lance dele(a)" lost_network = "Perca de conexão" failed_to_load = "Houve falha no carregamento de um ou mais recursos. Por favor, atualize." planned_feature = "Esse recurso está planejado!" main_menu = "Menu Principal" resign_game = "Desistir" abort_game = "Abortar Partida" offer_draw = "Oferecer Empate" # Offer draw button text in the pause menu accept_draw = "Aceitar Empate" # Offer draw button text in the pause menu arrows_off = "Setas: Off" arrows_defense = "Setas: Defesa" arrows_all = "Setas: Todas" arrows_all_hippogonals = "Setas: Todas (com hipogonais)" toggled = "Alternado" menu_online = "Jogar - Online" menu_local = "Jogar - Local" invite_error_digits = "Código de convite precisa ter 5 digitos." invite_copied = "Código de convite copiado para a área de transferência." move_counter = "Lance:" constructing_mesh = "Constructing mesh" rotating_mesh = "Rotating mesh" lost_connection = "Perca de conexão." please_wait = "Aguarde um momento para executar essa tarefa." webgl_unsupported = "Por favor atualize seu navegador! Atualmente não suporta WebGL2..." bigints_unsupported = "Não há suporte para BigInts. Atualize seu navegador.\nBigInts são necessários para tornar o tabuleiro infinito." shaders_failed = "Não foi possível inicializar o programa de shaders:" failed_compiling_shaders = "Ocorreu um erro na compilação dos shaders:" # Checkmate Practice versus = "vs" easy = "Easy" medium = "Medium" hard = "Hard" insane = "Insane" checkmate_logged_out = "Você precisa entrar para ganhar distintivos." checkmate_bronze = "Veterano do Xeque-mate: Complete 50% de todos os confrontos de prática." checkmate_silver = "Pro do Xeque-mate: Concluir 75% de todos os confrontos de prática." checkmate_gold = "Mestre do Xeque-mate: Complete 100% de todos os checkmates de prática." checkmate_bronze_unearned = "Complete 50% de todos os xeque-mate de prática para ganhar esse distintivo." checkmate_silver_unearned = "Complete 75% de todos os xeque-mate de prática para ganhar esse distintivo." checkmate_gold_unearned = "Complete 100% de todos os xeque-mate de prática para ganhar esse distintivo." [play.javascript.copypaste] copied_game = "Jogo copiado para a área de transferência!" cannot_paste_in_public = "Não é possível colar o jogo em uma partida pública!" cannot_paste_in_rated = "Não é possível colar o jogo em uma partida com rating!" cannot_paste_in_engine = "Não é possível colar o jogo em uma partida com Engine!" cannot_paste_after_moves = "Não é possível colar o jogo depois que os movimentos são feitos!" clipboard_denied = "Permissão negada para a área de transferência. Pode ser seu navegador." clipboard_invalid = "A área de transferência não está em uma notação ICN válida." game_needs_to_specify = "O jogo precisa especificar os metadados 'Variant' ou a propriedade 'position'." invalid_wincon_white = "As brancas têm uma condição de vitória inválida" invalid_wincon_black = "As pretas têm uma condição de vitória inválida" pasting_game = "Colando jogo..." pasting_in_private = "Colar um jogo em uma partida privada causará uma dessincronização se o seu oponente não fizer o mesmo!" piece_count = "Contagem de peças" exceeded = "excedido" changed_wincon = "Alterado as condições de vitória do xeque-mate para royalcapture e desativado a renderização de ícones. Pressione 'P' para reativar (não recomendado)." loaded_from_clipboard = "Jogo carregado da área de transferência!" slidelimit_not_number = "slideLimit gamerule deve ser um número. Recebido" [play.javascript.rendering] on = "On" off = "Off" icon_rendering_off = "Renderização de ícones desativada." icon_rendering_on = "Renderização de ícones ativada." perspective = "Perspectiva" perspective_mode_on_desktop = "O modo de perspectiva está disponível no desktop!" movement_tutorial = "WASD para mover. Espaço e shift para dar zoom." regenerated_pieces = "Peças regeneradas." [play.javascript.invites] move_mouse = "Mova o mouse para reconectar." cannot_cancel = "Não é possível cancelar um convite de ID indefinido." you_are_white = "Você: Brancas" you_are_black = "Você: Pretas" random = "Aleatório" accept = "Aceitar" cancel = "Cancelar" create_invite = "Criar convite" cancel_invite = "Cancelar convite" start_game = "Iniciar jogo" join_existing_active_games = "Participe de jogos existentes - Jogos Ativos:" [play.javascript.onlinegame] afk_warning = "Você está ausente." opponent_afk = "Seu oponente está ausente." opponent_disconnected = "O oponente desconectou." opponent_lost_connection = "O oponenteu perdeu conexão." auto_resigning_in = "Desistindo automaticamente em" auto_aborting_in = "Abortando automaticamente em" not_logged_in = "Você não está conectado. Faça login para se reconectar a este jogo." game_no_longer_exists = "Jogo não existe mais." another_window_connected = "Outra janela foi conectada." server_restarting = "Servidor reiniciando em breve..." server_restarting_in = "Servidor reiniciando em" minute = "minuto" minutes = "minutos" [play.javascript.websocket] no_connection = "Sem conexão." reconnected = "Reconectou." unable_to_identify_ip = "Não é possível identificar o IP." online_play_disabled = "Jogo on-line desativado. Cookies não suportados. Tente um navegador diferente." too_many_requests = "Muitas solicitações. Tente novamente em breve." message_too_big = "Mensagem grande demais." too_many_sockets = "Muitos sockets" origin_error = "Erro de Origem." connection_closed = "Conexão fechada inesperadamente. Mensagem do servidor:" please_report_bug = "Isso nunca deveria acontecer. Relate esse bug!" [play.javascript.termination] # O que causou o fim do jogo, é falado na lingua checkmate = "Xeque-mate" stalemate = "Afogamento" repetition = "Tripla repetição" moverule = "-Regra do" # O jogo insere um número na frente dessa frase insuffmat = "Material insuficiente" royalcapture = "Captura da realeza" allroyalscaptured = "Todas peças da realeza capturadas" allpiecescaptured = "Todas peças capturadas" koth = "King of the hill" resignation = "Desistência" agreement = "Acordo" time = "Tempo esgotado" aborted = "Cancelada" # Partida foi cancelada (Sem troca de elo) disconnect = "Abandonado" # Algum jogador saiu [play.javascript.results] you_checkmate = "Você venceu por Xeque-mate!" you_time = "Você venceu por tempo!" you_resignation = "Você venceu por desistência!" you_disconnect = "Você venceu por desconexão!" you_royalcapture = "Você venceu por captura da realeza!" you_allroyalscaptured = "Você venceu por capturar todas as peças da realeza!" you_allpiecescaptured = "Você venceu por capturar todas as peças!" you_koth = "Você venceu Rei da Colina!" you_generic = "Você venceu!" draw_stalemate = "Empate por afogamento!" draw_repetition = "Empate por repetição!" draw_moverule = ["Empate pela regra dos ", "-move-rule!"] draw_insuffmat = "Empate por material insuficiente!" draw_agreement = "Empate por acordo!" draw_generic = "Empate!" aborted = "Partida cancelada." opponent_checkmate = "Você perdeu por Xeque-mate!" opponent_time = "Você venceu por tempo!" opponen_resignation = "Você perdeu por desistência!" opponent_disconnect = "Você perdeu por desconexão!" opponent_royalcapture = "Você perdeu por captura da realeza!" opponent_allroyalscaptured = "Você perdeu por pendurar todas as peças da realeza!" opponent_allpiecescaptured = "Você perdeu por pendurar todas as peças!" opponent_koth = "Você perdeu por Rei da Colina!" opponent_generic = "Você perdeu!" white_checkmate = "Brancas vencem por xeque-mate!" black_checkmate = "Pretas vencem por xeque-mate!" white_time = "Brancas vencem por tempo!" black_time = "Pretas vencem por tempo!" white_resignation = "Brancas vencem por desistência!" black_resignation = "Pretas vencem por desistência!" white_disconnect = "Brancas vencem por desconexão!" black_disconnect = "Pretas vencem por desconexão!" white_royalcapture = "Brancas vencem por captura da realeza!" black_royalcapture = "Pretas vencem por captura da realeza!" white_allroyalscaptured = "Brancas vencem por capturar todas as peças da realeza!" black_allroyalscaptured = "Pretas vencem por capturar todas as peças da realeza!" white_allpiecescaptured = "Brancas vencem por capturar todas as peças!" black_allpiecescaptured = "Pretas vencem por capturar todas as peças!" white_koth = "Brancas ganham por Rei da Colina!" black_koth = "Pretas ganham por Rei da Colina!" bug_generic = "Isso é um bug, por favor, informe!" [terms] # Traduções estão desativadas por enquanto, a única lingua permitida é en-US title = "Termos de Serviço" warning = ["ESTE DOCUMENTO NÃO É JURIDICAMENTE VINCULATIVO. Somos responsáveis apenas pela versão em inglês deste documento. Esta tradução é fornecida apenas para fins informativos gerais. Você pode acessar a versão oficial em inglês ", "aqui", "."] consent = "Ao usar este site, você concorda em cumprir os seguintes termos. Se não concordar, você deve parar imediatamente de usar o site." guardian_consent = "Se você for menor de 18 anos, deverá receber o consentimento de um dos pais ou responsável legal para usar este site e criar uma conta." parents_header = "Pais" parents_paragraphs = [ "Há um algoritmo em vigor para proibir que os usuários definam seus nomes com palavrões comuns. No momento, não há nenhum método de comunicação entre os membros do site.", "Atualmente, os membros não podem definir sua própria foto de perfil. Há um plano para permitir esse recurso. Nesse momento, faremos o possível para evitar fotos de perfil inadequadas", ] fair_play_header = "Jogo limpo" fair_play_paragraph1 = ["Não é possível criar mais de uma conta."] fair_play_paragraph2 = "Para manter o jogo divertido e justo para todos, você NÃO deve:" fair_play_rules = [ "Modificar ou manipular o código de qualquer forma, incluindo, mas não se limitando a: Usar comandos de console, substituições locais, scripts personalizados, modificar solicitações http, etc. Isso pode ser feito para interromper intencionalmente o jogo ou para lhe dar uma vantagem.", "Em jogos classificados, receba ajuda/aconselhamento de outra pessoa ou programa sobre o que você deve jogar. (Criar um mecanismo é aceitável e incentivado, mas você deve limitar seu uso a jogos sem classificação)", "Trocar pontos de elo com outras pessoas, perdendo propositalmente com a intenção de aumentar o elo de seu oponente ou recebendo pontos de elo de um oponente que pretende perder para aumentar sua própria classificação. Isso abusa do sistema e cria classificações imprecisas de acordo com seu nível de habilidade." ] cleanliness_header = "Cavalheirismo" cleanliness_rules = [ "Em toda a sua linguagem no site, você deve permanecer limpo, sem vulgaridade ou xingamentos. Você não pode intimidar, assediar ou ameaçar ninguém, nem fazer nada que seja ilegal. Não é permitido enviar spam a outros usuários ou fóruns.", "Você não pode carregar imagens em seu perfil que sejam inadequadas, sugestivas ou sangrentas. Fazer isso pode resultar em banimento ou encerramento de sua conta." ] privacy_header = "Privacidade" privacy_rules = [ "Atualmente, as únicas informações pessoais que coletamos são o e-mail. A intenção é verificar as contas dos usuários e fornecer um meio de provar quem eles são quando solicitam uma redefinição de senha. Não enviamos nenhum e-mail promocional ou ofertas. Não compartilhamos o endereço de e-mail de nenhum usuário com ninguém.", "InfiniteChess.org pode coletar dados sobre seu uso no site, incluindo seu endereço IP. O objetivo é ajudar a prevenir ataques de bots e outras entidades indesejadas e manter estatísticas precisas no banco de dados. Este NÃO é o seu endereço residencial.", "Todos os jogos que você joga neste site se tornam informações públicas. Se desejar permanecer anônimo, não compartilhe seu nome de usuário com amigos ou familiares. Se esse for o seu desejo, é sua responsabilidade garantir que ninguém descubra que seu nome de usuário está associado à sua identidade humana.", "O status on-line de sua conta e a última vez que esteve ativo no site também são informações públicas.", ["Embora o InfiniteChess.org se esforce para manter a conta e as informações pessoais de todos seguras da melhor forma possível, no caso de uma invasão ou vazamento de dados, você não pode nos acusar. Se ocorrer um vazamento de dados, os usuários serão notificados na página ", "Notícias", "."], "Não há conteúdo disponível no site para compra. Nenhuma outra informação pessoal é coletada.", "Para que suas informações privadas sejam excluídas de nossos servidores, você pode excluir sua conta por meio da página de perfil. A única coisa vinculada ao seu nome de usuário que NÃO será excluída é o seu histórico de jogos, pois todos os jogos são informações públicas.", ] cookie_header = "Políticas de Cookies" cookie_paragraphs = [ "Este site usa cookies, que são pequenos arquivos de texto armazenados em seu navegador e enviados ao servidor quando são feitas conexões. A finalidade desses cookies é: Validar sua sessão de login, validar que seu navegador pertence ao jogo de xadrez em que diz estar e armazenar as preferências de jogo do usuário para que ele possa manter suas preferências quando visitar o site novamente. O site não usa cookies de terceiros e os cookies não são compartilhados com terceiros.", "Os cookies são necessários para que este site e o jogo funcionem corretamente. Se não quiser que o site armazene cookies, você deverá parar de usar o site. Você pode navegar até as preferências do seu navegador para excluir os cookies existentes. Ao continuar a usar este site, você estará consentindo com o uso de cookies." ] conclusion_header = "Conclusão" conclusion_paragraphs = [ "Qualquer violação desses termos poderá resultar em banimento ou encerramento de sua conta. O InfiniteChess.org quer dar a todos a oportunidade de jogar e se divertir! No entanto, reservamo-nos o direito de, a qualquer momento, banir ou encerrar as contas de qualquer usuário, por motivos que não precisam ser divulgados. Não podem ser feitas acusações contra nós.", ["Estes termos de serviço podem ser modificados a qualquer momento. É SUA responsabilidade garantir que você esteja atualizado sobre as últimas alterações! Quando estes termos de serviço receberem uma atualização, essa informação será publicada na página", "Notícias", ". Se, no momento da atualização dos termos de serviço, você não concordar com os novos termos, deverá interromper imediatamente o uso do site. Você pode excluir sua conta na sua página de perfil. Se você excluir sua conta, todas as suas informações privadas e dados da conta serão excluídos, EXCETO que não excluiremos o histórico de jogos associado ao seu nome de usuário, que é uma informação pública."], ["Este site é de código aberto. Você pode copiar ou distribuir qualquer conteúdo deste site, desde que siga as condições descritas em", "os termos da licença", "! Se esse link estiver quebrado, é sua responsabilidade encontrar os meios."], "Não podemos garantir que o site estará funcionando 100% do tempo. Também não podemos garantir que os dados nunca serão corrompidos.", "Você não pode realizar nenhuma atividade ilegal no site.", ["Se tiver alguma dúvida sobre esses termos ou qualquer outra questão sobre o site,", "envie-nos um e-mail!"] ] update = "(Última atualização: 7/13/24. Adicionado o aviso de que todos os jogos jogados podem se tornar informações públicas, incluindo o último tempo aproximado em que sua conta esteve ativa. Além disso, esses termos podem ser atualizados a qualquer momento, e é de sua responsabilidade manter-se atualizado)." thanks = "Muito obrigado!" [login] title = "Entrar" # The tab name username = "Usuário:" password = "Senha:" login_button = "Entrar" send_reset_link = "Enviar Link de Redefinição" forgot_question = "Esqueceu a Senha?" back_to_login = "Voltar ao Login" forgot_instruction = "Por favor, insira o endereço de e-mail associado à sua conta." [login.javascript] network-error = "A network error occurred. Please try again." [reset_password] title = "Redefinir sua Senha" instruction = "Por favor digite sua Senha e confirme sua nova Senha." new_password = "Nova Senha" confirm_password = "Confirmar Senha" submit_button = "Redefinir sua Senha" [error-pages] # Mensagens de erro mostradas em algumas páginas, explicando o que houve de errado 400_message = "Parâmetros inválidos foram recebidos." 409_message = ["Pode ter havido um conflito de nome de usuário ou e-mail. Por favor ", "recarregue", ", ta página."] 500_message = "Isso não deveria acontecer. Há algum debug a ser feito!" [news] title = "Notícias" more_dev_logs = ["Mais registros de desenvolvimento são postados no ", "discord oficial", ", e nos ", "fóruns do chess.com!"] [server.javascript] ws-invalid_username = "Nome de Usuário é inválido" ws-incorrect_password = "Senha incorreta" ws-login_failure_retry_in = "Falha no login, tente novamente em" ws-seconds = "segundos" ws-second = "segundo" ws-username_length = "Nome de usuário deve ter de 3 a 20 caracteres" ws-username_letters = "Nome de usuário deve conter apenas letras de A-Z e números de 0-9" ws-username_taken = "Esse nome de usuário já está em uso" ws-username_bad_word = "Esse nome de usuário contém uma palavra que não é permitida" ws-username_reserved = "Esse nome de usuário é reservado" ws-email_too_long = "Seu e-mail é muito loooooooongo." ws-email_invalid = "Este não é um e-mail válido" ws-email_in_use = "Este e-mail já está em uso" ws-email_domain_invalid = "Domínio Inválido." ws-you_are_banned = "Você foi banido." ws-password_length = "A senha deve ter entre 6-72 caracteres" ws-password_format = "A senha está em um formato incorreto" ws-password_password = "A senha não pode ser 'password'" ws-password-reset-link-sent = "Se existir uma conta com esse e-mail, um link de redefinição de senha foi enviado." ws-password-change-success = "A senha foi redefinida com sucesso. Você será redirecionado para a página de login em breve." ws-password-reset-token-invalid = "O token de redefinição de senha é inválido ou expirou." ws-forbidden_wrong_account = "Proibido. Esta não é a sua conta." ws-deleting_account_not_found = "Falha ao excluir a conta. Conta não encontrada." ws-deleting_account_in_game = "Você não pode excluir sua conta enquanto estiver conectado a um jogo online." ws-server_error = "Desculpe, ocorreu um erro no servidor! Por favor, volte." ws-not_found = "404 Não Encontrado" ws-forbidden = "Proibido." ws-already_in_game = "Você já está em um jogo." ws-server_restarting = "O servidor reiniciará em" ws-server_under_maintenance = "O servidor está em manutenção. Verifique novamente mais tarde" ws-minutes = "minutos" ws-minute = "minuto" ws-game_aborted_cheating = "Jogo abortado por provável trapaça." ws-cannot_resign_finished_game = "Não é possível desistir do jogo, ele já terminou." ws-invalid_code = "Código Inválido" ws-game_aborted = "Jogo abortado." ws-rated_invite_verification_needed = "Para jogar ranqueado, você precisa estar logado em uma conta verificada." ================================================ FILE: translation/ru-RU.toml ================================================ name = "Русский" # Name of language english_name = "Russian" direction = "ltr" # Change to "rtl" for right to left languages version = "76" maintainer = "Function f(x)" [header] home = "Бесконечные Шахматы" play = "Играть" news = "Новости" login = "Войти" profile = "Профиль" createaccount = "Создать аккаунт" logout = "Выйти" leaderboard = "Таблица лидеров" [header.settings] language = "Язык" appearance = "Внешний вид" # Board color/theme and visual effects appearance-theme = "Тема" appearance-starfield = "Звёздное небо" # The Starfield space animation underneath void appearance-advanced-effects = "Расширенные эффекты" # Post processing and board tile effects at extreme distances legalmoves = "Легальные ходы" # Legal moves shape legalmoves-squares = "Квадраты" legalmoves-dots = "Точки" # Dots and 4 corner triangles selection = "Выбор" selection-drag = "Перетаскивание фигур" selection-premove = "Премувы" selection-animations = "Анимации" selection-lingering_annotations = "Постоянные аннотации" perspective = "Перспектива" # Perspective-mode perspective-mouse-sensitivity = "Чувствительность мыши" perspective-fov = "Поле зрения" sound = "Звук" sound-master-volume = "Общая громкость" sound-ambience = "Атмосфера" ping = ["Пинг", "мс"] # A number is inserted between these 2 strings. reset-to-default = "Сброс настроек" [footer] contact = "Связаться с нами" terms_of_service = "Условия использования" source_code = "Исходный код" language = "Язык" [member.javascript] js-confirm_delete = "Вы уверены, что хотите удалить свою учётную запись? Это действие НЕВОЗМОЖНО отменить! Нажмите «Да», чтобы ввести пароль." js-enter_password = "Введите пароль чтобы удалить свой аккаунт НАВСЕГДА:" [leaderboard.javascript] supported_variants = "Эта таблица лидеров используется для следующих вариантов:" rank = "Место" player = "Игрок" rating = "Рейтинг" [index] title = "Бесконечные Шахматы | Домашняя страница - Официальный сайт" # The tab title secondary_title = "Официальный сайт для игры онлайн!" what_is_it_title = "Что это?" what_is_it_pargaraphs = [ "Бесконечные Шахматы это шахматный вариант где нет границ, гораздо больше чем твоя привычка доска 8x8. У ферзя, ладей и слонов нет предела как далеко они могут пойти за один ход. Выбери любое натуральное число до бесконечности!", "Без предела как далеко можно ходить, возможны позиции где часы Судного дня, или мат-в-пробел, числа представленного первым бесконечным ординалом, омегой ω. Более того, исследователи обнаружили что любой счётный ординал достижим для матовых часов!", "Как ты можешь представить, здесь бесконечность возможностей для стартовых конфигураций, большинство из которых ты можешь полностью испытать! Твоя главная цель это всё ещё мат, который теперь требует новой тактики так как теперь нет стен где можно заманить в ловушку вражеского короля. Партии обычно длятся ненамного дольше обычных шахматных. Пешки так же превращаются на 1й и 8й горизонталях!", ] how_to_title = "Как я могу играть?" how_to_paragraph = ["Текущая версия релиза это 1.10 на странице ","Играть","!"] about_title = "Про проект" about_paragraphs = [ "Я Naviary. С того момента когда я придумал Бесконечные Шахматы (эта концепция существовала задолго до этого сайта), они и их возможности меня очень заинтриговали! До недавнего времени играть было довольно сложно, поскольку игрокам приходилось создавать изображения текущей доски и отправлять их друг другу для каждого сделанного хода. Поэтому не так много людей знали или имели возможность в это сыграть.", ["Моя цель — сделать игру доступной для всех и сформировать вокруг нее сообщество. Я провёл бесчисленное количество часов своего времени на этом сайте, поддерживая его и разрабатывая игру. У меня ещё много идей, которые займут меня надолго. Хоть я и хочу, чтобы игра была бесплатной, жизнь имеет свои особенности. Чтобы поддержать меня финансово, пожалуйста, рассмотрите возможность присоединиться к моему ", "Patreon", "."] # Patreon receives a hyperlink, here ] patreon_title = "Поддержавшие на Patreon" github_title = "Участники Github" [index.javascript] contribution_count_singular = ["", " участник"] # A number is inserted between these 2 strings. contribution_count_plural = ["", " участников"] [credits] title = "Благодарности" copyright = "Все материалы на сайте, не указанные ниже, являются объектом авторского права www.InfiniteChess.org." variants_heading = "Варианты" variants_credits = [ "Ядро разработал Andreas Tsevas.", "Космос разработал Andreas Tsevas.", "Классический космос разработал Andreas Tsevas.", "Шахматы на бесконечной плоскости разработал V. Reinhart.", "Пешечную орду разработал Inaccessible Cardinal.", "Изобилие разработал Clicktuck Suskriberz.", "Пешкандард - SexyLexi.", "Классические+ - SexyLexi.", "Линия коней - Inaccessible Cardinal.", "Конные шахматы - cycy98.", " разработали Cory Evans и Joel Hamkins.", " разработал Andreas Tsevas.", " разработал Cory Evans и Joel Hamkins.", " разработали Cory Evans, Joel Hamkins, и Norman Lewis Perlmutter.", "Шахматы на бесконечной плоскости - Вариант с Гюйгенсом - V. Reinhart.", "Ограниченные классические - Andreas Tsevas.", "Шахматы 4x4x4x4 - Andreas Tsevas.", "5D Шахматы - Jace.", ] textures_heading = "Текстуры" textures_licensed_under = "текстуры лицендированы под" sounds_heading = "Звуки" sounds_credits = [ ["Некоторые звуки предоставлены", "проект в рамках"], "Другие звуки созданы Naviary.", ] code_heading = "Код" code_credits = [ "Brandon Jones и Colin MacKenzie IV.", "Andreas Tsevas и Naviary.", ] language_heading = "Переводы на языки" language_credits = [ # The strings below that contain ONLY a username will receive a hyperlink. Strings may be left empty, but not excluded. "Французский - ", "Life Enjoyer", " и ", "cycy98", ".", "Упрощённый китайский - ", "Heinrich Xiao", ".", "Традиционный китайский - ", "Heinrich Xiao", ".", "Польский - ", "Tymon Becella", ".", # Apsurt "Португальский - ", "Emerson P. Machado", ".", # The_Skeleton on discord "Испанский - ", "xa31er", ".", "Немецкий - ", "Estetique", "." ] [member] title = "Пользователь" # The tab name verify_message = "Пожалуйста проверьте вашу электронную почту чтобы верифицировать ваш аккаунт. Не верифицированные аккаунты удаляются через 3 дня." resend_message = ["Не получили письмо? Проверьте папку \"Спам\". Также можно ", "отправить его заново.", " Если вы всё равно не можете найти, ", "напишите нам."] verify_confirm = "Спасибо! Ваш аккаунт был верифицирован." joined = "Присоединился:" seen = ["Был в сети:", " назад"] practice_progress = "Прогресс в режиме практики:" ranked_elo = "Рейтинг:" infinity_leaderboard_position = "Место в мировом рейтинге:" infinity_leaderboard_rating_deviation = "Отклонение рейтинга:" reveal_info = "Показать информацию об аккаунте" account_info_heading = "Информация об аккаунте" email = "Электронная почта:" delete_account = "Удалить аккаунт" [member.badge-tooltips] checkmate_bronze = "Ветеран мата: Пройти 50% от всех матов в практике." checkmate_silver = "Матовый про: Пройти 75% от всех матов в практике." checkmate_gold = "Шахматный мастер: Пройти 100% от всех матов в практике." [create-account] title = "Регистрация" # The tab name username = "Имя пользователя:" email = "Электронная почта:" password = "Пароль:" create_button = "Зарегистрироваться" agreement = ["Я соглашаюсь с ", "Условиями использования", "."] # the middle entry is a hyperlink, the others are not [create-account.javascript] js-username_specs = "Имя пользователя должно быть как минимум 3 символа, и содержать только буквы A-Z и цифры 0-9" js-username_tooshort = "Имя пользователя должно быть как минимум 3 символа" js-username_wrongenc = "Имя пользователя должно содержать только буквы A-Z и цифры 0-9" js-email_invalid = "Это некорректная электронная почта" js-email_inuse = "Эта электронная почта уже используется" [reset-password.javascript] js-pwd_no_match = "Пароли не совпадают." reset-password = "Сбросить пароль" processing = "Обработка..." network-error = "Произошла сетевая ошибка. Попробуйте еще раз." [password-validation] js-pwd_incorrect_format = "Пароль имеет неверный формат" js-pwd_too_short = "Пароль должен содержать не менее 6 символов" js-pwd_too_long = "Пароль не может быть длиннее 72 символов" js-pwd_not_pwd = "Пароль не должен быть словом 'password'" [leaderboard] title = "Таблица лидеров" inactive_players = "Неактивные игроки с высокой погрешностью рейтинга не включаются в таблицу лидеров." your_global_ranking = "Ваше место в мировом рейтинге:" show_more = "Показать больше..." [play] title = "Бесконечные Шахматы - Играть" # The tab title loading = "ЗАГРУЗКА" error = "ОШИБКА" [play.main-menu] credits = "Благодарности" play = "Играть" practice = "Практика" guide = "Гайд" editor = "Редактор доски" [play.guide] title = "Гайд" rules = "Правила" rules_paragraphs = [ "Правила бесконечных шахмат почти идентичны классическим, за исключением того, что доска бесконечна во всех направлениях! Вот все особенности и изменения, о которых вам нужно знать:", "Фигуры с дальнобойными ходами, такие как ладьи, слоны и ферзь, не имеют ограничений по расстоянию за один ход! Пока их путь не перекрыт, вы можете переместиться на миллионы клеток!", ["В стандартном варианте \"Classical\" белые пешки превращаются на 8-й горизонтали, а чёрные — на 1-й. На изображении это обозначено тонкими чёрными линиями — они едва заметны, попробуйте их найти! Пешкам нужно лишь достичь противоположной линии для превращения, ", "не", " пересечь её."], "Клетки больше не обозначаются буквами и цифрами (например, a1); вместо этого каждая клетка определяется парой координат x и y. Клетка a1 стала (1,1), а h8 — (8,8). На десктопных устройствах координаты клетки под курсором отображаются в верхней части экрана.", "Все остальные правила такие же, как в классических шахматах: мат, пат, троекратное повторение позиции, правило 50 ходов, рокировка, мечта каждой пешки - EN PASSANT, и так далее!" ] careful_heading = "Осторожно!" careful_paragraphs = [ "Открытость бесконечной доски делает вилки, связки и линейные удары особенно опасными. Ваши тыловые позиции часто уязвимы. Будьте начеку против таких тактических приёмов! Проявляйте изобретательность в защите короля и ладей! Стратегия дебюта здесь совершенно иная, чем в классических шахматах.", "Многие другие варианты были созданы специально для усиления защиты тыловых позиций." ] controls_heading = "Управление" controls_paragraph = "Кликните и перетаскивайте доску, чтобы перемещаться. Используйте колесо мыши для увеличения и уменьшения масштаба. Кликните на любую фигуру, включая фигуры противника, чтобы в любой момент увидеть её возможные ходы! Дополнительные элементы управления:" keybinds = [ "Стрелки для перемещения по доске.", ["Пробел", " и ", "Shift", " для увеличения и уменьшения масштаба."], ["Escape", " для паузы в игре."], ["Tab", " переключает индикаторы-стрелки на краях экрана, указывающие на фигуры за пределами видимой области. По умолчанию этот режим установлен в «Защита», что отображает стрелки, указывающие на все фигуры, которые могут переместиться на ваш экран в их направлении движения. Но ", "Tab", " может переключить этот режим в «Все» или «Выкл»; «Все» отображает стрелки для всех фигур, независимо от того, могут ли они переместиться на ваш экран. Эту настройку также можно изменить в меню паузы. Клик по стрелке мгновенно переместит вас к фигуре, на которую она указывает."], ["Control", " будет принудительно перемещать доску вместо перетаскивания фигуры, если перетаскивание включено в настройках."], " переключает «Режим редактирования» в локальных играх. Это позволяет перемещать любую фигуру в любое место на доске! Очень полезно для анализа." ] controls_paragraph2 = "Это основные элементы управления, которые вам нужно знать. Но вот некоторые дополнительные возможности, если они вам когда-нибудь понадобятся!" keybinds_extra = [ " сбросит отображение фигур. Это полезно, если они становятся невидимыми. Такой глюк может возникнуть при перемещении на экстремальные расстояния (например, 1e21).", " переключает отображение панелей навигации и игровой информации, что может быть полезно для записи. Стриминг и создание видео с игрой приветствуется!", " переключает отображение FPS. Это показывает, сколько раз в секунду обновляется игра, а не всегда количество отрендеренных кадров, так как игра пропускает рендеринг, когда ничего видимого не изменилось, чтобы повысить производительность.", " переключает отображение иконок фигур. Это миниатюрные кликабельные изображения фигур, появляющиеся при достаточном отдалении. В импортированных играх с более чем 50 000 фигур эта функция автоматически отключается, так как значительно снижает производительность, но их можно снова включить с помощью ", ["Ё", " переключает режим отладки."] ] fairy_heading = "Нестандартные фигуры" fairy_paragraph = "Вы уже знаете всё необходимое для игры в стандартный вариант «Классические». Нестандартные шахматные фигуры не используются в обычных шахматах, но применяются в других вариантах! Если вы окажетесь в варианте с фигурами, которые вы раньше не видели, узнайте, как они работают, здесь!" editing_heading = "Редактирование доски" editing_paragraphs = [ ["Внешний ", "редактор доски", " в настоящее время доступен в общедоступной Google Таблице! Там есть инструкции по использованию. Это требует базовых знаний Google Таблиц. После настройки вы сможете создавать и импортировать пользовательские позиции в игру через кнопку «Вставить игру» в меню настроек!"], "Чтобы сыграть в пользовательскую позицию с другом, попросите его присоединиться к приватной игре по приглашению, а затем оба можете вставить код игры, чтобы начать играть!", "Встроенный редактор доски всё ещё находится в планах." ] back = "Назад" [play.guide.pieces] chancellor = {name="Канцлер", description="Ходит как ладья и конь вместе."} archbishop = {name="Архиепископ", description="Ходит как слон и конь вместе."} amazon = {name="Амазон", description="Ходит как ферзь и конь вместе. Это самая сильная фигура в игре!"} guard = {name="Страж", description="Ходит как король, но не может находиться под шахом или матом."} hawk = {name="Ястреб", description="Совершает прыжки ровно на 2 или 3 клетки в любом направлении."} centaur = {name="Кентавр", description="Ходит как конь и страж вместе."} knightrider = {name="Гиперконь", description="Совершает бесконечные прыжки как конь в одном направлении до препятствия."} huygen = {name="Гюйгенс", description="Прыгает бесконечно в одном из четырех основных направлений, посещая только клетки на расстоянии простого числа от начальной позиции, до препятствия."} rose = {name="Роза", description="Круговой гиперконь. Перемещается по часовой и против часовой стрелки по круговым траекториям, совершая прыжки как конь и поворачивая на 45 градусов после каждого прыжка. Может быть заблокирован другими фигурами, поэтому красная клетка на изображении недоступна для розы."} obstacle = {name="Препятствие", description="Нейтральная фигура (не контролируется ни одним игроком), которая блокирует движение, но может быть взята."} void = {name="Пустота", description="Нейтральная фигура (не контролируется ни одним игроком), представляющая отсутствие доски. Фигуры не могут двигаться через нее или на нее."} [play.practice-menu] title = "Практика - Мат одинокому королю" play = "Играть" back = "Назад" difficulty = "Сложность" [play.play-menu] title = "Играть - Онлайн" colors = "Цвета" online = "Онлайн" local = "Локально" computer = "Компьютер" variant = "Вариант" Classical = "Классические" Confined_Classical = "Ограниченные Классические" Classical_Plus = "Классические+" CoaIP = "Шахматы на бесконечной плоскости" Pawndard = "Пешкандард" Knighted_Chess = "Конные шахматы" Palace = "Дворец" Knightline = "Линия коней" Core = "Ядро" Standarch = "Стандархиепископ" Pawn_Horde = "Пешечная орда" Space_Classic = "Классический космос" Space = "Космос" Obstocean = "Препятствокеан" Abundance = "Изобилие" Amazon_Chandelier = "Люстра амазонов" Containment = "Сдерживание" Classical_Limit_7 = "Классические - Лимит 7" CoaIP_Limit_7 = "Шахматы на бесконечной плоскости - Лимит 7" Chess = "Шахматы" Classical_KOTH = "Экспериментальный: Классические - Царь горы" CoaIP_KOTH = "Экспериментальный: Шахматы на бесконечной плоскости - Царь горы" CoaIP_HO = "Шахматы на бесконечной плоскости - Вариант с Гюйгенсом" CoaIP_RO = "Шахматы на бесконечной плоскости - Вариант с Розой" CoaIP_NO = "Шахматы на бесконечной плоскости - Вариант с Гиперконём" Omega = "Демонстрация: Омега" Omega_Squared = "Демонстрация: Омега^2" Omega_Cubed = "Демонстрация: Омега^3" Omega_Fourth = "Демонстрация: Омега^4" 4x4x4x4_Chess = "Шахматы 4×4×4×4" 5D_Chess = "5D Шахматы" no_clock = "Без часов" clock = "Часы" minutes = "мин" seconds = "сек" infinite_time = "Бесконечно времени!" color = "Цвет" piece_colors = ["Случайный", "Белые", "Чёрные"] private = "Приватная" no = "Нет" yes = "Да" rated = "Рейтинговая" casual = "Казуальная" join_games = "Зайти в существующую - Активные игры:" private_invite = "Приватное приглашение:" your_invite = "Ваш код приглашения:" create_invite = "Создать приглашение" join = "Зайти" copy = "Скопировать" back = "Назад" code = "Код" [play.gamebuttontooltips] undo_transition = "Отменить переход" expand_fit_all = "Расширить чтобы поместилось всё" recenter = "Повторно центрировать" annotations = "Рисовать аннотации" erase = "Стереть аннотации" collapse = "Свернуть аннотации" rewind_move = "Предыдущий ход" forward_move = "Следующий ход" undo_edit = "Отменить редактирование (Ctrl+Z)" # Board editor redo_edit = "Вернуть редактирование (Ctrl+Y)" # Board editor pause = "Пауза" undo = "Отменить ход" # Checkmate practice game restart = "Перезапустить партию" # Checkmate practice game [play.pause] title = "На паузе" resume = "Возобновить" arrows = "Стрелки: Защита" perspective = "Перспектива: Выкл" copy = "Скопировать игру" paste = "Вставить игру" offer_draw = "Предложить ничью" practice_menu = "Меню практики" main_menu = "Главное меню" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "Принять ничью?" [play.javascript] # Not text that's included in the html, but text that scripts use! guest_indicator = "(Гость)" you_indicator = "(Вы)" engine_indicator = "Движок" player_name_white_generic = "Белые" player_name_black_generic = "Чёрные" white_to_move = "Ход белых" black_to_move = "Ход чёрных" your_move = "Ваш ход" their_move = "Ход противника" lost_network = "Потеряна сеть." failed_to_load = "Не удалось загрузить один или несколько ресурсов. Обновите страницу." planned_feature = "Эта функция запланирована!" main_menu = "Главное меню" resign_game = "Сдаться " abort_game = "Отменить игру" offer_draw = "Предоожить ничью" # Offer draw button text in the pause menu accept_draw = "Принять ничью" # Offer draw button text in the pause menu arrows_off = "Стрелки: Выкл" arrows_defense = "Стрелки: Защита" arrows_all = "Стрелки: Все" arrows_all_hippogonals = "Стрелки: Все (с гиппогонами)" toggled = "Переключено" menu_online = "Играть - Онлайн" menu_local = "Играть - Локально" invite_error_digits = "Код приглашения должен состоять из 5 цифр." invite_copied = "Код приглашения скопирован в буфер обмена." move_counter = "Ход:" constructing_mesh = "Построение сетки" rotating_mesh = "Поворот сетки" lost_connection = "Потерянно соединение." please_wait = "Пожалуйста, подождите немного, чтобы выполнить эту задачу." webgl_unsupported = "Пожалуйста, обновите ваш браузер! Он не поддерживает WebGL2." bigints_unsupported = "BigInt не поддерживается. Обновите браузер.\nBigInt нужны, чтобы сделать доску бесконечной." # Checkmate Practice versus = "против" easy = "Лёгкий" medium = "Средний" hard = "Сложный" insane = "Безумный" checkmate_logged_out = "Чтобы получить значки, вам необходимо войти в систему." checkmate_bronze = "Ветеран мата: Поставить 50% от всех матов в практике." checkmate_silver = "Матовый про: Поставить 75% от всех матов в практике." checkmate_gold = "Шахматный Мастер: Поставить все маты в практике." checkmate_bronze_unearned = "Поставьте 50% всех матов в практике, чтобы получить этот значок." checkmate_silver_unearned = "Поставьте 75% всех матов в практике, чтобы получить этот значок." checkmate_gold_unearned = "Поставьте все маты в практике, чтобы получить этот значок." coords-invalid = "Неверный формат координат. Введите целые числа или e-нотацию (например 1.23e4)." coords-exceeded = "Ты не можешь телепортироваться так далеко! Это было бы слишком просто ;)" [play.javascript.piecenames] # The string representations of each raw piece type, as found in typeutil.strtypes void = "Пустота" obstacle = "Препятствие" king = "Король" giraffe = "Жираф" camel = "Верблюд" zebra = "Зебра" knightrider = "Гиперконь" amazon = "Амазон" queen = "Ферзь" royalQueen = "Королевский ферзь" hawk = "Ястреб" chancellor = "Канцлер" archbishop = "Архиепископ" centaur = "Центавр" royalCentaur = "Королевский центавр" rose = "Роза" knight = "Конь" guard = "Страж" huygen = "Гюйгенс" rook = "Ладья" bishop = "Слон" pawn = "Пешка" [play.javascript.copypaste] copied_game = "Партия скопирована в буфер обмена!" cannot_paste_in_public = "Невозможно вставить партию в публичный матч!" cannot_paste_in_rated = "Невозможно вставить партию в рейтинговый матч!" cannot_paste_in_engine = "Невозможно вставить партию в матч с движком!" cannot_paste_after_moves = "Невозможно вставить партию после того, как сделаны ходы!" clipboard_denied = "Отказано в доступе к буферу обмена. Возможно, проблема в вашем браузере." clipboard_invalid = "Буфер обмена не имеет допустимой нотации ICN." game_needs_to_specify = "В партии необходимо указать либо метаданные 'Variant', либо свойство 'positon'." invalid_wincon = "У игрока неверное условие выигрыша" pasting_game = "Вставка партии..." pasting_in_private = "Вставка партии в приватный матч приведет к рассинхронизации, если ваш противник не сделает того же самого!" piece_count = "Количество фигур" exceeded = "превышен" changed_wincon = "Изменены условия победы: вместо мата используется захват королевской фигуры, а также отключено отображение иконок. Нажмите 'P', чтобы включить обратно (не рекомендуется)." loaded_from_clipboard = "Загружена партия из буфера обмена!" copied_position = "Позиция скопирована в буфер обмена!" loaded_position_from_clipboard = "Загружена позиция из буфера обмена" reset_position = "Позиция была сброшена!" clear_position = "Позиция была очищена!" [play.javascript.rendering] on = "Вкл" off = "Выкл" icon_rendering_off = "Отключено отображение значков." icon_rendering_on = "Включено отображение значков." perspective = "Перспектива" perspective_mode_on_desktop = "Режим перспективы доступен на десктопе!" movement_tutorial = "WASD чтобы двигаться. Пробел и shift для масштабирования" regenerated_pieces = "Фигуры регенерированы" [play.javascript.invites] move_mouse = "Для повторного подключения переместите мышь." cannot_cancel = "Невозможно отменить приглашение с неопределенным ИД." you_are_white = "Вы: Белые" you_are_black = "Вы: Чёрные" random = "Случайно" accept = "Принять" cancel = "Закрыть" create_invite = "Создать приглашение" cancel_invite = "Закрыть приглашение" start_game = "Начать игру" join_existing_active_games = "Зайти в существующую - Активные игры:" [play.javascript.onlinegame] afk_warning = "Вы в AFK." opponent_afk = "Противник в AFK." opponent_disconnected = "Противник отключился." opponent_lost_connection = "Противник потерял соединение." auto_resigning_in = "Авто-сдача через" auto_aborting_in = "Авто-отмена через" not_logged_in = "Вы не вошли в систему. Пожалуйста, войдите в систему, чтобы повторно подключиться к этой партии." game_no_longer_exists = "Партия больше не существует" another_window_connected = "Подключилось еще одно окно." server_restarting = "Сервер скоро перезагрузится..." server_restarting_in = "Сервер перезагрузится через" minute = "минуту" minutes = "минут" [play.javascript.websocket] no_connection = "Нет соединения." reconnected = "Переподключено." unable_to_identify_ip = "Не удалось определить IP." online_play_disabled = "Онлайн-игра отключена. Файлы cookie не поддерживаются. Попробуйте другой браузер." too_many_requests = "Слишком много запросов. Попробуйте ещё раз." message_too_big = "Сообщение слишком большое." too_many_sockets = "Слишком много соединений" origin_error = "Ошибка происхождения." connection_closed = "Соединение неожиданно разорвано. Сообщение сервера:" please_report_bug = "Этого никогда не должно происходить, пожалуйста, сообщите об этой ошибке!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "Мат" stalemate = "Пат" repetition = "Троекратное повторение" moverule = ["Правило ", " ходов"] # The game inserts a number inbetween these two strings insuffmat = "Недостаток материала для мата" royalcapture = "Взята королевская фигура" allroyalscaptured = "Взяты все королевские фигуры" allpiecescaptured = "Взяты все фигуры" koth = "Царь на горе" resignation = "Сдача" agreement = "Соглашение" time = "Время вышло" aborted = "Отменена" # Game was cancelled (no elo exchanged) disconnect = "Противник вышел" # A player left [play.javascript.results] you_checkmate = "Вы выиграли - объявлен мат!" you_time = "Вы выиграли по времени!" you_resignation = "Вы выиграли - противник сдался!" you_disconnect = "Вы выиграли - противник вышел!" you_royalcapture = "Вы выиграли - взята королевская фигура!" you_allroyalscaptured = "Вы выиграли - взяты все королевские фигуры!" you_allpiecescaptured = "Вы выиграли - взяты все фигуры!" you_koth = "Вы выиграли - Царь на горе!" you_generic = "Вы выиграли!" draw_stalemate = "Ничья - пат!" draw_repetition = "Ничья - повторение позиции!" draw_moverule = ["Ничья - правило ", " ходов!"] # The game inserts a number inbetween these two strings draw_insuffmat = "Ничья - недостаток материала для мата!" draw_agreement = "Ничья по соглашению!" draw_generic = "Ничья!" aborted = "Партия отменена." opponent_checkmate = "Вы проиграли - объявлен мат!" opponent_time = "Вы проиграли по времени!" opponent_resignation = "Вы проиграли - вы сдались!" opponent_disconnect = "Вы проиграли - вы вышли!" opponent_royalcapture = "Вы проиграли - взята королевская фигура!" opponent_allroyalscaptured = "Вы проиграли - взяты все королевские фигуры!" opponent_allpiecescaptured = "Вы проиграли - взяты все фигуры!" opponent_koth = "Вы проиграли - Царь на горе!" opponent_generic = "Вы проиграли!" white_checkmate = "Белые выиграли - объявлен мат!" black_checkmate = "Чёрные выиграли - объявлен мат!" white_time = "Белые выиграли по времени!" black_time = "Чёрные выиграли по времени!" white_resignation = "Белые выиграли - противник сдался!" black_resignation = "Чёрные выиграли - противник сдался!" white_disconnect = "Белые выиграли - противник вышел!" black_disconnect = "Чёрные выиграли - противник вышел!" white_royalcapture = "Белые выиграли - взята королевская фигура!" black_royalcapture = "Чёрные выиграли - взята королевская фигура!" white_allroyalscaptured = "Белые выиграли - взяты все королевские фигуры!" black_allroyalscaptured = "Чёрные выиграли - взяты все королевские фигуры!" white_allpiecescaptured = "Белые выиграли - взяты все фигуры!" black_allpiecescaptured = "Чёрные выиграли - взяты все фигуры!" white_koth = "Белые выиграли - Царь на горе!" black_koth = "Чёрные выиграли - Царь на горе!" bug_generic = "Это ошибка, пожалуйста, сообщите!" [terms] title = "Условия использования" warning = ["ЭТОТ ДОКУМЕНТ НЕ ИМЕЕТ ЮРИДИЧЕСКОЙ СИЛЫ. Мы несём ответственность только за английскую версию этого документа. Данный перевод предоставляется исключительно в информационных целях. Официальную английскую версию можно найти ", "здесь", "."] consent = "Используя этот сайт, вы соглашаетесь соблюдать следующие условия. Если вы не согласны, вы должны немедленно прекратить использование сайта." guardian_consent = "Если вам меньше 18 лет, вы должны получить согласие родителя или законного представителя на использование сайта и создание учётной записи." parents_header = "Родителям" parents_paragraphs = [ "В системе есть алгоритм, запрещающий пользователям устанавливать в качестве имени распространённые нецензурные слова. В настоящее время на сайте отсутствует возможность общения между участниками.", "В настоящее время участники не могут устанавливать собственные изображения профиля. Планируется добавить эту функцию. Когда это произойдёт, мы сделаем всё возможное, чтобы предотвратить размещение неподходящих изображений профиля." ] fair_play_header = "Честная игра" fair_play_paragraph1 = ["Вы не можете иметь более одной учётной записи."] fair_play_paragraph2 = "Чтобы сохранить игру справедливой и приятной для всех, вам ЗАПРЕЩАЕТСЯ:" fair_play_rules = [ "Изменять или модифицировать код каким-либо образом, включая, помимо прочего: использование консольных команд, локальных переопределений, пользовательских скриптов, изменение HTTP-запросов, сообщений веб-сокетов и т.д. Это может быть сделано для намеренного нарушения работы игры, совершения иначе незаконных ходов или получения преимущества.", "Злоупотреблять ошибками или сбоями в программном обеспечении для прерывания игры, получения преимущества или для того, чтобы сделать игру невозможной для продолжения. Это может включать перемещение фигуры на экстремальные расстояния, например, на 10^15 от центральной точки.", "В рейтинговых играх получать помощь/советы от другого человека или программы относительно того, какой ход делать. (Создание шахматного движка допускается и поощряется, но вы должны ограничить его использование только нерейтинговыми, обычными играми)", "Обмениваться рейтинговыми очками с другими людьми, намеренно проигрывая с целью повышения рейтинга вашего оппонента или получая рейтинговые очки от оппонента, который намеренно проигрывает для повышения вашего рейтинга. Это нарушает систему и создаёт неточные рейтинги, не соответствующие вашему реальному уровню мастерства." ] cleanliness_header = "Корректное поведение" cleanliness_rules = [ "Во всех ваших сообщениях на сайте вы должны сохранять чистоту речи: без вульгарности и нецензурной лексики. Вам запрещается издеваться, преследовать или угрожать кому-либо или совершать что-либо незаконное. Запрещается рассылать спам другим пользователям или на форумах.", "Вы не можете загружать изображения в свой профиль, которые являются неподходящими, откровенными или изображающими насилие. Нарушение этого правила может привести к блокировке или удалению вашей учётной записи." ] privacy_header = "Конфиденциальность" privacy_rules = [ "В настоящее время единственная личная информация, которую мы собираем, — это ваш адрес электронной почты. Это делается с целью верификации учётных записей пользователей и предоставления средства подтверждения вашей личности при запросе сброса пароля. Мы не отправляем рекламные письма или предложения. Мы не передаём адреса электронной почты пользователей третьим лицам.", "InfiniteChess.org может собирать данные о вашем использовании сайта, включая ваш IP-адрес. Это предназначено для предотвращения атак ботов и других нежелательных субъектов, а также для ведения точной статистики в базе данных. Это НЕ ваш домашний адрес.", "Все игры, в которые вы играете на этом веб-сайте, становятся публичной информацией. Если вы хотите оставаться анонимным, не сообщайте своё имя пользователя друзьям или родственникам. Если это ваше желание, вы обязаны убедиться, что никто не узнает, что ваше имя пользователя связано с вашей реальной личностью.", "Статус вашей учётной записи (онлайн/оффлайн) и приблизительное время вашего последнего посещения сайта также являются публичной информацией.", ["Хотя InfiniteChess.org будет стремиться сохранить безопасность учётных записей и личной информации всех пользователей наилучшим образом, в случае взлома или утечки данных вы не можете возбуждать против нас судебное преследование. Если когда-либо произойдёт утечка данных, пользователи будут уведомлены на странице ", "Новости", "."], "На сайте отсутствует контент для покупки. Любая другая личная информация не собирается.", "Чтобы удалить вашу личную информацию с наших серверов, вы можете удалить свою учётную запись через страницу профиля. Единственное, что связано с вашим именем пользователя и что мы НЕ будем удалять, — это история ваших игр, потому что все игры являются публичной информацией." ] cookie_header = "Политика использования файлов cookie" cookie_paragraphs = [ "Этот сайт использует файлы cookie — небольшие текстовые файлы, которые хранятся в вашем браузере и отправляются на сервер при установлении соединений. Назначение этих файлов cookie: подтверждение вашей сессии входа, проверка того, что ваш браузер принадлежит к той шахматной игре, в которой он утверждает, что находится, и сохранение пользовательских настроек игры, чтобы вы могли сохранить свои предпочтения при повторном посещении сайта. Сайт не использует сторонние файлы cookie, файлы cookie не передаются внешним сторонам.", "Файлы cookie необходимы для корректной работы этого сайта и игры. Если вы не хотите, чтобы сайт хранил файлы cookie, вы должны прекратить использование сайта. Вы можете перейти в настройки вашего браузера, чтобы удалить существующие файлы cookie. Продолжая использовать этот сайт, вы даёте согласие на использование файлов cookie." ] conclusion_header = "Заключение" conclusion_paragraphs = [ "Любые нарушения этих условий могут привести к блокировке или удалению вашей учётной записи. InfiniteChess.org стремится предоставить всем возможность играть и получать удовольствие! Однако мы оставляем за собой право в любое время блокировать или удалять учётные записи любых пользователей по причинам, которые могут не раскрываться. Против нас не может быть возбуждено судебное преследование.", ["Эти условия использования могут быть изменены в любой момент. ВАША ответственность — следить за последними изменениями! Когда эти условия использования обновляются, эта информация будет размещена на странице ", "Новости", ". Если в момент обновления условий использования вы не согласны с новыми условиями, вы должны немедленно прекратить использование веб-сайта. Вы можете удалить свою учётную запись через страницу профиля. Если вы удалите свою учётную запись, вся ваша личная информация и данные учётной записи будут удалены, ЗА ИСКЛЮЧЕНИЕМ истории ваших игр, связанной с вашим именем пользователя — это публичная информация."], ["Этот сайт имеет открытый исходный код. Вы можете копировать или распространять любой материал с этого веб-сайта при условии соблюдения условий, изложенных в ", "условиях лицензии", "! Если эта ссылка не работает, вы обязаны найти эти условия самостоятельно."], "Мы не можем гарантировать, что сайт будет работать 100% времени. Мы также не можем гарантировать, что данные никогда не будут повреждены.", "Вам запрещается совершать любую незаконную деятельность на сайте.", ["Если у вас есть какие-либо вопросы относительно этих условий или любые другие вопросы о сайте,", "напишите нам по электронной почте!"] ] thanks = "Спасибо!" [login] title = "Вход" # The tab name username = "Имя пользователя:" password = "Пароль:" login_button = "Войти" send_reset_link = "Отправить ссылку для сброса" forgot_question = "Забыли пароль?" back_to_login = "Вернуться к входу" forgot_instruction = "Введите адрес электронной почты, связанный с вашей учетной записью." [login.javascript] network-error = "Произошла ошибка сети. Попробуйте ещё раз." [reset_password] title = "Сброс пароля" instruction = "Введите и подтвердите новый пароль." new_password = "Новый пароль" confirm_password = "Подтвердить пароль" submit_button = "Сбросить пароль" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "Получены неверные параметры." 409_message = ["Возможно, имело место конфликтующее имя пользователя или адрес электронной почты. Пожалуйста ", "перезагрузите", ", страницу."] 500_message = "Этого не должно было случиться. Нужно провести некоторую отладку и всё будет в порядке!" [news] title = "Новости" # The tab name more_dev_logs = ["Больше записей о разработке публикуются в ", "официальном Discord", ", и на ", "форумах chess.com!"] [server.javascript] ws-invalid_username = "Не верное имя пользователя" ws-incorrect_password = "Неправильный пароль" ws-login_failure_retry_in = "Не удалось войти, попробуйте еще раз через" ws-seconds = "секунд" # unit of time ws-second = "секунда" # unit of time ws-username_length = "Имя пользователя должно содержать от 3 до 20 символов." ws-username_letters = "Имя пользователя должно содержать только буквы A-Z и цифры 0-9." ws-username_taken = "Это имя пользователя занято." ws-username_bad_word = "Это имя пользователя содержит недопустимое слово." ws-username_reserved = "Это имя пользователя зарезервировано." ws-email_too_long = "Ваш адрес электронной почты слишком длиииииииииинный" ws-email_invalid = "Это некорректная электронная почта" ws-email_in_use = "Эта электронная почта уже используется" ws-email_domain_invalid = "Неверный домен." ws-you_are_banned = "Вы забанены." ws-password_length = "Пароль должен быть длиной от 6 до 72 символов." ws-password_format = "Пароль имеет неверный формат." ws-password_password = "Пароль не должен быть словом 'password'" ws-password-reset-link-sent = "Если учетная запись с таким адресом электронной почты существует, вам была отправлена ссылка для сброса пароля." ws-password-change-success = "Пароль успешно сброшен. Скоро вы будете перенаправлены на страницу входа." ws-password-reset-token-invalid = "Токен сброса пароля недействителен или истек срок его действия." ws-forbidden_wrong_account = "Запрещено. Это не ваш аккаунт." ws-deleting_account_not_found = "Не удалось удалить аккаунт. Аккаунт не найден." ws-deleting_account_in_game = "Вы не сможете удалить свой аккаунт, пока вы подключены к онлайн-игре." ws-server_error = "Извините, произошла ошибка сервера! Пожалуйста, вернитесь." ws-not_found = "404 Не найдено" ws-forbidden = "Запрещено." ws-already_in_game = "Вы уже в партии." ws-server_restarting = "Сервер перезапускается через" # The server inserts a number immediately after this, followed by the correct plurality of minutes. ws-server_under_maintenance = "Сервер находится на обслуживании. Зайдите позже!" # Can be changed at will to change the display message. ws-minutes = "минут" # unit of time ws-minute = "минута" # unit of time ws-game_aborted_cheating = "Игра прервана из-за вероятного читерства." ws-cannot_resign_finished_game = "Нельзя сдатся, партия уже окончена." ws-invalid_code = "Неверный код!" # Invite code doesn't match any existing invites ws-game_aborted = "Партия отменена." # Invite was cancelled as you clicked on it ws-rated_invite_verification_needed = "Для участия в рейтинговых партиях вам необходимо войти в систему с подтвержденным аккаунтом." ================================================ FILE: translation/zh-CN.toml ================================================ name = "简体中文" # Name of language english_name = "Simplified Chinese" direction = "ltr" # Change to "rtl" for right to left languages version = "22" maintainer = "Heinrich Xiao" [header] home = "无限棋" play = "开始" news = "消息" login = "登录" profile = "个人资料" createaccount = "注册" logout = "登出" [header.settings] language = "语言" board = "棋盘" # Board color/theme legalmoves = "合法移动" # Legal moves shape legalmoves-squares = "方块" legalmoves-dots = "点" # Dots and 4 corner triangles perspective = "视角" # Perspective-mode perspective-mouse-sensitivity = "鼠标灵敏度" perspective-fov = "视野范围" ping = ["延迟", "毫秒"] # A number is inserted between these 2 strings. reset-to-default = "恢复默认" [footer] contact = "联系" terms_of_service = "服务条款" source_code = "程序" language = "语言" [member.javascript] js-confirm_delete = "您确定要删除账号吗?这无法撤销!要是你确定删除账号,点OK。" js-enter_password = "输入密码以永久删除您的账户:" [index] title = "无限棋 | 首页 - 官方网站" # The tab title secondary_title = "现场游戏的官方网站!" what_is_it_title = "这是什么?" what_is_it_pargaraphs = [ "无限国际象棋是一种棋类变体,没有边界,比你熟悉的8x8棋盘大得多。皇后、车和主教在每一回合中可以移动的距离没有限制。选择任何自然数,直至无限!", "由于移动距离没有限制,因此有可能出现末日时钟或将军空白位置的数字由第一个无限序数omega ω表示。事实上,研究人员已经发现任何可数序数都可以用于将军时钟!", "可以想象,起始配置有无数种可能,其中许多可以进行竞技比赛!你的最终目标仍然是将军,这需要新的策略,因为没有墙可以用来困住敌方的国王。游戏通常不会比正常的国际象棋比赛持续更久。兵仍然在第1和第8排晋升!", ] how_to_title = "我要怎么玩?" how_to_paragraph = ["当前版本是1.10,你可以在","游戏页面","上进行游戏!"] about_title = "关于项目" about_paragraphs = [ "我是Naviary。自从我第一次发现无限国际象棋(这个概念在这个网站出现之前就已经存在),我就对它及其可能性非常感兴趣!直到最近,玩这款游戏一直很困难,需要chess.com会员每次走棋时创建当前棋盘的图像并来回发送。因此,知道并能玩这款游戏的人并不多。", ["我的目标是建立一种方式,让每个人都可以轻松地玩这个游戏,并建立一个围绕它的社区。我已经花费了无数个小时在这个网站上,维护和开发游戏。我还有很多想法,这些想法会让我忙上一段时间。虽然我希望保持免费游戏,但生活有其需求,如果你能在经济上支持我,请考虑加入我的 ", "Patreon", "."], # Patreon receives a hyperlink, here ] patreon_title = "Patreon支持者" [credits] title = "鸣谢" copyright = "网站上未列出的任何内容均为 www.InfiniteChess.org 的版权" variants_heading = "变体" variants_credits = [ "核心设计者:Andreas Tsevas。", "空间设计者:Andreas Tsevas。", "经典空间设计者:Andreas Tsevas。", "无限平面上的国际象棋(Coaip)设计者:V. Reinhart。", "兵群设计者:Inaccessible Cardinal。", "丰富设计者:Clicktuck Suskriberz。", "Pawndard设计者:SexiLexi。", "Classical+设计者:SexiLexi。", "Knightline设计者:Inaccessible Cardinal。", "Knighted Chess设计者:cycy98。", "设计者:Cory Evans 和 Joel Hamkins。", "设计者:Andreas Tsevas。", "设计者:Cory Evans 和 Joel Hamkins。", "设计者:Cory Evans,Joel Hamkins 和 Norman Lewis Perlmutter。", ] textures_heading = "纹理" textures_licensed_under = "纹理使用了" textures_credits = [ "金币设计者:Quolte。", ] sounds_heading = "声音" sounds_credits = [ ["部分声音由", "项目提供,使用许可为"], "其他声音由Naviary创作。", ] code_heading = "程序" code_credits = [ "由Brandon Jones 和 Colin MacKenzie IV 编写。", "由Andreas" ] language_heading = "语言翻译" language_credits = [ "法语由 ", "Life Enjoyer", " 和 ", "cycy98", " 贡献。", "简体中文由 ", "Heinrich Xiao", " 贡献。", "繁体中文由 ", "Heinrich Xiao", " 贡献。", "波兰语由 ", "Tymon Becella", " 贡献。", "葡萄牙语由 ", "Emerson P. Machado", " 贡献。", # The_Skeleton on discord "西班牙语由 ", "xa31er", " 贡献。" ] [member] title = "会员" # The tab name verify_message = "请检查您的电子邮件以验证您的账户。未验证的账户将在 3 天后删除。" resend_message = ["没有收到?请检查您的垃圾邮件文件夹。另外,", "重新发送邮件。", "如果仍找不到,请", "联系我们。"] verify_confirm = "感谢您!您的账户已验证。" rating = "Elo 评级:" joined = "加入时间:" seen = ["上次在线:", " 前"] reveal_info = "显示账号资料" account_info_heading = "账号资料" email = "电子邮箱:" delete_account = "删除账号" password_reset_message = ["要更改您的用户名、电子邮件或密码,请", "联系我们。"] [create-account] title = "注册" username = "账号:" email = "电子邮箱:" password = "密码:" create_button = "注册" agreement = ["我同意", "服务条款", "。"] [create-account.javascript] js-username_specs = "用户名必须至少包含 3 个字符,并且只能包含字母 A-Z 和数字 0-9" js-username_tooshort = "用户名必须多于三个字母" js-username_wrongenc = "用户名只能包含字母 A-Z 和数字 0-9。" js-email_invalid = "这不是一个有效的邮箱" js-email_inuse = "这个电子邮箱已经被用了" js-pwd_incorrect_format = "密码格式不正确" js-pwd_too_short = "密码必须多于六个字母" js-pwd_too_long = "密码禁止多于七十二个字母" js-pwd_not_pwd = "密码禁止是'password'" [play] title = "无限棋 - 对局" # The tab title loading = "加载中" error = "错误" [play.main-menu] credits = "鸣谢" play = "开始" guide = "指南" editor = "棋盘编辑器" [play.guide] title = "指南" rules = "规则" rules_paragraphs = [ "无限国际象棋的规则与经典国际象棋几乎相同,唯一的区别是棋盘在所有方向上都是无限的!以下是您需要注意的更改和说明:", "滑动移动的棋子,如车、主教和皇后,每回合移动的距离没有限制!只要路径畅通无阻,您可以移动数百万格!", ["在“经典”默认变体中,白兵在第8排晋升,黑兵在第1排晋升。在这张图片中,细黑线表示这一点,它们很微弱,看看您是否能找到它们!兵只需要到达相对的线即可晋升,", "不需要", "越过它。"], "棋盘方格不再用字母和数字(例如a1)表示,而是用x和y坐标对来定义。a1方格变成了(1,1),h8方格变成了(8,8)。在桌面设备上,鼠标悬停的坐标会显示在屏幕顶部。", "其他规则与经典国际象棋相同,例如将军、逼和、三次重复、50步规则、王车易位、“吃过路兵”等!" ] careful_heading = "小心!" careful_paragraphs = [ "无限棋盘的开放性意味着很容易利用叉子、钉子和斜线攻击。您的后方通常非常脆弱。小心这样的战术!在保护国王和车的过程中要有创造力!开局策略与经典国际象棋非常不同。", "为了增强您的后方,已经创建了许多其他变体。" ] controls_heading = "控制" controls_paragraph = "点击并拖动棋盘来移动。滚动鼠标滚轮进行缩放。点击任何棋子,包括对手的棋子,在任何时候查看它们的合法移动!其他控制如下:" keybinds = [ " 来移动棋盘。", ["空格键", " 和 ", "Shift键", " 来缩放。"], ["Esc键", " 来暂停游戏。"], ["Tab键", " 切换屏幕边缘的箭头指示器,用于指向屏幕外的棋子。默认情况下,此模式设置为“防御”,显示从当前位置可以移动到您所在位置的棋子的箭头。但按", "Tab键", "可以将此模式切换为“全部”或“关闭”。“全部”模式显示所有在那些直线和斜线上的棋子,无论它们是否可以直线或斜线移动。此设置也可以在暂停菜单中切换。点击这些箭头会将您传送到它们指向的棋子位置。"], " 在本地游戏中切换“编辑模式”。这允许您将任何棋子移动到棋盘上的其他位置!非常适合分析。" ] controls_paragraph2 = "这些是您需要了解的主要控制。但如果您需要,这里还有一些额外的操作!" keybinds_extra = [ " 将重置棋子的渲染。如果它们变得不可见,这将非常有用。如果您移动极远的距离(例如1e21),可能会发生此错误。", " 将切换导航和游戏信息栏的渲染,这对录制很有用。欢迎在游戏中进行流媒体或制作视频!", " 将切换FPS计数器。这显示游戏每秒更新的次数,而不总是显示渲染的帧数,因为游戏在没有可见变化时跳过渲染以节省计算资源。", " 将切换图标渲染。这些是在您足够远地缩小时棋子的可点击缩略图。在导入超过50,000个棋子的游戏中,这将自动关闭,因为它是性能瓶颈,但您可以使用 ", [" (反引号,或与 ", "相同的键)将切换调试模式。"], ] fairy_heading = "仙子棋子" fairy_paragraph = "您已经掌握了玩默认“经典”变体所需的知识。仙子棋子不用于常规国际象棋,但被整合到其他变体中!如果您发现自己在某个变体中遇到了一些以前没见过的棋子,让我们在这里学习它们的工作原理!" editing_heading = "棋盘编辑" editing_paragraphs = [ ["目前有一个外部 ", "棋盘编辑器", ",可在公共Google表单上使用!它包含使用说明。此工具需要一些基本的Google表单知识。设置后,您将能够通过选项菜单中的“粘贴游戏”按钮创建和导入自定义棋局位置!"], "要与朋友玩自定义棋局,请让他们加入私人邀请,然后在开始游戏之前,双方都粘贴游戏代码!", "游戏内棋盘编辑器仍在计划中。", ] back = "返回" [play.guide.pieces] chancellor = {name="大臣", description="像车和骑士的组合一样移动。"} archbishop = {name="主教骑士", description="像主教和骑士的组合一样移动。"} amazon = {name="女皇", description="像皇后和骑士的组合一样移动。这是游戏中最强大的棋子!"} guard = {name="护卫", description="像国王一样移动,但不易受将军或将死。"} hawk = {name="鹰", description="在任何方向上跳跃2或3格。"} centaur = {name="人马", description="像骑士和护卫的组合一样移动。"} knightrider = {name="骑士骑士", description="像骑士一样在一个方向上无限跳跃,直到被阻挡。"} obstacle = {name="障碍物", description="一个中立棋子(不由任何玩家控制),阻挡移动,但可以被捕获。"} void = {name="虚空", description="一个中立棋子(不由任何玩家控制),表示棋盘的缺失。棋子不能穿过或移动到它上面。"} [play.play-menu] title = "玩 - 网上" colors = "颜色" online = "网上" local = "本地" computer = "计算机" variant = "变体" Classical = "经典" Classical_Plus = "经典+" CoaIP = "无限棋盘上的国际象棋" Pawndard = "兵棋" Knighted_Chess = "骑士国际象棋" Knightline = "骑士线" Core = "核心" Standarch = "标准弧" Pawn_Horde = "兵群" Space_Classic = "太空经典" Space = "太空" Obstocean = "障碍海洋" Abundance = "丰饶" Amazon_Chandelier = "亚马逊吊灯" Containment = "遏制" Classical_Limit_7 = "经典 - 限制 7" CoaIP_Limit_7 = "无限棋盘 - 限制 7" Chess = "国际象棋" Classical_KOTH = "实验: 经典 - 王者争夺" CoaIP_KOTH = "实验: 无限棋盘 - 王者争夺" Omega = "展示: 欧米伽" Omega_Squared = "展示: 欧米伽²" Omega_Cubed = "展示: 欧米伽³" Omega_Fourth = "展示: 欧米伽⁴" no_clock = "没有表" clock = "表" minutes = "分钟" seconds = "秒" infinite_time = "无限时间" color = "颜色" piece_colors = ["随机", "白", "黑"] private = "未发布" no = "不" yes = "是" rated = "评级" casual = "休闲" join_games = "加入现有 - 活跃游戏:" private_invite = "私人邀请:" your_invite = "您的邀请码:" create_invite = "创建邀请" join = "加入" copy = "复制" back = "返回" code = "邀请码" [play.gamebuttontooltips] undo_transition = "撤销过渡" expand_fit_all = "展开以适应所有" recenter = "重新居中" rewind_move = "倒回操作" forward_move = "前进操作" pause = "暂停" [play.footer] white_to_move = "白方走起" player_white = "白方" player_black = "黑方" [play.pause] title = "暂停" resume = "继续" arrows = "箭头: 防御" perspective = "视角: 关闭" copy = "复制棋局" paste = "粘贴棋局" offer_draw = "提和" main_menu = "主页" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "接受和棋提议" [play.javascript] guest_indicator = "(游客)" you_indicator = "(您)" white_to_move = "白方走棋" black_to_move = "黑方走棋" your_move = "轮到您走棋" their_move = "轮到他走棋" lost_network = "网络丢失。" failed_to_load = "一个或多个资源加载失败。请刷新页面。" planned_feature = "此功能已计划!" main_menu = "主页" resign_game = "认输" abort_game = "放弃游戏" offer_draw = "提和" # Offer draw button text in the pause menu accept_draw = "接受和棋" # Offer draw button text in the pause menu arrows_off = "箭头: 关闭" arrows_defense = "箭头: 防御" arrows_all = "箭头: 全部" toggled = "切换" menu_online = "玩 - 网上" menu_local = "玩 - 本地" invite_error_digits = "邀请码需要5位数字。" invite_copied = "邀请码已复制到剪贴板。" move_counter = "步数:" constructing_mesh = "构建网格" rotating_mesh = "旋转网格" lost_connection = "连接丢失。" please_wait = "请稍等,正在执行此任务。" webgl_unsupported = "您的浏览器不支持WebGL。此游戏需要WebGL才能运行。请更新您的浏览器。" bigints_unsupported = "BigInts 不受支持。请升级您的浏览器。\nBigInts 用于使棋盘无限。" shaders_failed = "无法初始化着色器程序:" failed_compiling_shaders = "编译着色器时发生错误:" [play.javascript.copypaste] copied_game = "游戏已复制到剪贴板!" cannot_paste_in_public = "不能在公共比赛中粘贴游戏!" cannot_paste_after_moves = "移动后不能粘贴游戏!" clipboard_denied = "剪贴板权限被拒绝。这可能是您的浏览器问题。" clipboard_invalid = "剪贴板内容不符合有效的ICN格式。" game_needs_to_specify = "游戏需要指定 'Variant' 元数据或 'position' 属性。" invalid_wincon_white = "白方有无效的胜利条件" invalid_wincon_black = "黑方有无效的胜利条件" pasting_game = "正在粘贴游戏..." pasting_in_private = "在私人比赛中粘贴游戏会导致不同步,如果对手没有做同样的操作!" piece_count = "棋子数量" exceeded = "超过了" changed_wincon = "将将死胜利条件更改为royalcapture,并关闭了图标渲染。按 'P' 重新启用(不推荐)。" loaded_from_clipboard = "从剪贴板加载了游戏!" loaded = "游戏已加载!" slidelimit_not_number = "slideLimit 游戏规则必须是数字。收到" [play.javascript.rendering] on = "开启" off = "关闭" icon_rendering_off = "图标渲染已关闭。" icon_rendering_on = "图标渲染已开启。" toggled_edit = "编辑模式已切换:" perspective = "视角" perspective_mode_on_desktop = "桌面版支持视角模式!" movement_tutorial = "WASD 移动。空格 & Shift 缩放。" regenerated_pieces = "重新生成了棋子。" [play.javascript.invites] move_mouse = "移动鼠标以重新连接。" unknown_action_received_1 = "未知操作" unknown_action_received_2 = "从邀请订阅中接收到的服务器消息!" cannot_cancel = "无法取消未定义 ID 的邀请。" you_indicator = "(你)" you_are_white = "你是白方" you_are_black = "你是黑方" random = "随机" accept = "接受" cancel = "取消" create_invite = "创建邀请函" cancel_invite = "取消邀请函" start_game = "开始" join_existing_active_games = "加入现有 - 活跃游戏:" [play.javascript.onlinegame] afk_warning = "你是AFK." opponent_afk = "对手是AFK." opponent_disconnected = "对手断开连接了" opponent_lost_connection = "对手断开连接了" auto_resigning_in = "将很快自动认输" auto_aborting_in = "将很快自动中止" not_logged_in = "您未登录。请登录以重新连接到此游戏。" game_no_longer_exists = "游戏不再存在。" another_window_connected = "另一个窗口已连接。" server_restarting = "服务器即将重新启动..." server_restarting_in = "服务器即将重新启动" minute = "分钟" minutes = "分钟" [play.javascript.websocket] no_connection = "没有连接" reconnected = "重新连接了" unable_to_identify_ip = "无法识别IP地址" online_play_disabled = "在线游戏已禁用。不支持Cookie。请尝试其他浏览器。" too_many_requests = "请求次数过多,请稍后再试。" message_too_big = "消息太大。" too_many_sockets = "套接字太多。" origin_error = "来源错误。" connection_closed = "连接意外关闭。服务器消息。" please_report_bug = "这不应该发生,请报告此错误!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "将死" stalemate = "僵局" repetition = "三次重复" moverule = ["", "-回合规则"] # The game inserts a number inbetween these two strings insuffmat = "棋子不足" royalcapture = "王被吃" allroyalscaptured = "所有王被吃" allpiecescaptured = "所有棋子被吃" threecheck = "三次将军" koth = "山丘之王" resignation = "认输" agreement = "同意" time = "超时" aborted = "已中止" # Game was cancelled (no elo exchanged) disconnect = "弃赛" # A player left [play.javascript.results] you_checkmate = "你赢了,将死!" you_time = "你超时赢了!" you_resignation = "你赢了,对手认输!" you_disconnect = "你赢了,对手弃权!" you_royalcapture = "你赢了,皇族棋子被捕!" you_allroyalscaptured = "你赢了,所有皇族棋子被捕!" you_allpiecescaptured = "你赢了,全军覆没!" you_threecheck = "你赢了,三步将军!" you_koth = "你赢了,山顶王!" you_generic = "你赢了!" draw_stalemate = "和棋,僵局!" draw_repetition = "和棋,局面重复!" draw_moverule = ["和棋,", "步规则!"] draw_insuffmat = "和棋,因棋子不足!" draw_agreement = "协议和棋" draw_generic = "和棋!" aborted = "游戏中止" opponent_checkmate = "你输了,将死!" opponent_time = "你超时输了!" opponent_resignation = "你输了,对手认输!" opponent_disconnect = "你输了,对手弃权!" opponent_royalcapture = "你输了,皇族棋子被捕!" opponent_allroyalscaptured = "你输了,所有皇族棋子被捕!" opponent_allpiecescaptured = "你输了,全军覆没!" opponent_threecheck = "你输了,被三步将军!" opponent_koth = "你输了山顶王!" opponent_generic = "你输了!" white_checkmate = "白方将死获胜!" black_checkmate = "黑方将死获胜!" bug_checkmate = "这是一个错误,请报告。游戏以将死结束。" white_time = "白方超时胜" black_time = "黑方超时胜!" bug_time = "这是一个错误,请报告!游戏因超时结束。" white_royalcapture = "白方通过捕获皇族棋子获胜!" black_royalcapture = "黑方通过捕获皇族棋子获胜!" bug_royalcapture = "这是一个错误,请报告!游戏因皇族棋子被捕获而结束。" white_allroyalscaptured = "白方通过吃掉所有皇族棋子获胜!" black_allroyalscaptured = "黑方通过吃掉所有皇族棋子获胜!" bug_allroyalscaptured = "这是一个错误,请报告!游戏因所有皇族棋子被捕获而结束。" white_allpiecescaptured = "白方通过吃掉所有棋子获胜!" black_allpiecescaptured = "黑方通过吃掉所有棋子获胜!" bug_allpiecescaptured = "这是一个错误,请报告!游戏因所有棋子被捕获而结束。" white_threecheck = "白方通过三步将军获胜!" black_threecheck = "黑方通过三步将军获胜!" bug_threecheck = "这是一个错误,请报告!游戏被三步将军结束。" white_koth = "白方通过山顶之王获胜!" black_koth = "黑方通过山顶之王获胜!" bug_koth = "这是一个错误,请报告!游戏被山顶之王结束。" bug_generic = "这是一个错误,请报告!" [terms] title = "服务条款" warning = ["此文件不具有法律约束力。我们只对英文版本的文件负责。本翻译仅供一般参考。您可以在此处访问官方英文版本", "这里", "。"] consent = "使用本网站即表示您同意遵守以下条款。如果您不同意,您必须立即停止使用本网站。" guardian_consent = "如果您未满18岁,您必须获得父母或法定监护人的同意,才能使用本网站并创建账户。" parents_header = "父母" parents_paragraphs = [ "本网站有一个算法,用于禁止用户将其名字设置为常见的脏话。目前,网站上用户之间没有交流方式。", "目前,会员无法设置自己的个人资料图片。我们计划在未来允许此功能,届时我们将尽最大努力防止不适当的个人资料图片。", ] fair_play_header = "公平游戏" fair_play_paragraph1 = ["您不能创建超过一个账户。如果您希望更改与账户关联的电子邮件地址,请", "联系我们。"] fair_play_paragraph2 = "为了让游戏保持有趣和公平,您不得:" fair_play_rules = [ "以任何方式修改或操纵代码,包括但不限于:使用控制台命令、本地覆盖、自定义脚本、修改HTTP请求等。这样做可能是为了故意破坏游戏,或给自己带来优势。", "在评级游戏中,接受他人或程序的帮助/建议,以决定应该下什么棋。(创建引擎是可以的,并且是鼓励的,但您必须将其使用限制在非评级游戏中。)", "通过故意输棋以提升对手的Elo积分,或接受对手故意输棋以提升自己的Elo积分。这会滥用系统,导致根据您的技能水平产生不准确的评级。" ] cleanliness_header = "清洁" cleanliness_rules = [ "在网站上使用的所有语言中,您必须保持文明,不得使用粗俗语言或脏话。您不得欺凌、骚扰或威胁他人,或从事任何非法行为。您不得向其他用户或论坛发送垃圾信息。", "您不得上传不适当、暗示性或血腥的图像作为您的个人资料图片。这样做可能会导致您被禁止或终止账户。" ] privacy_header = "隐私" privacy_rules = [ "目前,我们收集的唯一个人信息是电子邮件。这是为了验证用户的账户,并提供在他们请求密码重置时证明身份的手段。我们不会发送任何促销电子邮件或优惠。我们不会与任何人共享用户的电子邮件地址。", "InfiniteChess.org可能会收集您在网站上使用的数据,包括您的IP地址。这是为了帮助防止来自机器人的攻击和其他不受欢迎的实体,并保持数据库中的准确统计信息。这不是您的家庭地址。", "您在本网站上玩的所有游戏都会成为公共信息。如果您希望保持匿名,请不要与朋友或家人分享您的用户名。如果这是您的愿望,您有责任确保没有人发现您的用户名与您的真实身份相关联。", "您的账户在线状态以及您上次在网站上活跃的近似时间也是公共信息。", ["尽管InfiniteChess.org将尽力在其能力范围内保护每个人的账户和个人信息,但在发生黑客攻击或数据泄露时,您不得向我们提出指控。如果发生数据泄露,用户将在", "新闻", "页面上收到通知。"], "网站上没有可购买的内容。我们不收集其他个人信息。", "要从我们的服务器中删除您的私人信息,您可以通过个人资料页面删除您的账户。唯一与您的用户名有关且我们不会删除的内容是您的游戏历史记录,因为所有游戏都是公开信息。", ] cookie_header = "Cookie政策" cookie_paragraphs = [ "本网站使用Cookie,Cookie是存储在您浏览器中的小型文本文件,在连接时发送到服务器。使用这些Cookie的目的是:验证您的登录会话,验证您的浏览器属于它所称的棋局,并存储用户的游戏偏好,以便他们在重新访问网站时可以保留其偏好。该网站不使用第三方Cookie,Cookie不会与外部方共享。", "Cookie是本网站和游戏正常运行所必需的。如果您不希望网站存储Cookie,您必须停止使用本网站。您可以进入浏览器偏好设置删除现有的Cookie。继续使用本网站即表示您同意使用Cookie。" ] conclusion_header = "结论" conclusion_paragraphs = [ "任何违反这些条款的行为可能导致您被禁止或终止账户。InfiniteChess.org希望能够为每个人提供玩乐的机会!但是,我们保留随时禁止或终止任何用户账户的权利,原因无需披露。您不得向我们提出指控。", ["这些服务条款可能随时修改。您有责任确保您保持最新!当这些服务条款更新时,该信息将发布在", "新闻", "页面上。如果在服务条款更新时,您不同意新条款,您必须立即停止使用网站。您可以从您的个人资料页面删除账户。如果您删除账户,所有您的私人信息和账户数据将被删除,除了与您的用户名相关的游戏历史记录,因为这是公开信息。"], ["此网站是开源的。只要您遵循许可条款中规定的条件,您可以复制或分发本网站上的任何内容!", "许可条款", "。如果此链接失效,您有责任找到条款。"], "我们不能保证网站将100%时间运行。我们也不能保证数据永远不会被损坏。", "您不得在网站上从事任何非法活动。", ["如果您对这些条款有任何疑问,或对网站有任何其他问题,请", "通过电子邮件联系我们!"] ] update = "(最后更新日期:2024年7月13日。添加了警告,所有玩过的游戏可能成为公共信息,包括您账户上次活跃的大致时间。此外,这些条款可能会随时更新,您有责任确保您保持更新。)" thanks = "谢谢!" [login] title = "登录" username = "用户名:" password = "密码:" forgot_password = ["忘记了? ", "给我们发邮件."] login_button = "登录" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "收到无效参数。" 409_message = ["可能存在冲突的用户名或电子邮件。请", "重新加载", "页面"] 500_message = "这不应该发生。需要进行一些调试!" ########### NEWS ########### [news] title = "新闻" more_dev_logs = ["更多开发者日志在我们的", "Discord", "和在", "chess.com的论坛!"] [server.javascript] ws-invalid_username = "用户名无效" ws-incorrect_password = "密码部队" ws-username_and_password_required = "需要用户名和密码" ws-username_and_password_string = "用户名和密码必须是字符串" ws-login_failure_retry_in = "登录失败" ws-seconds = "妙" # unit of time ws-second = "妙" # unit of time ws-username_length = "用户名必须多于3个字母和少于20字母" ws-username_letters = "用户名只能包含字母 A-Z 和数字 0-9。" ws-username_taken = "那个用户名已经被用了。" ws-username_bad_word = "该用户名包含不允许的词语。" ws-email_too_long = "您的电子有限太长。" ws-email_invalid = "这个邮箱无效。" ws-email_in_use = "这个邮箱已经被用了。" ws-you_are_banned = "您被封禁了" ws-password_length = "密码必须多于6个字母,并且少于72个字母。" ws-password_format = "密码格式不正确" ws-password_password = "密码禁止是password" ws-refresh_token_not_found_logged_out = "没有成员拥有该刷新令牌 (已经等处)" ws-refresh_token_not_found = "没有成员拥有该刷新令牌" ws-refresh_token_expired = "未找到刷新令牌(会话过期)" ws-refresh_token_invalid = "刷新令牌过期或被篡改" ws-member_not_found = "账户未找到" ws-forbidden_wrong_account = "禁止。这不是您的账户。" ws-deleting_account_not_found = "刪除帳戶失敗。找不到帳戶。" ws-server_error = "抱歉,發生伺服器錯誤!請返回。" ws-unable_to_identify_client_ip = "無法識別客戶端 IP 地址" ws-you_are_banned_by_server = "您被禁用了" ws-too_many_requests_to_server = "請求過多。請稍後再試。" ws-bad_request = "400" ws-not_found = "404" ws-forbidden = "403" ws-unauthorized_patron_page = "未经授权。此页面仅限会员访问。" ws-username_reserved = "用户名已被保留" ws-already_in_game = "已在游戏中" ws-server_restarting = "服务器正在重启" ws-minutes = "分钟" ws-server_under_maintenance = "服务器正在维护中。请稍后再试!" # Can be changed at will to change the display message. ws-minute = "分钟" ws-no_abort_game_over = "游戏结束无法中止" ws-no_abort_after_moves = "无法在棋步后中止" ws-game_aborted_cheating = "因作弊游戏中止" ws-cannot_resign_finished_game = "无法放弃已完成的游戏" ws-invalid_code = "无效代码" ws-game_aborted = "游戏中止" ================================================ FILE: translation/zh-TW.toml ================================================ name = "繁體中文" # Name of language english_name = "Traditional Chinese" direction = "ltr" # Change to "rtl" for right to left languages version = "20" maintainer = "Heinrich Xiao" [header] home = "主頁" play = "開始" news = "消息" login = "登錄" createaccount = "注冊" [footer] contact = "聯系" terms_of_service = "服務條款" source_code = "程序" language = "語言" [header.javascript] js-profile = "個人賬戶" js-logout = "登出" js-login = "登錄" js-createaccount = "注冊" [member.javascript] js-confirm_delete = "您確定要刪除賬號嗎?這無法撤銷!要是你確定刪除賬號,點OK。" js-enter_password = "輸入密碼以永久刪除您的賬戶:" [index] title = "無限棋 | 首頁 - 官方網站" # The tab title secondary_title = "現場游戲的官方網站!" what_is_it_title = "這是什麼?" what_is_it_pargaraphs = [ "無限國際象棋是一種棋類變體,沒有邊界,比你熟悉的8x8棋盤大得多。皇后、車和主教在每一回合中可以移動的距離沒有限制。選擇任何自然數,直至無限!", "由於移動距離沒有限制,因此有可能出現末日時鐘或將軍空白位置的數字由第一個無限序數omega ω表示。事實上,研究人員已經發現任何可數序數都可以用於將軍時鐘!", "可以想象,起始配置有無數種可能,其中許多可以進行競技比賽!你的最終目標仍然是將軍,這需要新的策略,因為沒有牆可以用來困住敵方的國王。游戲通常不會比正常的國際象棋比賽持續更久。兵仍然在第1和第8排晉升!", ] how_to_title = "我要怎麼玩?" how_to_paragraph = ["當前版本是1.10,你可以在","游戲頁面","上進行游戲!"] about_title = "關於項目" about_paragraphs = [ "我是Naviary。自從我第一次發現無限國際象棋(這個概念在這個網站出現之前就已經存在),我就對它及其可能性非常感興趣!直到最近,玩這款游戲一直很困難,需要chess.com會員每次走棋時創建當前棋盤的圖像並來回發送。因此,知道並能玩這款游戲的人並不多。", ["我的目標是建立一種方式,讓每個人都可以輕鬆地玩這個游戲,並建立一個圍繞它的社區。我已經花費了無數個小時在這個網站上,維護和開發游戲。我還有很多想法,這些想法會讓我忙上一段時間。雖然我希望保持免費游戲,但生活有其需求,如果你能在經濟上支持我,請考慮加入我的 ", "Patreon", "."], # Patreon receives a hyperlink, here ] patreon_title = "Patreon支持者" [credits] title = "鳴謝" copyright = "網站上未列出的任何內容均為 www.InfiniteChess.org 的版權" variants_heading = "變體" variants_credits = [ "核心設計者:Andreas Tsevas。", "空間設計者:Andreas Tsevas。", "經典空間設計者:Andreas Tsevas。", "無限平面上的國際象棋(Coaip)設計者:V. Reinhart。", "兵群設計者:Inaccessible Cardinal。", "豐富設計者:Clicktuck Suskriberz。", "Pawndard設計者:SexiLexi。", "Classical+設計者:SexiLexi。", "Knightline設計者:Inaccessible Cardinal。", "Knighted Chess設計者:cycy98。", "設計者:Cory Evans 和 Joel Hamkins。", "設計者:Andreas Tsevas。", "設計者:Cory Evans 和 Joel Hamkins。", "設計者:Cory Evans,Joel Hamkins 和 Norman Lewis Perlmutter。", ] textures_heading = "紋理" textures_licensed_under = "紋理使用了" textures_credits = [ "金幣設計者:Quolte。", ] sounds_heading = "聲音" sounds_credits = [ ["部分聲音由", "項目提供,使用許可為"], "其他聲音由Naviary創作。", ] code_heading = "程序" code_credits = [ "由Brandon Jones 和 Colin MacKenzie IV 編寫。", "由Andreas" ] language_heading = "語言翻譯" language_credits = [ "法語由 ", "Life Enjoyer", " 和 ", "cycy98", " 貢獻。", "繁體中文由 ", "Heinrich Xiao", " 貢獻。", "簡體中文由 ", "Heinrich Xiao", " 貢獻。", "波蘭語由 ", "Tymon Becella", " 貢獻。", "葡萄牙語由 ", "Emerson P. Machado", " 貢獻。", # The_Skeleton on discord "西班牙語由 ", "xa31er", " 貢獻。" ] [member] title = "會員" # The tab name verify_message = "請檢查您的電子郵件以驗證您的賬戶。未驗證的賬戶將在 3 天後刪除。" resend_message = ["沒有收到?請檢查您的垃圾郵件文件夾。另外,", "重新發送郵件。", "如果仍找不到,請", "聯繫我們。"] verify_confirm = "感謝您!您的賬戶已驗証。" rating = "Elo 評級:" joined = "加入時間:" seen = ["上次在線:", " 前"] reveal_info = "顯示賬號資料" account_info_heading = "賬號資料" email = "電子郵箱:" delete_account = "刪除賬號" password_reset_message = ["要更改您的用戶名、電子郵件或密碼,請", "聯系我們。"] [create-account] title = "注冊" username = "賬號:" email = "電子郵箱:" password = "密碼:" create_button = "注冊" agreement = ["我同意", "服務條款", "。"] [create-account.javascript] js-username_specs = "用戶名必須至少包含 3 個字符,並且隻能包含字母 A-Z 和數字 0-9" js-username_tooshort = "用戶名必須多於三個字母" js-username_wrongenc = "用戶名隻能包含字母 A-Z 和數字 0-9。" js-email_invalid = "這不是一個有效的郵箱" js-email_inuse = "這個電子郵箱已經被用了" js-pwd_incorrect_format = "密碼格式不正確" js-pwd_too_short = "密碼必須多於六個字母" js-pwd_too_long = "密碼禁止多於七十二個字母" js-pwd_not_pwd = "密碼禁止是'password'" [play] title = "無限棋 - 對局" # The tab title loading = "加載中" error = "錯誤" [play.main-menu] credits = "鳴謝" play = "開始" guide = "指南" editor = "棋盤編輯器" [play.guide] title = "指南" rules = "規則" rules_paragraphs = [ "無限國際象棋的規則與經典國際象棋幾乎相同,唯一的區別是棋盤在所有方向上都是無限的!以下是您需要注意的更改和說明:", "滑動移動的棋子,如車、主教和皇后,每回合移動的距離沒有限制!隻要路徑暢通無阻,您可以移動數百萬格!", ["在“經典”默認變體中,白兵在第8排晉升,黑兵在第1排晉升。在這張圖片中,細黑線表示這一點,它們很微弱,看看您是否能找到它們!兵隻需要到達相對的線即可晉升,", "不需要", "越過它。"], "棋盤方格不再用字母和數字(例如a1)表示,而是用x和y坐標對來定義。a1方格變成了(1,1),h8方格變成了(8,8)。在桌面設備上,鼠標懸停的坐標會顯示在屏幕頂部。", "其他規則與經典國際象棋相同,例如將軍、逼和、三次重復、50步規則、王車易位、“吃過路兵”等!" ] careful_heading = "小心!" careful_paragraphs = [ "無限棋盤的開放性意味著很容易利用叉子、釘子和斜線攻擊。您的后方通常非常脆弱。小心這樣的戰術!在保護國王和車的過程中要有創造力!開局策略與經典國際象棋非常不同。", "為了增強您的后方,已經創建了許多其他變體。" ] controls_heading = "控制" controls_paragraph = "點擊並拖動棋盤來移動。滾動鼠標滾輪進行縮放。點擊任何棋子,包括對手的棋子,在任何時候查看它們的合法移動!其他控制如下:" keybinds = [ " 來移動棋盤。", ["空格鍵", " 和 ", "Shift鍵", " 來縮放。"], ["Esc鍵", " 來暫停游戲。"], ["Tab鍵", " 切換屏幕邊緣的箭頭指示器,用於指向屏幕外的棋子。默認情況下,此模式設置為“防御”,顯示從當前位置可以移動到您所在位置的棋子的箭頭。但按", "Tab鍵", "可以將此模式切換為“全部”或“關閉”。“全部”模式顯示所有在那些直線和斜線上的棋子,無論它們是否可以直線或斜線移動。此設置也可以在暫停菜單中切換。點擊這些箭頭會將您傳送到它們指向的棋子位置。"], " 在本地游戲中切換“編輯模式”。這允許您將任何棋子移動到棋盤上的其他位置!非常適合分析。" ] controls_paragraph2 = "這些是您需要了解的主要控制。但如果您需要,這裡還有一些額外的操作!" keybinds_extra = [ " 將重置棋子的渲染。如果它們變得不可見,這將非常有用。如果您移動極遠的距離(例如1e21),可能會發生此錯誤。", " 將切換導航和游戲信息欄的渲染,這對錄制很有用。歡迎在游戲中進行流媒體或制作視頻!", " 將切換FPS計數器。這顯示游戲每秒更新的次數,而不總是顯示渲染的幀數,因為游戲在沒有可見變化時跳過渲染以節省計算資源。", " 將切換圖標渲染。這些是在您足夠遠地縮小時棋子的可點擊縮略圖。在導入超過50,000個棋子的游戲中,這將自動關閉,因為它是性能瓶頸,但您可以使用 ", [" (反引號,或與 ", "相同的鍵)將切換調試模式。"], ] fairy_heading = "仙子棋子" fairy_paragraph = "您已經掌握了玩默認“經典”變體所需的知識。仙子棋子不用於常規國際象棋,但被整合到其他變體中!如果您發現自己在某個變體中遇到了一些以前沒見過的棋子,讓我們在這裡學習它們的工作原理!" editing_heading = "棋盤編輯" editing_paragraphs = [ ["目前有一個外部 ", "棋盤編輯器", ",可在公共Google表單上使用!它包含使用說明。此工具需要一些基本的Google表單知識。設置后,您將能夠通過選項菜單中的“粘貼游戲”按鈕創建和導入自定義棋局位置!"], "要與朋友玩自定義棋局,請讓他們加入私人邀請,然后在開始游戲之前,雙方都粘貼游戲代碼!", "游戲內棋盤編輯器仍在計劃中。", ] back = "返回" [play.guide.pieces] chancellor = {name="大臣", description="像車和騎士的組合一樣移動。"} archbishop = {name="主教騎士", description="像主教和騎士的組合一樣移動。"} amazon = {name="女皇", description="像皇后和騎士的組合一樣移動。這是游戲中最強大的棋子!"} guard = {name="護衛", description="像國王一樣移動,但不易受將軍或將死。"} hawk = {name="鷹", description="在任何方向上跳躍2或3格。"} centaur = {name="人馬", description="像騎士和護衛的組合一樣移動。"} knightrider = {name="騎士騎士", description="像騎士一樣在一個方向上無限跳躍,直到被阻擋。"} obstacle = {name="障礙物", description="一個中立棋子(不由任何玩家控制),阻擋移動,但可以被捕獲。"} void = {name="虛空", description="一個中立棋子(不由任何玩家控制),表示棋盤的缺失。棋子不能穿過或移動到它上面。"} [play.play-menu] title = "玩 - 網上" colors = "顏色" online = "網上" local = "本地" computer = "計算機" variant = "變體" Classical = "經典" Classical_Plus = "經典+" CoaIP = "無限棋盤上的國際象棋" Pawndard = "兵棋" Knighted_Chess = "騎士國際象棋" Knightline = "騎士線" Core = "核心" Standarch = "標准弧" Pawn_Horde = "兵群" Space_Classic = "太空經典" Space = "太空" Obstocean = "障礙海洋" Abundance = "豐饒" Amazon_Chandelier = "亞馬遜吊燈" Containment = "遏制" Classical_Limit_7 = "經典 - 限制 7" CoaIP_Limit_7 = "無限棋盤 - 限制 7" Chess = "國際象棋" Classical_KOTH = "實驗: 經典 - 王者爭奪" CoaIP_KOTH = "實驗: 無限棋盤 - 王者爭奪" Omega = "展示: 歐米伽" Omega_Squared = "展示: 歐米伽²" Omega_Cubed = "展示: 歐米伽³" Omega_Fourth = "展示: 歐米伽⁴" no_clock = "沒有表" clock = "表" minutes = "分鐘" seconds = "秒" infinite_time = "無限時間" color = "顏色" piece_colors = ["隨機", "白", "黑"] private = "未發布" no = "不" yes = "是" rated = "評級" casual = "休閑" join_games = "加入現有 - 活躍游戲:" private_invite = "私人邀請:" your_invite = "您的邀請碼:" create_invite = "創建邀請" join = "加入" copy = "復制" back = "返回" code = "邀請碼" [play.gamebuttontooltips] undo_transition = "撤銷過渡" expand_fit_all = "展開以適應所有" recenter = "重新居中" rewind_move = "倒回操作" forward_move = "前進操作" pause = "暫停" [play.footer] white_to_move = "白方走起" player_white = "白方" player_black = "黑方" [play.pause] title = "暫停" resume = "繼續" arrows = "箭頭: 防御" perspective = "視角: 關閉" copy = "復制棋局" paste = "粘貼棋局" offer_draw = "提和" main_menu = "主頁" [play.drawoffer] # The draw offer UI that appears on the bottom bar question = "接受和棋提議" [play.javascript] guest_indicator = "(游客)" you_indicator = "(您)" white_to_move = "白方走棋" black_to_move = "黑方走棋" your_move = "輪到您走棋" their_move = "輪到他走棋" lost_network = "網絡丟失。" failed_to_load = "一個或多個資源加載失敗。請刷新頁面。" planned_feature = "此功能已計劃!" main_menu = "主頁" resign_game = "認輸" abort_game = "放棄游戲" offer_draw = "提和" # Offer draw button text in the pause menu accept_draw = "接受和棋" # Offer draw button text in the pause menu arrows_off = "箭頭: 關閉" arrows_defense = "箭頭: 防御" arrows_all = "箭頭: 全部" toggled = "切換" menu_online = "玩 - 網上" menu_local = "玩 - 本地" invite_error_digits = "邀請碼需要5位數字。" invite_copied = "邀請碼已復制到剪貼板。" move_counter = "步數:" constructing_mesh = "構建網格" rotating_mesh = "旋轉網格" lost_connection = "連接丟失。" please_wait = "請稍等,正在執行此任務。" webgl_unsupported = "您的瀏覽器不支持WebGL。此游戲需要WebGL才能運行。請更新您的瀏覽器。" bigints_unsupported = "BigInts 不受支持。請升級您的瀏覽器。\nBigInts 用於使棋盤無限。" shaders_failed = "無法初始化著色器程序:" failed_compiling_shaders = "編譯著色器時發生錯誤:" [play.javascript.copypaste] copied_game = "游戲已復制到剪貼板!" cannot_paste_in_public = "不能在公共比賽中粘貼游戲!" cannot_paste_after_moves = "移動后不能粘貼游戲!" clipboard_denied = "剪貼板權限被拒絕。這可能是您的瀏覽器問題。" clipboard_invalid = "剪貼板內容不符合有效的ICN格式。" game_needs_to_specify = "游戲需要指定 'Variant' 元數據或 'position' 屬性。" invalid_wincon_white = "白方有無效的勝利條件" invalid_wincon_black = "黑方有無效的勝利條件" pasting_game = "正在粘貼游戲..." pasting_in_private = "在私人比賽中粘貼游戲會導致不同步,如果對手沒有做同樣的操作!" piece_count = "棋子數量" exceeded = "超過了" changed_wincon = "將將死勝利條件更改為royalcapture,並關閉了圖標渲染。按 'P' 重新啟用(不推薦)。" loaded_from_clipboard = "從剪貼板加載了游戲!" loaded = "游戲已加載!" slidelimit_not_number = "slideLimit 游戲規則必須是數字。收到" [play.javascript.rendering] on = "開啟" off = "關閉" icon_rendering_off = "圖標渲染已關閉。" icon_rendering_on = "圖標渲染已開啟。" toggled_edit = "編輯模式已切換:" perspective = "視角" perspective_mode_on_desktop = "桌面版支持視角模式!" movement_tutorial = "WASD 移動。空格 & Shift 縮放。" regenerated_pieces = "重新生成了棋子。" [play.javascript.invites] move_mouse = "移動鼠標以重新連接。" unknown_action_received_1 = "未知操作" unknown_action_received_2 = "從邀請訂閱中接收到的服務器消息!" cannot_cancel = "無法取消未定義 ID 的邀請。" you_indicator = "(你)" you_are_white = "你是白方" you_are_black = "你是黑方" random = "隨機" accept = "接受" cancel = "取消" create_invite = "創建邀請函" cancel_invite = "取消邀請函" start_game = "開始" join_existing_active_games = "加入現有 - 活躍游戲:" [play.javascript.onlinegame] afk_warning = "你是AFK." opponent_afk = "對手是AFK." opponent_disconnected = "對手斷開連接了" opponent_lost_connection = "對手斷開連接了" auto_resigning_in = "將很快自動認輸" auto_aborting_in = "將很快自動中止" not_logged_in = "您未登錄。請登錄以重新連接到此游戲。" game_no_longer_exists = "游戲不再存在。" another_window_connected = "另一個窗口已連接。" server_restarting = "服務器即將重新啟動..." server_restarting_in = "服務器即將重新啟動" minute = "分鐘" minutes = "分鐘" [play.javascript.websocket] no_connection = "沒有連接" reconnected = "重新連接了" unable_to_identify_ip = "無法識別IP地址" online_play_disabled = "在線游戲已禁用。不支持Cookie。請嘗試其他瀏覽器。" too_many_requests = "請求次數過多,請稍后再試。" message_too_big = "消息太大。" too_many_sockets = "套接字太多。" origin_error = "來源錯誤。" connection_closed = "連接意外關閉。服務器消息。" please_report_bug = "這不應該發生,請報告此錯誤!" [play.javascript.termination] # What caused the termination of the game, in spoken language checkmate = "將死" stalemate = "僵局" repetition = "三次重復" moverule = ["", "-回合規則"] # The game inserts a number inbetween these two strings insuffmat = "棋子不足" royalcapture = "王被吃" allroyalscaptured = "所有王被吃" allpiecescaptured = "所有棋子被吃" threecheck = "三次將軍" koth = "山丘之王" resignation = "認輸" agreement = "同意" time = "超時" aborted = "已中止" # Game was cancelled (no elo exchanged) disconnect = "棄賽" # A player left [play.javascript.results] you_checkmate = "你贏了,將死!" you_time = "你超時贏了!" you_resignation = "你贏了,對手認輸!" you_disconnect = "你贏了,對手棄權!" you_royalcapture = "你贏了,皇族棋子被捕!" you_allroyalscaptured = "你贏了,所有皇族棋子被捕!" you_allpiecescaptured = "你贏了,全軍覆沒!" you_threecheck = "你贏了,三步將軍!" you_koth = "你贏了,山頂王!" you_generic = "你贏了!" draw_stalemate = "和棋,僵局!" draw_repetition = "和棋,局面重復!" draw_moverule = ["和棋,", "步規則!"] draw_insuffmat = "和棋,因棋子不足!" draw_agreement = "協議和棋" draw_generic = "和棋!" aborted = "游戲中止" opponent_checkmate = "你輸了,將死!" opponent_time = "你超時輸了!" opponent_resignation = "你輸了,對手認輸!" opponent_disconnect = "你輸了,對手棄權!" opponent_royalcapture = "你輸了,皇族棋子被捕!" opponent_allroyalscaptured = "你輸了,所有皇族棋子被捕!" opponent_allpiecescaptured = "你輸了,全軍覆沒!" opponent_threecheck = "你輸了,被三步將軍!" opponent_koth = "你輸了山頂王!" opponent_generic = "你輸了!" white_checkmate = "白方將死獲勝!" black_checkmate = "黑方將死獲勝!" bug_checkmate = "這是一個錯誤,請報告。游戲以將死結束。" white_time = "白方超時勝" black_time = "黑方超時勝!" bug_time = "這是一個錯誤,請報告!游戲因超時結束。" white_royalcapture = "白方通過捕獲皇族棋子獲勝!" black_royalcapture = "黑方通過捕獲皇族棋子獲勝!" bug_royalcapture = "這是一個錯誤,請報告!游戲因皇族棋子被捕獲而結束。" white_allroyalscaptured = "白方通過吃掉所有皇族棋子獲勝!" black_allroyalscaptured = "黑方通過吃掉所有皇族棋子獲勝!" bug_allroyalscaptured = "這是一個錯誤,請報告!游戲因所有皇族棋子被捕獲而結束。" white_allpiecescaptured = "白方通過吃掉所有棋子獲勝!" black_allpiecescaptured = "黑方通過吃掉所有棋子獲勝!" bug_allpiecescaptured = "這是一個錯誤,請報告!游戲因所有棋子被捕獲而結束。" white_threecheck = "白方通過三步將軍獲勝!" black_threecheck = "黑方通過三步將軍獲勝!" bug_threecheck = "這是一個錯誤,請報告!游戲被三步將軍結束。" white_koth = "白方通過山頂之王獲勝!" black_koth = "黑方通過山頂之王獲勝!" bug_koth = "這是一個錯誤,請報告!游戲被山頂之王結束。" bug_generic = "這是一個錯誤,請報告!" [terms] title = "服務條款" warning = ["此文件不具有法律約束力。我們隻對英文版本的文件負責。本翻譯僅供一般參考。您可以在此處訪問官方英文版本", "這裡", "。"] consent = "使用本網站即表示您同意遵守以下條款。如果您不同意,您必須立即停止使用本網站。" guardian_consent = "如果您未滿18歲,您必須獲得父母或法定監護人的同意,才能使用本網站並創建賬戶。" parents_header = "父母" parents_paragraphs = [ "本網站有一個算法,用於禁止用戶將其名字設置為常見的臟話。目前,網站上用戶之間沒有交流方式。", "目前,會員無法設置自己的個人資料圖片。我們計劃在未來允許此功能,屆時我們將盡最大努力防止不適當的個人資料圖片。", ] fair_play_header = "公平游戲" fair_play_paragraph1 = ["您不能創建超過一個賬戶。如果您希望更改與賬戶關聯的電子郵件地址,請", "聯系我們。"] fair_play_paragraph2 = "為了讓游戲保持有趣和公平,您不得:" fair_play_rules = [ "以任何方式修改或操縱代碼,包括但不限於:使用控制台命令、本地覆蓋、自定義腳本、修改HTTP請求等。這樣做可能是為了故意破壞游戲,或給自己帶來優勢。", "在評級游戲中,接受他人或程序的幫助/建議,以決定應該下什麼棋。(創建引擎是可以的,並且是鼓勵的,但您必須將其使用限制在非評級游戲中。)", "通過故意輸棋以提升對手的Elo積分,或接受對手故意輸棋以提升自己的Elo積分。這會濫用系統,導致根據您的技能水平產生不准確的評級。" ] cleanliness_header = "清潔" cleanliness_rules = [ "在網站上使用的所有語言中,您必須保持文明,不得使用粗俗語言或臟話。您不得欺凌、騷擾或威脅他人,或從事任何非法行為。您不得向其他用戶或論壇發送垃圾信息。", "您不得上傳不適當、暗示性或血腥的圖像作為您的個人資料圖片。這樣做可能會導致您被禁止或終止賬戶。" ] privacy_header = "隱私" privacy_rules = [ "目前,我們收集的唯一個人信息是電子郵件。這是為了驗証用戶的賬戶,並提供在他們請求密碼重置時証明身份的手段。我們不會發送任何促銷電子郵件或優惠。我們不會與任何人共享用戶的電子郵件地址。", "InfiniteChess.org可能會收集您在網站上使用的數據,包括您的IP地址。這是為了幫助防止來自機器人的攻擊和其他不受歡迎的實體,並保持數據庫中的准確統計信息。這不是您的家庭地址。", "您在本網站上玩的所有游戲都會成為公共信息。如果您希望保持匿名,請不要與朋友或家人分享您的用戶名。如果這是您的願望,您有責任確保沒有人發現您的用戶名與您的真實身份相關聯。", "您的賬戶在線狀態以及您上次在網站上活躍的近似時間也是公共信息。", ["盡管InfiniteChess.org將盡力在其能力范圍內保護每個人的賬戶和個人信息,但在發生黑客攻擊或數據泄露時,您不得向我們提出指控。如果發生數據泄露,用戶將在", "新聞", "頁面上收到通知。"], "網站上沒有可購買的內容。我們不收集其他個人信息。", "要從我們的服務器中刪除您的私人信息,您可以通過個人資料頁面刪除您的賬戶。唯一與您的用戶名有關且我們不會刪除的內容是您的游戲歷史記錄,因為所有游戲都是公開信息。", ] cookie_header = "Cookie政策" cookie_paragraphs = [ "本網站使用Cookie,Cookie是存儲在您瀏覽器中的小型文本文件,在連接時發送到服務器。使用這些Cookie的目的是:驗証您的登錄會話,驗証您的瀏覽器屬於它所稱的棋局,並存儲用戶的游戲偏好,以便他們在重新訪問網站時可以保留其偏好。該網站不使用第三方Cookie,Cookie不會與外部方共享。", "Cookie是本網站和游戲正常運行所必需的。如果您不希望網站存儲Cookie,您必須停止使用本網站。您可以進入瀏覽器偏好設置刪除現有的Cookie。繼續使用本網站即表示您同意使用Cookie。" ] conclusion_header = "結論" conclusion_paragraphs = [ "任何違反這些條款的行為可能導致您被禁止或終止賬戶。InfiniteChess.org希望能夠為每個人提供玩樂的機會!但是,我們保留隨時禁止或終止任何用戶賬戶的權利,原因無需披露。您不得向我們提出指控。", ["這些服務條款可能隨時修改。您有責任確保您保持最新!當這些服務條款更新時,該信息將發布在", "新聞", "頁面上。如果在服務條款更新時,您不同意新條款,您必須立即停止使用網站。您可以從您的個人資料頁面刪除賬戶。如果您刪除賬戶,所有您的私人信息和賬戶數據將被刪除,除了與您的用戶名相關的游戲歷史記錄,因為這是公開信息。"], ["此網站是開源的。隻要您遵循許可條款中規定的條件,您可以復制或分發本網站上的任何內容!", "許可條款", "。如果此鏈接失效,您有責任找到條款。"], "我們不能保証網站將100%時間運行。我們也不能保証數據永遠不會被損壞。", "您不得在網站上從事任何非法活動。", ["如果您對這些條款有任何疑問,或對網站有任何其他問題,請", "通過電子郵件聯系我們!"] ] update = "(最后更新日期:2024年7月13日。添加了警告,所有玩過的游戲可能成為公共信息,包括您賬戶上次活躍的大致時間。此外,這些條款可能會隨時更新,您有責任確保您保持更新。)" thanks = "謝謝!" [login] title = "登錄" username = "用戶名:" password = "密碼:" forgot_password = ["忘記了? ", "給我們發郵件."] login_button = "登錄" [error-pages] # Messages shown on some error pages explaining what went wrong 400_message = "收到無效參數。" 409_message = ["可能存在沖突的用戶名或電子郵件。請", "重新加載", "頁面"] 500_message = "這不應該發生。需要進行一些調試!" [news] title = "新聞" more_dev_logs = ["更多開發者日志在我們的", "Discord", "和在", "chess.com的論壇!"] [server.javascript] ws-invalid_username = "用戶名無效" ws-incorrect_password = "密碼部隊" ws-username_and_password_required = "需要用戶名和密碼" ws-username_and_password_string = "用戶名和密碼必須是字符串" ws-login_failure_retry_in = "登錄失敗" ws-seconds = "妙" # unit of time ws-second = "妙" # unit of time ws-username_length = "用戶名必須多於3個字母和少於20字母" ws-username_letters = "用戶名隻能包含字母 A-Z 和數字 0-9。" ws-username_taken = "那個用戶名已經被用了。" ws-username_bad_word = "該用戶名包含不允許的詞語。" ws-email_too_long = "您的電子有限太長。" ws-email_invalid = "這個郵箱無效。" ws-email_in_use = "這個郵箱已經被用了。" ws-you_are_banned = "您被封禁了" ws-password_length = "密碼必須多於6個字母,並且少於72個字母。" ws-password_format = "密碼格式不正確" ws-password_password = "密碼禁止是password" ws-refresh_token_not_found_logged_out = "沒有成員擁有該刷新令牌 (已經等處)" ws-refresh_token_not_found = "沒有成員擁有該刷新令牌" ws-refresh_token_expired = "未找到刷新令牌(會話過期)" ws-refresh_token_invalid = "刷新令牌過期或被篡改" ws-member_not_found = "賬戶未找到" ws-forbidden_wrong_account = "禁止。這不是您的賬戶。" ws-deleting_account_not_found = "刪除帳戶失敗。找不到帳戶。" ws-server_error = "抱歉,發生伺服器錯誤!請返回。" ws-unable_to_identify_client_ip = "無法識別客戶端 IP 地址" ws-you_are_banned_by_server = "您被禁用了" ws-too_many_requests_to_server = "請求過多。請稍後再試。" ws-bad_request = "400" ws-not_found = "404" ws-forbidden = "403" ws-unauthorized_patron_page = "未經授權。此頁面僅限會員訪問。" ws-username_reserved = "用戶名已被保留" ws-already_in_game = "已在游戲中" ws-server_restarting = "服務器正在重啟" ws-minutes = "分鐘" ws-server_under_maintenance = "服務器正在維護中。請稍后再試!" # Can be changed at will to change the display message. ws-minute = "分鐘" ws-no_abort_game_over = "游戲結束無法中止" ws-no_abort_after_moves = "無法在棋步后中止" ws-game_aborted_cheating = "因作弊游戲中止" ws-cannot_resign_finished_game = "無法放棄已完成的游戲" ws-invalid_code = "無效代碼" ws-game_aborted = "游戲中止" ================================================ FILE: tsconfig.json ================================================ // tsconfig.json { "compilerOptions": { "outDir": "./dist", "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "alwaysStrict": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitOverride": true, "noImplicitReturns": true, "noImplicitThis": true, "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, "strict": true, "noEmitOnError": true, "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", "allowJs": true // Allows TypeScript to import JavaScript modules. // "checkJs": true // Reports errors in .js files based on JSDoc and inference. }, // Tells TypeScript and every tool that uses this file (IntelliSense, ESLint): "My source code is in the src folder." "exclude": [ "node_modules", // Don't look in node_modules "dist", // Don't look in the output directory "dev-utils" ] } ================================================ FILE: vitest.config.ts ================================================ // vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', setupFiles: ['src/tests/tests-setup.ts'], include: ['**/*.test.ts', '**/*.test.js'], exclude: ['node_modules', 'dist'], }, });