From 4db956289ab8b6d15cdeb5055021d0756f374746 Mon Sep 17 00:00:00 2001 From: nkzw-bot Date: Sun, 5 May 2024 10:53:46 +0900 Subject: [PATCH] Initial Commit. GitOrigin-RevId: bd4a3caef8e73a6adca9cd1cbf197d6e0c20eda9 --- .eslintrc.cjs | 107 + .github/workflows/test.yml | 74 + .gitignore | 33 + .npmrc | 3 + .prettierignore | 33 + .vscode/extensions.json | 10 + .vscode/settings.json | 22 + @types/vitest.d.ts | 8 + CODE_OF_CONDUCT.md | 131 + LICENSE.md | 29 + README.md | 137 + apollo/Action.tsx | 985 + apollo/ActionMap.json | 175 + apollo/ActionResponse.tsx | 241 + apollo/CharacterMessage.tsx | 17 + apollo/Condition.tsx | 141 + apollo/ConditionMap.json | 4 + apollo/Effects.tsx | 208 + apollo/GameOver.tsx | 399 + apollo/HiddenAction.tsx | 351 + apollo/MapMetadata.tsx | 10 + apollo/Types.tsx | 57 + apollo/__tests__/Action.test.tsx | 118 + apollo/__tests__/AttackBuilding.test.tsx | 68 + apollo/__tests__/Skill.test.tsx | 600 + apollo/__tests__/Supply.test.tsx | 114 + apollo/action-mutators/ActionMutators.tsx | 151 + apollo/actions/applyActionResponse.tsx | 556 + apollo/actions/applyEndTurnActionResponse.tsx | 79 + apollo/actions/encodeGameActionResponse.tsx | 107 + apollo/actions/executeGameAction.tsx | 107 + apollo/actions/validateAction.tsx | 37 + .../attack-direction/getAttackDirection.tsx | 57 + apollo/lib/GameTimerValue.tsx | 21 + apollo/lib/__tests__/nameGenerator.test.tsx | 9 + apollo/lib/applyConditions.tsx | 116 + apollo/lib/checkWinCondition.tsx | 300 + apollo/lib/computeVisibleActions.tsx | 550 + apollo/lib/decodeGameActionResponse.tsx | 43 + apollo/lib/dropLabelsFromActionResponse.tsx | 76 + apollo/lib/dropLabelsFromGameState.tsx | 26 + apollo/lib/gameHasEnded.tsx | 10 + apollo/lib/getActionResponseVectors.tsx | 102 + apollo/lib/getColorName.tsx | 16 + apollo/lib/getMessageKey.tsx | 13 + apollo/lib/getVisibleEntities.tsx | 29 + apollo/lib/getWinningTeam.tsx | 13 + apollo/lib/hasTimer.tsx | 18 + apollo/lib/mapWithAIPlayers.tsx | 21 + apollo/lib/maybeDecodeActionResponse.tsx | 19 + apollo/lib/nameGenerator.tsx | 127 + apollo/lib/processRewards.tsx | 48 + apollo/lib/timeoutActionResponseMutator.tsx | 7 + apollo/lib/toCampaignSlug.tsx | 5 + apollo/lib/toMapSlug.tsx | 5 + apollo/lib/transformEffectValue.tsx | 86 + apollo/lib/updateVisibleEntities.tsx | 28 + apollo/package.json | 17 + apollo/push/Types.tsx | 29 + apollo/routes/getCampaignRoute.tsx | 8 + apollo/routes/getMapRoute.tsx | 5 + apollo/routes/getUserRoute.tsx | 8 + apollo/socket/Room.tsx | 7 + apollo/socket/Types.tsx | 40 + ares/package.json | 63 + art/BiomeVariants.tsx | 13 + art/Sprites.tsx | 280 + art/VariantConfiguration.tsx | 421 + art/Variants.tsx | 79 + art/package.json | 18 + art/types/athena-crisis-asset-variants.d.ts | 12 + artemis/package.json | 77 + athena/MapData.tsx | 649 + athena/Radius.tsx | 382 + athena/Vision.tsx | 91 + athena/WinConditions.tsx | 797 + athena/__tests__/MapData.test.tsx | 62 + athena/__tests__/Player.test.tsx | 57 + athena/__tests__/Radius.test.tsx | 1224 ++ athena/generator/MapGenerator.tsx | 483 + athena/info/AttackSprite.tsx | 30 + athena/info/Building.tsx | 600 + athena/info/BuildingIDs.tsx | 1 + athena/info/Decorator.tsx | 459 + athena/info/FactionNames.tsx | 30 + athena/info/MovementType.tsx | 95 + athena/info/Music.tsx | 97 + athena/info/Skill.tsx | 849 + athena/info/SpriteVariants.tsx | 66 + athena/info/Tile.tsx | 1703 ++ athena/info/Unit.tsx | 3822 ++++ athena/info/UnitID.tsx | 14 + athena/info/UnitNames.tsx | 117 + athena/info/__tests__/UnitNames.test.tsx | 75 + athena/lib/Modifier.tsx | 112 + athena/lib/__tests__/assignUnitNames.test.tsx | 132 + .../lib/__tests__/calculateClusters.test.tsx | 50 + athena/lib/__tests__/canDeploy.test.tsx | 78 + .../__tests__/determineUnitsToCreate.test.tsx | 330 + athena/lib/__tests__/startGame.test.tsx | 127 + athena/lib/__tests__/validateTeams.test.tsx | 199 + athena/lib/assignUnitNames.tsx | 31 + athena/lib/calculateClusters.tsx | 58 + athena/lib/calculateDamage.tsx | 25 + athena/lib/calculateEmptyClusters.tsx | 27 + athena/lib/calculateFunds.tsx | 25 + athena/lib/calculateLikelyDamage.tsx | 40 + athena/lib/canBuild.tsx | 25 + athena/lib/canDeploy.tsx | 19 + athena/lib/canLoad.tsx | 16 + athena/lib/canPlaceDecorator.tsx | 16 + athena/lib/canPlaceLightning.tsx | 16 + athena/lib/canPlaceRailTrack.tsx | 31 + athena/lib/canPlaceTile.tsx | 213 + athena/lib/convertBiome.tsx | 44 + athena/lib/determineUnitsToCreate.tsx | 131 + athena/lib/dropInactivePlayers.tsx | 16 + athena/lib/dropLabels.tsx | 12 + .../encodedMapDataHasHiddenWinCondition.tsx | 5 + athena/lib/filterNullables.tsx | 12 + athena/lib/followMovementPath.tsx | 21 + athena/lib/formatText.tsx | 22 + athena/lib/getActivePlayers.tsx | 18 + athena/lib/getAllUnitsToRefill.tsx | 25 + athena/lib/getAttackStatusEffect.tsx | 31 + athena/lib/getAttackableEntitiesInRange.tsx | 31 + athena/lib/getAttributeRange.tsx | 61 + athena/lib/getAvailableUnitActions.tsx | 121 + athena/lib/getAverageVector.tsx | 18 + athena/lib/getBiomeStyle.tsx | 188 + athena/lib/getBuildableUnits.tsx | 21 + athena/lib/getChargeValue.tsx | 28 + athena/lib/getDecoratorIndex.tsx | 12 + athena/lib/getDecoratorsAtField.tsx | 28 + athena/lib/getDefenseStatusEffect.tsx | 23 + athena/lib/getDeployableVectors.tsx | 24 + athena/lib/getFirstHumanPlayer.tsx | 5 + athena/lib/getFloatingEdgeModifier.tsx | 138 + athena/lib/getHealCost.tsx | 13 + athena/lib/getHealableVectors.tsx | 22 + athena/lib/getLeaders.tsx | 42 + athena/lib/getMapSize.tsx | 21 + athena/lib/getModifier.tsx | 569 + athena/lib/getMovementPath.tsx | 28 + athena/lib/getParentToMoveTo.tsx | 51 + athena/lib/getPathFields.tsx | 16 + athena/lib/getRescuableVectors.tsx | 14 + athena/lib/getSabotageableVectors.tsx | 22 + athena/lib/getUnitValue.tsx | 9 + athena/lib/getUnitsByPositions.tsx | 19 + athena/lib/getUnitsToHealOnBuildings.tsx | 19 + athena/lib/getUnitsToRefill.tsx | 39 + athena/lib/getVectorRadius.tsx | 39 + athena/lib/hasLeader.tsx | 20 + athena/lib/hasLowAmmoSupply.tsx | 10 + athena/lib/indexToSpriteVector.tsx | 8 + athena/lib/indexToVector.tsx | 6 + athena/lib/isAmphibiousOnLand.tsx | 6 + athena/lib/isFuelConsumingUnit.tsx | 15 + athena/lib/isPvP.tsx | 6 + athena/lib/matchesActiveType.tsx | 17 + athena/lib/matchesPlayerList.tsx | 8 + athena/lib/maybeConvertPlayer.tsx | 12 + athena/lib/mergeTeams.tsx | 30 + athena/lib/refillUnits.tsx | 15 + athena/lib/removeLeader.tsx | 28 + athena/lib/resizeMap.tsx | 102 + athena/lib/shouldRemoveUnit.tsx | 18 + athena/lib/singleTilesToModifiers.tsx | 18 + athena/lib/startGame.tsx | 27 + athena/lib/updateActivePlayers.tsx | 29 + athena/lib/updatePlayer.tsx | 12 + athena/lib/updatePlayers.tsx | 13 + athena/lib/validateMap.tsx | 420 + athena/lib/validateSkills.tsx | 29 + athena/lib/validateTeams.tsx | 86 + athena/lib/verifyTiles.tsx | 160 + athena/lib/withModifiers.tsx | 6 + athena/map/AIBehavior.tsx | 15 + athena/map/Biome.tsx | 59 + athena/map/Building.tsx | 210 + athena/map/Configuration.tsx | 64 + athena/map/Entity.tsx | 158 + athena/map/PlainMap.tsx | 39 + athena/map/Player.tsx | 497 + athena/map/Reward.tsx | 68 + athena/map/Serialization.tsx | 133 + athena/map/SpriteVector.tsx | 19 + athena/map/Statistics.tsx | 76 + athena/map/Team.tsx | 50 + athena/map/Unit.tsx | 876 + athena/map/Vector.tsx | 145 + athena/map/isPlayable.tsx | 14 + athena/map/vec.tsx | 23 + athena/mutation/toggleLightningTile.tsx | 23 + athena/mutation/writeTile.tsx | 53 + athena/package.json | 22 + codegen/generate-actions.tsx | 1018 + codegen/generate-all.tsx | 7 + codegen/generate-campaign-names.tsx | 46 + codegen/generate-graphql.tsx | 43 + codegen/generate-routes.tsx | 167 + codegen/generate-translations.tsx | 372 + codegen/lib/sign.tsx | 7 + codegen/lib/traverse.tsx | 17 + codegen/package.json | 25 + deimos/package.json | 32 + dionysus/BaseAI.tsx | 124 + dionysus/DionysusAlpha.tsx | 1272 ++ dionysus/lib/estimateClosestTarget.tsx | 246 + dionysus/lib/findPathToTarget.tsx | 105 + dionysus/lib/getAttackableArea.tsx | 12 + .../lib/getAttackableUnitsWithinRadius.tsx | 45 + dionysus/lib/getBuildingWeight.tsx | 20 + dionysus/lib/getInterestingVectors.tsx | 198 + .../lib/getInterestingVectorsByAbilities.tsx | 83 + dionysus/lib/getPossibleAttacks.tsx | 268 + dionysus/lib/getPossibleUnitAbilities.tsx | 69 + dionysus/lib/getUnitInfosWithMaxVision.tsx | 19 + dionysus/lib/getWinConditionVectors.tsx | 21 + dionysus/lib/needsSupply.tsx | 17 + dionysus/lib/shouldAttack.tsx | 52 + dionysus/lib/shouldCaptureBuilding.tsx | 27 + dionysus/lib/sortByDamage.tsx | 45 + dionysus/lib/sortPossibleAttacks.tsx | 16 + dionysus/package.json | 17 + docs/content/examples/map-data-examples.tsx | 47 + docs/content/examples/map-editor.tsx | 30 + docs/content/pages/core-concepts/actions.mdx | 1 + .../immutable-data-structures.mdx | 60 + docs/content/pages/core-concepts/map-data.mdx | 124 + docs/content/pages/core-concepts/overview.mdx | 7 + docs/content/pages/getting-started.mdx | 20 + docs/content/pages/index.mdx | 26 + docs/content/pages/playground/map-editor.mdx | 10 + docs/content/playground/ClientComponent.tsx | 31 + docs/content/playground/ClientScope.tsx | 51 + .../content/playground/PlaygroundDemoGame.tsx | 37 + docs/content/playground/PlaygroundGame.tsx | 81 + docs/content/public/apple-touch-icon.png | Bin 0 -> 3185 bytes docs/content/public/athena-crisis.svg | 1 + docs/content/public/favicon.ico | Bin 0 -> 2462 bytes docs/content/public/favicon.png | Bin 0 -> 284 bytes docs/content/public/fonts/AthenaNova.woff2 | Bin 0 -> 17756 bytes docs/content/styles.css | 0 docs/package.json | 35 + docs/vocs.config.tsx | 142 + eslint-plugin/index.js | 24 + eslint-plugin/no-copy-expression.js | 23 + eslint-plugin/no-date-now.js | 24 + eslint-plugin/no-fbt-import.js | 23 + eslint-plugin/no-inline-css.js | 36 + eslint-plugin/no-lazy-import.js | 23 + eslint-plugin/package.json | 12 + eslint-plugin/require-fbt-description.js | 30 + eslint-plugin/use-mutation-types.js | 18 + fixtures/package.json | 17 + git-hooks/pre-commit | 7 + hephaestus/UnknownTypeError.tsx | 5 + hephaestus/dateNow.tsx | 12 + hephaestus/getFirst.tsx | 6 + hephaestus/getFirstOrThrow.tsx | 9 + hephaestus/getOrThrow.tsx | 9 + hephaestus/groupBy.tsx | 18 + hephaestus/isPositiveInteger.tsx | 3 + hephaestus/isPresent.tsx | 3 + hephaestus/jenkinsHash.tsx | 69 + hephaestus/maxBy.tsx | 8 + hephaestus/minBy.tsx | 8 + hephaestus/package.json | 11 + hephaestus/parseInteger.tsx | 4 + hephaestus/random.tsx | 3 + hephaestus/randomEntry.tsx | 5 + hephaestus/sanitizeText.tsx | 12 + hephaestus/sortBy.tsx | 3 + hephaestus/toSlug.tsx | 7 + hephaestus/toTag.tsx | 3 + hera/Building.tsx | 343 + hera/Cursor.tsx | 82 + hera/Decorators.tsx | 159 + hera/Fog.tsx | 136 + hera/GameMap.tsx | 1901 ++ hera/Label.tsx | 54 + hera/Map.tsx | 354 + hera/MapAnimations.tsx | 721 + hera/Mask.tsx | 273 + hera/MaskWithSubtiles.tsx | 68 + hera/Radius.tsx | 467 + hera/Tick.tsx | 90 + hera/TileDecorator.tsx | 84 + hera/TileDecorators.tsx | 76 + hera/Tiles.tsx | 300 + hera/Types.tsx | 287 + hera/Unit.tsx | 1214 ++ hera/action-response/ActionResponseError.tsx | 18 + .../action-response/processActionResponse.tsx | 712 + hera/animations/Animation.tsx | 161 + hera/animations/AttackAnimation.tsx | 114 + hera/animations/BuildingCreate.tsx | 45 + hera/animations/Explosion.tsx | 127 + hera/animations/Fireworks.tsx | 35 + hera/animations/Heal.tsx | 35 + hera/animations/HealthAnimation.tsx | 108 + hera/animations/Rescue.tsx | 48 + hera/animations/Sabotage.tsx | 35 + hera/animations/Shake.tsx | 25 + hera/animations/Spawn.tsx | 48 + hera/animations/UpgradeAnimation.tsx | 49 + hera/animations/addExplosionAnimation.tsx | 77 + hera/animations/attackActionAnimation.tsx | 85 + hera/animations/attackFlashAnimation.tsx | 40 + hera/animations/explosionAnimation.tsx | 44 + hera/animations/generateFrames.tsx | 17 + hera/animations/secretDiscoveredAnimation.tsx | 49 + hera/audio/LoggedOutVolumeControl.tsx | 32 + hera/audio/Music.tsx | 117 + hera/audio/VolumeControl.tsx | 124 + hera/behavior/AbstractSelectBehavior.tsx | 97 + hera/behavior/Attack.tsx | 159 + hera/behavior/AttackRadius.tsx | 29 + hera/behavior/Base.tsx | 231 + hera/behavior/Behavior.tsx | 57 + hera/behavior/BuySkills.tsx | 174 + hera/behavior/CreateBuilding.tsx | 138 + hera/behavior/CreateUnit.tsx | 299 + hera/behavior/DropUnit.tsx | 217 + hera/behavior/Heal.tsx | 171 + hera/behavior/Menu.tsx | 469 + hera/behavior/Move.tsx | 521 + hera/behavior/NullBehavior.tsx | 15 + hera/behavior/Radar.tsx | 111 + hera/behavior/Rescue.tsx | 50 + hera/behavior/Sabotage.tsx | 50 + hera/behavior/Transport.tsx | 105 + .../activatePower/activatePowerAction.tsx | 107 + hera/behavior/attack/AttackSelector.tsx | 286 + hera/behavior/attack/attackAction.tsx | 33 + hera/behavior/attack/clientAttackAction.tsx | 139 + .../behavior/attack/getAttackableEntities.tsx | 40 + hera/behavior/attack/getDamageColor.tsx | 19 + hera/behavior/attack/getHealthColor.tsx | 10 + hera/behavior/attack/hiddenAttackActions.tsx | 232 + hera/behavior/buySkill/buySkillAction.tsx | 78 + hera/behavior/capture/captureAction.tsx | 47 + hera/behavior/confirm/ConfirmAction.tsx | 55 + .../createBuilding/createBuildingAction.tsx | 62 + .../createTracks/createTracksAction.tsx | 22 + hera/behavior/createUnit/createUnitAction.tsx | 43 + hera/behavior/drop/dropUnitAction.tsx | 55 + hera/behavior/endTurn/canEndTurn.tsx | 13 + hera/behavior/endTurn/endTurnAction.tsx | 57 + hera/behavior/heal/healAction.tsx | 68 + hera/behavior/move/clientMoveAction.tsx | 66 + hera/behavior/move/hiddenMoveAction.tsx | 60 + hera/behavior/move/moveAction.tsx | 41 + hera/behavior/move/syncMoveAction.tsx | 155 + hera/behavior/radar/toggleLightningAction.tsx | 61 + hera/behavior/rescue/rescueAction.tsx | 66 + hera/behavior/sabotage/sabotageAction.tsx | 51 + hera/behavior/unfold/unfoldAction.tsx | 89 + hera/bottom-drawer/BottomDrawer.tsx | 159 + hera/campaign/CampaignEditor.tsx | 654 + hera/campaign/EffectDialogue.tsx | 41 + hera/campaign/Level.tsx | 545 + hera/campaign/LevelDialogue.tsx | 117 + hera/campaign/Types.tsx | 77 + hera/campaign/hooks/useEffectCharacters.tsx | 13 + hera/campaign/lib/PlayStyle.tsx | 30 + hera/campaign/lib/getAllEffectCharacters.tsx | 27 + hera/campaign/lib/sortByDepth.tsx | 14 + .../panels/CampaignEditorControlPanel.tsx | 88 + .../panels/CampaignEditorSettingsPanel.tsx | 217 + hera/card/AttributeGrid.tsx | 87 + hera/card/BuildingCard.tsx | 357 + hera/card/CardTitle.tsx | 54 + hera/card/InlineTileList.tsx | 341 + hera/card/LeaderCard.tsx | 117 + hera/card/LeaderTitle.tsx | 11 + hera/card/MovementBox.tsx | 42 + hera/card/Range.tsx | 279 + hera/card/TileBox.tsx | 20 + hera/card/TileCard.tsx | 326 + hera/card/TilePreview.tsx | 98 + hera/card/UnitCard.tsx | 917 + hera/card/lib/CoverRange.tsx | 20 + hera/card/lib/tileFieldHasDecorator.tsx | 11 + hera/character/MiniPortrait.tsx | 61 + hera/character/Portrait.tsx | 97 + hera/character/PortraitPicker.tsx | 59 + hera/editor/MapEditor.tsx | 1098 + hera/editor/ResizeHandle.tsx | 232 + hera/editor/Types.tsx | 128 + hera/editor/behavior/DesignBehavior.tsx | 766 + hera/editor/behavior/EntityBehavior.tsx | 212 + hera/editor/behavior/VectorBehavior.tsx | 50 + hera/editor/hooks/useColumns.tsx | 35 + hera/editor/hooks/useSetTags.tsx | 12 + hera/editor/hooks/useZoom.tsx | 57 + hera/editor/lib/AIBehaviorLink.tsx | 78 + hera/editor/lib/ActionCard.tsx | 459 + hera/editor/lib/BiomeIcon.tsx | 43 + hera/editor/lib/DeleteTile.tsx | 49 + hera/editor/lib/EffectTitle.tsx | 103 + hera/editor/lib/WinConditionCard.tsx | 297 + hera/editor/lib/ZoomButton.tsx | 64 + hera/editor/lib/canFillTile.tsx | 5 + hera/editor/lib/changePlayer.tsx | 51 + hera/editor/lib/getMapValidationErrorText.tsx | 58 + hera/editor/lib/hasGameEndCondition.tsx | 10 + hera/editor/lib/navigate.tsx | 36 + hera/editor/lib/selectWinConditionEffect.tsx | 38 + hera/editor/lib/tileFieldHasAnimation.tsx | 10 + hera/editor/lib/updateUndoStack.tsx | 27 + hera/editor/lib/useGridNavigation.tsx | 24 + hera/editor/panels/DecoratorPanel.tsx | 115 + hera/editor/panels/DesignPanel.tsx | 364 + hera/editor/panels/EffectsPanel.tsx | 302 + hera/editor/panels/EntityPanel.tsx | 454 + hera/editor/panels/EvaluationPanel.tsx | 256 + hera/editor/panels/MapEditorControlPanel.tsx | 501 + hera/editor/panels/MapEditorSettingsPanel.tsx | 246 + hera/editor/panels/RestrictionsPanel.tsx | 213 + hera/editor/panels/SetupPanel.tsx | 100 + hera/editor/panels/WinConditionPanel.tsx | 180 + hera/editor/selectors/BiomeSelector.tsx | 89 + .../editor/selectors/EditorPlayerSelector.tsx | 55 + hera/editor/selectors/EffectSelector.tsx | 49 + hera/editor/selectors/LabelSelector.tsx | 101 + hera/hooks/useAnimationSpeed.tsx | 36 + hera/hooks/useClientGame.tsx | 61 + hera/hooks/useClientGameAction.tsx | 110 + hera/hooks/useEffects.tsx | 9 + hera/hooks/useFactionNameDataSource.tsx | 19 + hera/hooks/useHide.tsx | 34 + hera/hooks/useMapData.tsx | 6 + hera/hooks/useSprites.tsx | 23 + hera/hooks/useTagDataSource.tsx | 22 + hera/hooks/useUserMap.tsx | 50 + hera/i18n/getCampaignMessage.tsx | 21 + hera/i18n/getLocale.tsx | 101 + hera/i18n/getMapName.tsx | 5 + hera/i18n/injectTranslation.tsx | 72 + hera/i18n/intlList.tsx | 167 + hera/lib/AnimationKey.tsx | 20 + hera/lib/AnimationSpeed.tsx | 5 + hera/lib/ConfirmActionStyle.tsx | 5 + hera/lib/Edges.tsx | 3 + hera/lib/FogStyle.tsx | 4 + hera/lib/MapSize.tsx | 7 + hera/lib/TiltStyle.tsx | 4 + hera/lib/addEndTurnAnimations.tsx | 118 + hera/lib/addFlashAnimation.tsx | 33 + hera/lib/addMoveAnimation.tsx | 34 + hera/lib/addPlayerLoseAnimation.tsx | 46 + hera/lib/animateHeal.tsx | 68 + hera/lib/animateSupply.tsx | 48 + hera/lib/attackSpriteHasVariants.tsx | 8 + hera/lib/botToUser.tsx | 29 + hera/lib/captureException.tsx | 9 + hera/lib/explodeUnits.tsx | 66 + hera/lib/getAnyBuildingTileField.tsx | 21 + hera/lib/getAnyUnitTile.tsx | 18 + hera/lib/getBuildingSpritePosition.tsx | 29 + hera/lib/getCoverName.tsx | 33 + hera/lib/getFlashDelay.tsx | 28 + hera/lib/getMapSizeName.tsx | 28 + hera/lib/getPlayerDefeatedMessage.tsx | 17 + hera/lib/getSkillConfigForDisplay.tsx | 212 + hera/lib/getTranslatedBiomeName.tsx | 26 + hera/lib/getTranslatedColorName.tsx | 26 + hera/lib/getTranslatedEntityName.tsx | 53 + hera/lib/getTranslatedFactionName.tsx | 54 + hera/lib/getTranslatedTileTypeName.tsx | 57 + hera/lib/getTranslatedTimerName.tsx | 21 + hera/lib/getUnitDirection.tsx | 10 + hera/lib/getWinCriteriaName.tsx | 66 + hera/lib/isFakeEndTurn.tsx | 8 + hera/lib/isInView.tsx | 9 + hera/lib/maskClassName.tsx | 7 + hera/lib/sleep.tsx | 17 + hera/lib/spawn.tsx | 80 + hera/lib/sprite.tsx | 20 + hera/lib/startGameAnimation.tsx | 26 + hera/lib/throwActionError.tsx | 10 + hera/lib/tick.tsx | 74 + hera/lib/toTransformOrigin.tsx | 20 + hera/lib/upgradeUnits.tsx | 49 + hera/package.json | 36 + hera/render/Images.tsx | 481 + hera/render/renderFloatingTile.tsx | 73 + hera/render/renderTile.tsx | 300 + hera/types/@emotion__babel-plugin.d.ts | 1 + hera/types/Fbt.tsx | 3 + hera/types/athena-crisis-images.d.ts | 28 + hera/types/babel-plugin-fbt-import.d.ts | 1 + hera/types/babel-plugin-fbt-runtime.d.ts | 1 + hera/types/babel-plugin-fbt.d.ts | 1 + hera/ui/ActionBar.tsx | 57 + hera/ui/ActionWheel.tsx | 685 + hera/ui/AdminActions.tsx | 87 + hera/ui/Banner.tsx | 287 + hera/ui/CharacterMessage.tsx | 437 + hera/ui/CurrentGameCard.tsx | 198 + hera/ui/EntityPickerFlyout.tsx | 143 + hera/ui/ErrorOverlay.tsx | 117 + hera/ui/FlashFlyout.tsx | 111 + hera/ui/Flyout.tsx | 285 + hera/ui/Funds.tsx | 75 + hera/ui/GameActions.tsx | 731 + hera/ui/GameDialog.tsx | 458 + hera/ui/MapDetails.tsx | 196 + hera/ui/MapInfo.tsx | 378 + hera/ui/Message.tsx | 295 + hera/ui/MiniPlayerIcon.tsx | 43 + hera/ui/ModeSelectButton.tsx | 290 + hera/ui/NewVersionNotification.tsx | 110 + hera/ui/Notice.tsx | 97 + hera/ui/Notification.tsx | 106 + hera/ui/PlayerCard.tsx | 431 + hera/ui/PlayerIcon.tsx | 96 + hera/ui/PlayerPosition.tsx | 90 + hera/ui/PlayerSelector.tsx | 403 + hera/ui/ReplayBar.tsx | 148 + hera/ui/SelectEntity.tsx | 60 + hera/ui/SkillDescription.tsx | 737 + hera/ui/SkillDialog.tsx | 715 + hera/ui/TeamSelector.tsx | 434 + hera/ui/UILabel.tsx | 22 + hera/ui/Vs.tsx | 31 + hera/ui/fps/Fps.tsx | 11 + hera/ui/fps/FpsComponent.tsx | 33 + hera/ui/lib/UnknownUser.tsx | 12 + hera/ui/lib/formatCharacterText.tsx | 39 + hera/ui/lib/getClientCoordinates.tsx | 20 + hera/ui/lib/maybeFade.tsx | 14 + hera/ui/lib/measureText.tsx | 60 + hera/ui/lib/useSkipAnimation.tsx | 22 + .../WinConditionDescription.tsx | 454 + hera/win-conditions/WinConditionTitle.tsx | 34 + hermes/Configuration.tsx | 3 + hermes/Rating.tsx | 26 + hermes/Types.tsx | 56 + hermes/game/onGameEnd.tsx | 90 + hermes/game/toClientGame.tsx | 39 + hermes/getCampaignLevelDepths.tsx | 38 + hermes/map-fixtures/demo-1.tsx | 525 + hermes/map-fixtures/demo-2.tsx | 679 + hermes/map-fixtures/demo-3.tsx | 695 + hermes/map-fixtures/shrine.tsx | 413 + .../map-fixtures/they-are-close-to-home.tsx | 525 + hermes/package.json | 17 + hermes/toCampaign.tsx | 48 + hermes/toLevelMap.tsx | 9 + hermes/toPlainCampaign.tsx | 24 + hermes/toPlainLevelList.tsx | 11 + hermes/unrollCampaign.tsx | 28 + hermes/validateCampaign.tsx | 40 + i18n/AvailableLanguages.tsx | 13 + i18n/Common.cjs | 3 + i18n/package.json | 12 + infra/assets/crowdin.svg | 1 + infra/assets/hetzner.svg | 1 + infra/assets/null.svg | 1 + infra/assets/polar.svg | 1 + infra/babelFbtPlugins.tsx | 30 + infra/isOpenSource.tsx | 9 + infra/resolver.tsx | 30 + infra/root.ts | 4 + infra/startServer.tsx | 33 + offline/apple-touch-icon.png | Bin 0 -> 3112 bytes offline/fonts/AthenaNova.woff2 | Bin 0 -> 17756 bytes offline/fonts/MadouFutoMaru.woff2 | Bin 0 -> 400080 bytes offline/fonts/PressStart2P.woff2 | Bin 0 -> 29184 bytes offline/index.html | 183 + offline/keyart.jpg | Bin 0 -> 670193 bytes offline/package.json | 16 + offline/vite.config.ts | 7 + package.json | 118 + patches/@remix-run__router@1.16.1.patch | 22 + patches/eslint-plugin-import@2.29.1.patch | 14 + patches/fbt@1.0.2.patch | 26 + patches/graphql-helix@1.13.0.patch | 26 + patches/howler@2.2.4.patch | 64 + patches/p-limit@5.0.0.patch | 11 + patches/resend@3.2.0.patch | 102 + pnpm-lock.yaml | 16677 ++++++++++++++++ pnpm-workspace.yaml | 20 + prettier.config.mjs | 7 + scripts/package.json | 23 + tests/__tests__/AIBehavior.test.tsx | 848 + tests/__tests__/AITransportMove.test.tsx | 257 + tests/__tests__/Building.test.tsx | 171 + tests/__tests__/CreateBuildingFog.test.tsx | 53 + tests/__tests__/CreateUnitFog.test.tsx | 109 + tests/__tests__/Decorators.test.tsx | 102 + tests/__tests__/Effects.test.tsx | 671 + tests/__tests__/EndTurn.test.tsx | 205 + tests/__tests__/EntityLabel.test.tsx | 162 + tests/__tests__/Fog.test.tsx | 274 + tests/__tests__/FogMove.test.tsx | 157 + tests/__tests__/FormatActions.test.tsx | 247 + tests/__tests__/GameOver.test.tsx | 405 + tests/__tests__/HaltingProblem.test.tsx | 233 + tests/__tests__/HiddenAction.test.tsx | 264 + tests/__tests__/Lightning.test.tsx | 115 + tests/__tests__/MapGenerator.test.tsx | 34 + tests/__tests__/Misses.test.tsx | 148 + tests/__tests__/Move.test.tsx | 227 + tests/__tests__/Power.test.tsx | 88 + tests/__tests__/Rescue.test.tsx | 119 + tests/__tests__/Reward.test.tsx | 224 + tests/__tests__/Spawn.test.tsx | 129 + tests/__tests__/Statistics.test.tsx | 375 + tests/__tests__/Transport.test.tsx | 64 + tests/__tests__/Unfold.test.tsx | 94 + tests/__tests__/Unit.test.tsx | 318 + tests/__tests__/WinConditions.test.tsx | 1653 ++ ...ill-hop-between-islands-if-necessary-1.png | Bin 0 -> 10055 bytes ...-on-sea-when-loaded-with-other-units-1.png | Bin 0 -> 14846 bytes ...l-units-when-loaded-with-other-units-1.png | Bin 0 -> 12976 bytes ...roperly-when-they-are-created-in-fog-1.png | Bin 0 -> 23209 bytes ...a-unit-is-created-and-moved-into-fog-1.png | Bin 0 -> 38032 bytes ...orsTest-correctly-renders-decorators-1.png | Bin 0 -> 10953 bytes ...ly-for-units-when-a-turn-ends-in-fog-1.png | Bin 0 -> 44817 bytes ...ly-for-units-when-a-turn-ends-in-fog-2.png | Bin 0 -> 45050 bytes ...ly-for-units-when-a-turn-ends-in-fog-3.png | Bin 0 -> 45015 bytes ...ly-for-units-when-a-turn-ends-in-fog-4.png | Bin 0 -> 45638 bytes ...ly-for-units-when-a-turn-ends-in-fog-5.png | Bin 0 -> 39547 bytes ...ly-for-units-when-a-turn-ends-in-fog-6.png | Bin 0 -> 39733 bytes ...ly-for-units-when-a-turn-ends-in-fog-7.png | Bin 0 -> 39839 bytes ...ly-for-units-when-a-turn-ends-in-fog-8.png | Bin 0 -> 13488 bytes ...nMove'-action-is-marked-as-completed-1.png | Bin 0 -> 32285 bytes ...nMove`-action-is-marked-as-completed-1.png | Bin 0 -> 31909 bytes ...ll-reveal-nearby-units-and-buildings-1.png | Bin 0 -> 42075 bytes ...nent-HQ-when-it-is-no-longer-visible-1.png | Bin 0 -> 29646 bytes ...unit-are-not-destroyed-on-the-client-1.png | Bin 0 -> 34833 bytes ...verTest-game-over-conditions-with-HQ-1.png | Bin 0 -> 12102 bytes ...verTest-game-over-conditions-with-HQ-2.png | Bin 0 -> 11455 bytes ...verTest-game-over-conditions-with-HQ-3.png | Bin 0 -> 10332 bytes ...verTest-game-over-conditions-with-HQ-4.png | Bin 0 -> 10332 bytes ...verTest-game-over-conditions-with-HQ-5.png | Bin 0 -> 10332 bytes ...Test-game-over-conditions-without-HQ-1.png | Bin 0 -> 10911 bytes ...Test-game-over-conditions-without-HQ-2.png | Bin 0 -> 9400 bytes ...Test-game-over-conditions-without-HQ-3.png | Bin 0 -> 12709 bytes ...Test-game-over-conditions-without-HQ-4.png | Bin 0 -> 11110 bytes ...ate-building-and-create-unit-actions-1.png | Bin 0 -> 16039 bytes ...ate-building-and-create-unit-actions-2.png | Bin 0 -> 32557 bytes ...ate-building-and-create-unit-actions-3.png | Bin 0 -> 32548 bytes ...ate-building-and-create-unit-actions-4.png | Bin 0 -> 32436 bytes ...ate-building-and-create-unit-actions-5.png | Bin 0 -> 32436 bytes ...enActionTest-destroy-hidden-building-1.png | Bin 0 -> 32803 bytes ...enActionTest-destroy-hidden-building-2.png | Bin 0 -> 32905 bytes ...n-turn-lightning-barriers-on-and-off-1.png | Bin 0 -> 35585 bytes ...n-turn-lightning-barriers-on-and-off-2.png | Bin 0 -> 35284 bytes ...n-turn-lightning-barriers-on-and-off-3.png | Bin 0 -> 35218 bytes ...n-turn-lightning-barriers-on-and-off-4.png | Bin 0 -> 35218 bytes ...n-turn-lightning-barriers-on-and-off-5.png | Bin 0 -> 35218 bytes ...st-spawns-units-and-adds-new-players-1.png | Bin 0 -> 35370 bytes ...st-spawns-units-and-adds-new-players-2.png | Bin 0 -> 35370 bytes ...unit-can-unfold-and-can-change-style-1.png | Bin 0 -> 16891 bytes ...unit-can-unfold-and-can-change-style-2.png | Bin 0 -> 21833 bytes ...unit-can-unfold-and-can-change-style-3.png | Bin 0 -> 21858 bytes ...unit-can-unfold-and-can-change-style-4.png | Bin 0 -> 16737 bytes ...y-palette-swaps-water-on-naval-units-1.png | Bin 0 -> 6672 bytes ...ts-and-all-possible-states-correctly-1.png | Bin 0 -> 96525 bytes .../UnitTest-displays-labels-correctly-1.png | Bin 0 -> 4714 bytes .../UnitTest-escort-radius-with-label-1.png | Bin 0 -> 9257 bytes ...renders-Dragons-differently-on-water-1.png | Bin 0 -> 3783 bytes tests/display.html | 28 + tests/display.tsx | 146 + tests/executeGameActions.tsx | 90 + tests/package.json | 40 + tests/playwrightServer.tsx | 15 + tests/printGameState.tsx | 19 + tests/screenshot.tsx | 112 + tests/setup.tsx | 29 + tests/snapshotEncodedActionResponse.tsx | 20 + tests/snapshotGameState.tsx | 9 + tests/vite.config.ts | 26 + tests/viteServer.tsx | 19 + tsconfig.json | 53 + ui/ActiveLink.tsx | 34 + ui/App.tsx | 127 + ui/Audio.tsx | 207 + ui/AudioPlayer.tsx | 226 + ui/Box.tsx | 48 + ui/Breakpoints.tsx | 18 + ui/Browser.tsx | 33 + ui/Button.tsx | 161 + ui/CSS.tsx | 320 + ui/ClearableInput.tsx | 71 + ui/Container.tsx | 28 + ui/Dialog.tsx | 313 + ui/Empty.aac | Bin 0 -> 587 bytes ui/Empty.ogg | Bin 0 -> 4115 bytes ui/ErrorText.tsx | 6 + ui/ExpandableMenuButton.tsx | 120 + ui/Form.tsx | 27 + ui/Icon.tsx | 45 + ui/InlineLink.tsx | 225 + ui/Link.tsx | 22 + ui/Menu.tsx | 400 + ui/MenuButton.tsx | 60 + ui/Navigate.tsx | 9 + ui/PageTransition.tsx | 41 + ui/Portal.tsx | 12 + ui/PrimaryExpandableMenuButton.tsx | 57 + ui/RainbowPulseStyle.tsx | 60 + ui/Reload.tsx | 26 + ui/ScrollContainer.tsx | 31 + ui/Select.tsx | 63 + ui/Slider.tsx | 80 + ui/Spinner.tsx | 61 + ui/Stack.tsx | 172 + ui/Storage.tsx | 24 + ui/Tag.tsx | 82 + ui/TagInput.tsx | 96 + ui/TagList.tsx | 46 + ui/Typeahead.tsx | 549 + ui/assets/Background.png | Bin 0 -> 131 bytes ui/clipBorder.tsx | 17 + ui/controls/Input.tsx | 92 + ui/controls/dynamicThrottle.tsx | 33 + ui/controls/isControlElement.tsx | 14 + ui/controls/setupGamePad.tsx | 255 + ui/controls/setupKeyboard.tsx | 178 + ui/controls/throttle.tsx | 18 + ui/controls/useAcceptNavigation.tsx | 33 + ui/controls/useActive.tsx | 24 + ui/controls/useBack.tsx | 14 + ui/controls/useBlockInput.tsx | 13 + ui/controls/useDirectionalNavigation.tsx | 55 + ui/controls/useHorizontalMenuNavigation.tsx | 63 + ui/controls/useHorizontalNavigation.tsx | 23 + ui/controls/useInput.tsx | 10 + ui/controls/useMenuNavigation.tsx | 63 + ui/cssVar.tsx | 205 + ui/ellipsis.tsx | 7 + ui/getColor.tsx | 127 + ui/gradient.tsx | 17 + ui/hooks/useAlert.tsx | 207 + ui/hooks/useBackgroundAnimation.tsx | 53 + ui/hooks/useFullScreen.tsx | 12 + ui/hooks/useLocation.tsx | 1 + ui/hooks/useMedia.tsx | 16 + ui/hooks/useNavigate.tsx | 11 + ui/hooks/usePress.tsx | 56 + ui/hooks/usePrompt.tsx | 30 + ui/hooks/useScale.tsx | 74 + ui/hooks/useScrollIntoView.tsx | 16 + ui/hooks/useScrollRestore.tsx | 35 + ui/hooks/useVisibilityState.tsx | 29 + ui/icons/Ammo.tsx | 5 + ui/icons/CreateMap.tsx | 5 + ui/icons/Crosshair.tsx | 5 + ui/icons/Drag.tsx | 5 + ui/icons/DropUnit.tsx | 5 + ui/icons/ExitFullscreen.tsx | 5 + ui/icons/Finished.tsx | 5 + ui/icons/Fold.tsx | 5 + ui/icons/Fullscreen.tsx | 5 + ui/icons/GamePad.tsx | 5 + ui/icons/Heart.tsx | 5 + ui/icons/Info.tsx | 5 + ui/icons/Label.tsx | 5 + ui/icons/Live.tsx | 5 + ui/icons/Magic.tsx | 5 + ui/icons/NoSkill.tsx | 5 + ui/icons/Paw.tsx | 5 + ui/icons/Rescue.tsx | 5 + ui/icons/Sabotage.tsx | 5 + ui/icons/Size.tsx | 5 + ui/icons/SkillBorder.tsx | 45 + ui/icons/Skills.tsx | 5 + ui/icons/Skull.tsx | 6 + ui/icons/StopCapture.tsx | 5 + ui/icons/Supply.tsx | 5 + ui/icons/Track.tsx | 5 + ui/icons/Tree.tsx | 5 + ui/icons/Volume1.tsx | 5 + ui/icons/Volume2.tsx | 5 + ui/icons/VolumeX.tsx | 5 + ui/icons/ZapOn.tsx | 5 + ui/icons/Zombie.tsx | 5 + ui/lib/getGameRoute.tsx | 11 + ui/lib/getTagColor.tsx | 9 + ui/lib/lazy.tsx | 64 + ui/lib/scrollToCenter.tsx | 18 + ui/package.json | 32 + ui/pixelBorder.tsx | 11 + ui/types/athena-crisis-audio.d.ts | 6 + vitest.config.ts | 14 + 792 files changed, 105164 insertions(+) create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 @types/vitest.d.ts create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 apollo/Action.tsx create mode 100644 apollo/ActionMap.json create mode 100644 apollo/ActionResponse.tsx create mode 100644 apollo/CharacterMessage.tsx create mode 100644 apollo/Condition.tsx create mode 100644 apollo/ConditionMap.json create mode 100644 apollo/Effects.tsx create mode 100644 apollo/GameOver.tsx create mode 100644 apollo/HiddenAction.tsx create mode 100644 apollo/MapMetadata.tsx create mode 100644 apollo/Types.tsx create mode 100644 apollo/__tests__/Action.test.tsx create mode 100644 apollo/__tests__/AttackBuilding.test.tsx create mode 100644 apollo/__tests__/Skill.test.tsx create mode 100644 apollo/__tests__/Supply.test.tsx create mode 100644 apollo/action-mutators/ActionMutators.tsx create mode 100644 apollo/actions/applyActionResponse.tsx create mode 100644 apollo/actions/applyEndTurnActionResponse.tsx create mode 100644 apollo/actions/encodeGameActionResponse.tsx create mode 100644 apollo/actions/executeGameAction.tsx create mode 100644 apollo/actions/validateAction.tsx create mode 100644 apollo/attack-direction/getAttackDirection.tsx create mode 100644 apollo/lib/GameTimerValue.tsx create mode 100644 apollo/lib/__tests__/nameGenerator.test.tsx create mode 100644 apollo/lib/applyConditions.tsx create mode 100644 apollo/lib/checkWinCondition.tsx create mode 100644 apollo/lib/computeVisibleActions.tsx create mode 100644 apollo/lib/decodeGameActionResponse.tsx create mode 100644 apollo/lib/dropLabelsFromActionResponse.tsx create mode 100644 apollo/lib/dropLabelsFromGameState.tsx create mode 100644 apollo/lib/gameHasEnded.tsx create mode 100644 apollo/lib/getActionResponseVectors.tsx create mode 100644 apollo/lib/getColorName.tsx create mode 100644 apollo/lib/getMessageKey.tsx create mode 100644 apollo/lib/getVisibleEntities.tsx create mode 100644 apollo/lib/getWinningTeam.tsx create mode 100644 apollo/lib/hasTimer.tsx create mode 100644 apollo/lib/mapWithAIPlayers.tsx create mode 100644 apollo/lib/maybeDecodeActionResponse.tsx create mode 100644 apollo/lib/nameGenerator.tsx create mode 100644 apollo/lib/processRewards.tsx create mode 100644 apollo/lib/timeoutActionResponseMutator.tsx create mode 100644 apollo/lib/toCampaignSlug.tsx create mode 100644 apollo/lib/toMapSlug.tsx create mode 100644 apollo/lib/transformEffectValue.tsx create mode 100644 apollo/lib/updateVisibleEntities.tsx create mode 100644 apollo/package.json create mode 100644 apollo/push/Types.tsx create mode 100644 apollo/routes/getCampaignRoute.tsx create mode 100644 apollo/routes/getMapRoute.tsx create mode 100644 apollo/routes/getUserRoute.tsx create mode 100644 apollo/socket/Room.tsx create mode 100644 apollo/socket/Types.tsx create mode 100644 ares/package.json create mode 100644 art/BiomeVariants.tsx create mode 100644 art/Sprites.tsx create mode 100644 art/VariantConfiguration.tsx create mode 100644 art/Variants.tsx create mode 100644 art/package.json create mode 100644 art/types/athena-crisis-asset-variants.d.ts create mode 100644 artemis/package.json create mode 100644 athena/MapData.tsx create mode 100644 athena/Radius.tsx create mode 100644 athena/Vision.tsx create mode 100644 athena/WinConditions.tsx create mode 100644 athena/__tests__/MapData.test.tsx create mode 100644 athena/__tests__/Player.test.tsx create mode 100644 athena/__tests__/Radius.test.tsx create mode 100644 athena/generator/MapGenerator.tsx create mode 100644 athena/info/AttackSprite.tsx create mode 100644 athena/info/Building.tsx create mode 100644 athena/info/BuildingIDs.tsx create mode 100644 athena/info/Decorator.tsx create mode 100644 athena/info/FactionNames.tsx create mode 100644 athena/info/MovementType.tsx create mode 100644 athena/info/Music.tsx create mode 100644 athena/info/Skill.tsx create mode 100644 athena/info/SpriteVariants.tsx create mode 100644 athena/info/Tile.tsx create mode 100644 athena/info/Unit.tsx create mode 100644 athena/info/UnitID.tsx create mode 100644 athena/info/UnitNames.tsx create mode 100644 athena/info/__tests__/UnitNames.test.tsx create mode 100644 athena/lib/Modifier.tsx create mode 100644 athena/lib/__tests__/assignUnitNames.test.tsx create mode 100644 athena/lib/__tests__/calculateClusters.test.tsx create mode 100644 athena/lib/__tests__/canDeploy.test.tsx create mode 100644 athena/lib/__tests__/determineUnitsToCreate.test.tsx create mode 100644 athena/lib/__tests__/startGame.test.tsx create mode 100644 athena/lib/__tests__/validateTeams.test.tsx create mode 100644 athena/lib/assignUnitNames.tsx create mode 100644 athena/lib/calculateClusters.tsx create mode 100644 athena/lib/calculateDamage.tsx create mode 100644 athena/lib/calculateEmptyClusters.tsx create mode 100644 athena/lib/calculateFunds.tsx create mode 100644 athena/lib/calculateLikelyDamage.tsx create mode 100644 athena/lib/canBuild.tsx create mode 100644 athena/lib/canDeploy.tsx create mode 100644 athena/lib/canLoad.tsx create mode 100644 athena/lib/canPlaceDecorator.tsx create mode 100644 athena/lib/canPlaceLightning.tsx create mode 100644 athena/lib/canPlaceRailTrack.tsx create mode 100644 athena/lib/canPlaceTile.tsx create mode 100644 athena/lib/convertBiome.tsx create mode 100644 athena/lib/determineUnitsToCreate.tsx create mode 100644 athena/lib/dropInactivePlayers.tsx create mode 100644 athena/lib/dropLabels.tsx create mode 100644 athena/lib/encodedMapDataHasHiddenWinCondition.tsx create mode 100644 athena/lib/filterNullables.tsx create mode 100644 athena/lib/followMovementPath.tsx create mode 100644 athena/lib/formatText.tsx create mode 100644 athena/lib/getActivePlayers.tsx create mode 100644 athena/lib/getAllUnitsToRefill.tsx create mode 100644 athena/lib/getAttackStatusEffect.tsx create mode 100644 athena/lib/getAttackableEntitiesInRange.tsx create mode 100644 athena/lib/getAttributeRange.tsx create mode 100644 athena/lib/getAvailableUnitActions.tsx create mode 100644 athena/lib/getAverageVector.tsx create mode 100644 athena/lib/getBiomeStyle.tsx create mode 100644 athena/lib/getBuildableUnits.tsx create mode 100644 athena/lib/getChargeValue.tsx create mode 100644 athena/lib/getDecoratorIndex.tsx create mode 100644 athena/lib/getDecoratorsAtField.tsx create mode 100644 athena/lib/getDefenseStatusEffect.tsx create mode 100644 athena/lib/getDeployableVectors.tsx create mode 100644 athena/lib/getFirstHumanPlayer.tsx create mode 100644 athena/lib/getFloatingEdgeModifier.tsx create mode 100644 athena/lib/getHealCost.tsx create mode 100644 athena/lib/getHealableVectors.tsx create mode 100644 athena/lib/getLeaders.tsx create mode 100644 athena/lib/getMapSize.tsx create mode 100644 athena/lib/getModifier.tsx create mode 100644 athena/lib/getMovementPath.tsx create mode 100644 athena/lib/getParentToMoveTo.tsx create mode 100644 athena/lib/getPathFields.tsx create mode 100644 athena/lib/getRescuableVectors.tsx create mode 100644 athena/lib/getSabotageableVectors.tsx create mode 100644 athena/lib/getUnitValue.tsx create mode 100644 athena/lib/getUnitsByPositions.tsx create mode 100644 athena/lib/getUnitsToHealOnBuildings.tsx create mode 100644 athena/lib/getUnitsToRefill.tsx create mode 100644 athena/lib/getVectorRadius.tsx create mode 100644 athena/lib/hasLeader.tsx create mode 100644 athena/lib/hasLowAmmoSupply.tsx create mode 100644 athena/lib/indexToSpriteVector.tsx create mode 100644 athena/lib/indexToVector.tsx create mode 100644 athena/lib/isAmphibiousOnLand.tsx create mode 100644 athena/lib/isFuelConsumingUnit.tsx create mode 100644 athena/lib/isPvP.tsx create mode 100644 athena/lib/matchesActiveType.tsx create mode 100644 athena/lib/matchesPlayerList.tsx create mode 100644 athena/lib/maybeConvertPlayer.tsx create mode 100644 athena/lib/mergeTeams.tsx create mode 100644 athena/lib/refillUnits.tsx create mode 100644 athena/lib/removeLeader.tsx create mode 100644 athena/lib/resizeMap.tsx create mode 100644 athena/lib/shouldRemoveUnit.tsx create mode 100644 athena/lib/singleTilesToModifiers.tsx create mode 100644 athena/lib/startGame.tsx create mode 100644 athena/lib/updateActivePlayers.tsx create mode 100644 athena/lib/updatePlayer.tsx create mode 100644 athena/lib/updatePlayers.tsx create mode 100644 athena/lib/validateMap.tsx create mode 100644 athena/lib/validateSkills.tsx create mode 100644 athena/lib/validateTeams.tsx create mode 100644 athena/lib/verifyTiles.tsx create mode 100644 athena/lib/withModifiers.tsx create mode 100644 athena/map/AIBehavior.tsx create mode 100644 athena/map/Biome.tsx create mode 100644 athena/map/Building.tsx create mode 100644 athena/map/Configuration.tsx create mode 100644 athena/map/Entity.tsx create mode 100644 athena/map/PlainMap.tsx create mode 100644 athena/map/Player.tsx create mode 100644 athena/map/Reward.tsx create mode 100644 athena/map/Serialization.tsx create mode 100644 athena/map/SpriteVector.tsx create mode 100644 athena/map/Statistics.tsx create mode 100644 athena/map/Team.tsx create mode 100644 athena/map/Unit.tsx create mode 100644 athena/map/Vector.tsx create mode 100644 athena/map/isPlayable.tsx create mode 100644 athena/map/vec.tsx create mode 100644 athena/mutation/toggleLightningTile.tsx create mode 100644 athena/mutation/writeTile.tsx create mode 100644 athena/package.json create mode 100755 codegen/generate-actions.tsx create mode 100755 codegen/generate-all.tsx create mode 100755 codegen/generate-campaign-names.tsx create mode 100755 codegen/generate-graphql.tsx create mode 100755 codegen/generate-routes.tsx create mode 100755 codegen/generate-translations.tsx create mode 100644 codegen/lib/sign.tsx create mode 100644 codegen/lib/traverse.tsx create mode 100644 codegen/package.json create mode 100644 deimos/package.json create mode 100644 dionysus/BaseAI.tsx create mode 100644 dionysus/DionysusAlpha.tsx create mode 100644 dionysus/lib/estimateClosestTarget.tsx create mode 100644 dionysus/lib/findPathToTarget.tsx create mode 100644 dionysus/lib/getAttackableArea.tsx create mode 100644 dionysus/lib/getAttackableUnitsWithinRadius.tsx create mode 100644 dionysus/lib/getBuildingWeight.tsx create mode 100644 dionysus/lib/getInterestingVectors.tsx create mode 100644 dionysus/lib/getInterestingVectorsByAbilities.tsx create mode 100644 dionysus/lib/getPossibleAttacks.tsx create mode 100644 dionysus/lib/getPossibleUnitAbilities.tsx create mode 100644 dionysus/lib/getUnitInfosWithMaxVision.tsx create mode 100644 dionysus/lib/getWinConditionVectors.tsx create mode 100644 dionysus/lib/needsSupply.tsx create mode 100644 dionysus/lib/shouldAttack.tsx create mode 100644 dionysus/lib/shouldCaptureBuilding.tsx create mode 100644 dionysus/lib/sortByDamage.tsx create mode 100644 dionysus/lib/sortPossibleAttacks.tsx create mode 100644 dionysus/package.json create mode 100644 docs/content/examples/map-data-examples.tsx create mode 100644 docs/content/examples/map-editor.tsx create mode 100644 docs/content/pages/core-concepts/actions.mdx create mode 100644 docs/content/pages/core-concepts/immutable-data-structures.mdx create mode 100644 docs/content/pages/core-concepts/map-data.mdx create mode 100644 docs/content/pages/core-concepts/overview.mdx create mode 100644 docs/content/pages/getting-started.mdx create mode 100644 docs/content/pages/index.mdx create mode 100644 docs/content/pages/playground/map-editor.mdx create mode 100644 docs/content/playground/ClientComponent.tsx create mode 100644 docs/content/playground/ClientScope.tsx create mode 100644 docs/content/playground/PlaygroundDemoGame.tsx create mode 100644 docs/content/playground/PlaygroundGame.tsx create mode 100644 docs/content/public/apple-touch-icon.png create mode 100644 docs/content/public/athena-crisis.svg create mode 100644 docs/content/public/favicon.ico create mode 100644 docs/content/public/favicon.png create mode 100644 docs/content/public/fonts/AthenaNova.woff2 create mode 100644 docs/content/styles.css create mode 100644 docs/package.json create mode 100644 docs/vocs.config.tsx create mode 100644 eslint-plugin/index.js create mode 100644 eslint-plugin/no-copy-expression.js create mode 100644 eslint-plugin/no-date-now.js create mode 100644 eslint-plugin/no-fbt-import.js create mode 100644 eslint-plugin/no-inline-css.js create mode 100644 eslint-plugin/no-lazy-import.js create mode 100644 eslint-plugin/package.json create mode 100644 eslint-plugin/require-fbt-description.js create mode 100644 eslint-plugin/use-mutation-types.js create mode 100644 fixtures/package.json create mode 100755 git-hooks/pre-commit create mode 100644 hephaestus/UnknownTypeError.tsx create mode 100644 hephaestus/dateNow.tsx create mode 100644 hephaestus/getFirst.tsx create mode 100644 hephaestus/getFirstOrThrow.tsx create mode 100644 hephaestus/getOrThrow.tsx create mode 100644 hephaestus/groupBy.tsx create mode 100644 hephaestus/isPositiveInteger.tsx create mode 100644 hephaestus/isPresent.tsx create mode 100644 hephaestus/jenkinsHash.tsx create mode 100644 hephaestus/maxBy.tsx create mode 100644 hephaestus/minBy.tsx create mode 100644 hephaestus/package.json create mode 100644 hephaestus/parseInteger.tsx create mode 100644 hephaestus/random.tsx create mode 100644 hephaestus/randomEntry.tsx create mode 100644 hephaestus/sanitizeText.tsx create mode 100644 hephaestus/sortBy.tsx create mode 100644 hephaestus/toSlug.tsx create mode 100644 hephaestus/toTag.tsx create mode 100644 hera/Building.tsx create mode 100644 hera/Cursor.tsx create mode 100644 hera/Decorators.tsx create mode 100644 hera/Fog.tsx create mode 100644 hera/GameMap.tsx create mode 100644 hera/Label.tsx create mode 100644 hera/Map.tsx create mode 100644 hera/MapAnimations.tsx create mode 100644 hera/Mask.tsx create mode 100644 hera/MaskWithSubtiles.tsx create mode 100644 hera/Radius.tsx create mode 100644 hera/Tick.tsx create mode 100644 hera/TileDecorator.tsx create mode 100644 hera/TileDecorators.tsx create mode 100644 hera/Tiles.tsx create mode 100644 hera/Types.tsx create mode 100644 hera/Unit.tsx create mode 100644 hera/action-response/ActionResponseError.tsx create mode 100644 hera/action-response/processActionResponse.tsx create mode 100644 hera/animations/Animation.tsx create mode 100644 hera/animations/AttackAnimation.tsx create mode 100644 hera/animations/BuildingCreate.tsx create mode 100644 hera/animations/Explosion.tsx create mode 100644 hera/animations/Fireworks.tsx create mode 100644 hera/animations/Heal.tsx create mode 100644 hera/animations/HealthAnimation.tsx create mode 100644 hera/animations/Rescue.tsx create mode 100644 hera/animations/Sabotage.tsx create mode 100644 hera/animations/Shake.tsx create mode 100644 hera/animations/Spawn.tsx create mode 100644 hera/animations/UpgradeAnimation.tsx create mode 100644 hera/animations/addExplosionAnimation.tsx create mode 100644 hera/animations/attackActionAnimation.tsx create mode 100644 hera/animations/attackFlashAnimation.tsx create mode 100644 hera/animations/explosionAnimation.tsx create mode 100644 hera/animations/generateFrames.tsx create mode 100644 hera/animations/secretDiscoveredAnimation.tsx create mode 100644 hera/audio/LoggedOutVolumeControl.tsx create mode 100644 hera/audio/Music.tsx create mode 100644 hera/audio/VolumeControl.tsx create mode 100644 hera/behavior/AbstractSelectBehavior.tsx create mode 100644 hera/behavior/Attack.tsx create mode 100644 hera/behavior/AttackRadius.tsx create mode 100644 hera/behavior/Base.tsx create mode 100644 hera/behavior/Behavior.tsx create mode 100644 hera/behavior/BuySkills.tsx create mode 100644 hera/behavior/CreateBuilding.tsx create mode 100644 hera/behavior/CreateUnit.tsx create mode 100644 hera/behavior/DropUnit.tsx create mode 100644 hera/behavior/Heal.tsx create mode 100644 hera/behavior/Menu.tsx create mode 100644 hera/behavior/Move.tsx create mode 100644 hera/behavior/NullBehavior.tsx create mode 100644 hera/behavior/Radar.tsx create mode 100644 hera/behavior/Rescue.tsx create mode 100644 hera/behavior/Sabotage.tsx create mode 100644 hera/behavior/Transport.tsx create mode 100644 hera/behavior/activatePower/activatePowerAction.tsx create mode 100644 hera/behavior/attack/AttackSelector.tsx create mode 100644 hera/behavior/attack/attackAction.tsx create mode 100644 hera/behavior/attack/clientAttackAction.tsx create mode 100644 hera/behavior/attack/getAttackableEntities.tsx create mode 100644 hera/behavior/attack/getDamageColor.tsx create mode 100644 hera/behavior/attack/getHealthColor.tsx create mode 100644 hera/behavior/attack/hiddenAttackActions.tsx create mode 100644 hera/behavior/buySkill/buySkillAction.tsx create mode 100644 hera/behavior/capture/captureAction.tsx create mode 100644 hera/behavior/confirm/ConfirmAction.tsx create mode 100644 hera/behavior/createBuilding/createBuildingAction.tsx create mode 100644 hera/behavior/createTracks/createTracksAction.tsx create mode 100644 hera/behavior/createUnit/createUnitAction.tsx create mode 100644 hera/behavior/drop/dropUnitAction.tsx create mode 100644 hera/behavior/endTurn/canEndTurn.tsx create mode 100644 hera/behavior/endTurn/endTurnAction.tsx create mode 100644 hera/behavior/heal/healAction.tsx create mode 100644 hera/behavior/move/clientMoveAction.tsx create mode 100644 hera/behavior/move/hiddenMoveAction.tsx create mode 100644 hera/behavior/move/moveAction.tsx create mode 100644 hera/behavior/move/syncMoveAction.tsx create mode 100644 hera/behavior/radar/toggleLightningAction.tsx create mode 100644 hera/behavior/rescue/rescueAction.tsx create mode 100644 hera/behavior/sabotage/sabotageAction.tsx create mode 100644 hera/behavior/unfold/unfoldAction.tsx create mode 100644 hera/bottom-drawer/BottomDrawer.tsx create mode 100644 hera/campaign/CampaignEditor.tsx create mode 100644 hera/campaign/EffectDialogue.tsx create mode 100644 hera/campaign/Level.tsx create mode 100644 hera/campaign/LevelDialogue.tsx create mode 100644 hera/campaign/Types.tsx create mode 100644 hera/campaign/hooks/useEffectCharacters.tsx create mode 100644 hera/campaign/lib/PlayStyle.tsx create mode 100644 hera/campaign/lib/getAllEffectCharacters.tsx create mode 100644 hera/campaign/lib/sortByDepth.tsx create mode 100644 hera/campaign/panels/CampaignEditorControlPanel.tsx create mode 100644 hera/campaign/panels/CampaignEditorSettingsPanel.tsx create mode 100644 hera/card/AttributeGrid.tsx create mode 100644 hera/card/BuildingCard.tsx create mode 100644 hera/card/CardTitle.tsx create mode 100644 hera/card/InlineTileList.tsx create mode 100644 hera/card/LeaderCard.tsx create mode 100644 hera/card/LeaderTitle.tsx create mode 100644 hera/card/MovementBox.tsx create mode 100644 hera/card/Range.tsx create mode 100644 hera/card/TileBox.tsx create mode 100644 hera/card/TileCard.tsx create mode 100644 hera/card/TilePreview.tsx create mode 100644 hera/card/UnitCard.tsx create mode 100644 hera/card/lib/CoverRange.tsx create mode 100644 hera/card/lib/tileFieldHasDecorator.tsx create mode 100644 hera/character/MiniPortrait.tsx create mode 100644 hera/character/Portrait.tsx create mode 100644 hera/character/PortraitPicker.tsx create mode 100644 hera/editor/MapEditor.tsx create mode 100644 hera/editor/ResizeHandle.tsx create mode 100644 hera/editor/Types.tsx create mode 100644 hera/editor/behavior/DesignBehavior.tsx create mode 100644 hera/editor/behavior/EntityBehavior.tsx create mode 100644 hera/editor/behavior/VectorBehavior.tsx create mode 100644 hera/editor/hooks/useColumns.tsx create mode 100644 hera/editor/hooks/useSetTags.tsx create mode 100644 hera/editor/hooks/useZoom.tsx create mode 100644 hera/editor/lib/AIBehaviorLink.tsx create mode 100644 hera/editor/lib/ActionCard.tsx create mode 100644 hera/editor/lib/BiomeIcon.tsx create mode 100644 hera/editor/lib/DeleteTile.tsx create mode 100644 hera/editor/lib/EffectTitle.tsx create mode 100644 hera/editor/lib/WinConditionCard.tsx create mode 100644 hera/editor/lib/ZoomButton.tsx create mode 100644 hera/editor/lib/canFillTile.tsx create mode 100644 hera/editor/lib/changePlayer.tsx create mode 100644 hera/editor/lib/getMapValidationErrorText.tsx create mode 100644 hera/editor/lib/hasGameEndCondition.tsx create mode 100644 hera/editor/lib/navigate.tsx create mode 100644 hera/editor/lib/selectWinConditionEffect.tsx create mode 100644 hera/editor/lib/tileFieldHasAnimation.tsx create mode 100644 hera/editor/lib/updateUndoStack.tsx create mode 100644 hera/editor/lib/useGridNavigation.tsx create mode 100644 hera/editor/panels/DecoratorPanel.tsx create mode 100644 hera/editor/panels/DesignPanel.tsx create mode 100644 hera/editor/panels/EffectsPanel.tsx create mode 100644 hera/editor/panels/EntityPanel.tsx create mode 100644 hera/editor/panels/EvaluationPanel.tsx create mode 100644 hera/editor/panels/MapEditorControlPanel.tsx create mode 100644 hera/editor/panels/MapEditorSettingsPanel.tsx create mode 100644 hera/editor/panels/RestrictionsPanel.tsx create mode 100644 hera/editor/panels/SetupPanel.tsx create mode 100644 hera/editor/panels/WinConditionPanel.tsx create mode 100644 hera/editor/selectors/BiomeSelector.tsx create mode 100644 hera/editor/selectors/EditorPlayerSelector.tsx create mode 100644 hera/editor/selectors/EffectSelector.tsx create mode 100644 hera/editor/selectors/LabelSelector.tsx create mode 100644 hera/hooks/useAnimationSpeed.tsx create mode 100644 hera/hooks/useClientGame.tsx create mode 100644 hera/hooks/useClientGameAction.tsx create mode 100644 hera/hooks/useEffects.tsx create mode 100644 hera/hooks/useFactionNameDataSource.tsx create mode 100644 hera/hooks/useHide.tsx create mode 100644 hera/hooks/useMapData.tsx create mode 100644 hera/hooks/useSprites.tsx create mode 100644 hera/hooks/useTagDataSource.tsx create mode 100644 hera/hooks/useUserMap.tsx create mode 100644 hera/i18n/getCampaignMessage.tsx create mode 100644 hera/i18n/getLocale.tsx create mode 100644 hera/i18n/getMapName.tsx create mode 100644 hera/i18n/injectTranslation.tsx create mode 100644 hera/i18n/intlList.tsx create mode 100644 hera/lib/AnimationKey.tsx create mode 100644 hera/lib/AnimationSpeed.tsx create mode 100644 hera/lib/ConfirmActionStyle.tsx create mode 100644 hera/lib/Edges.tsx create mode 100644 hera/lib/FogStyle.tsx create mode 100644 hera/lib/MapSize.tsx create mode 100644 hera/lib/TiltStyle.tsx create mode 100644 hera/lib/addEndTurnAnimations.tsx create mode 100644 hera/lib/addFlashAnimation.tsx create mode 100644 hera/lib/addMoveAnimation.tsx create mode 100644 hera/lib/addPlayerLoseAnimation.tsx create mode 100644 hera/lib/animateHeal.tsx create mode 100644 hera/lib/animateSupply.tsx create mode 100644 hera/lib/attackSpriteHasVariants.tsx create mode 100644 hera/lib/botToUser.tsx create mode 100644 hera/lib/captureException.tsx create mode 100644 hera/lib/explodeUnits.tsx create mode 100644 hera/lib/getAnyBuildingTileField.tsx create mode 100644 hera/lib/getAnyUnitTile.tsx create mode 100644 hera/lib/getBuildingSpritePosition.tsx create mode 100644 hera/lib/getCoverName.tsx create mode 100644 hera/lib/getFlashDelay.tsx create mode 100644 hera/lib/getMapSizeName.tsx create mode 100644 hera/lib/getPlayerDefeatedMessage.tsx create mode 100644 hera/lib/getSkillConfigForDisplay.tsx create mode 100644 hera/lib/getTranslatedBiomeName.tsx create mode 100644 hera/lib/getTranslatedColorName.tsx create mode 100644 hera/lib/getTranslatedEntityName.tsx create mode 100644 hera/lib/getTranslatedFactionName.tsx create mode 100644 hera/lib/getTranslatedTileTypeName.tsx create mode 100644 hera/lib/getTranslatedTimerName.tsx create mode 100644 hera/lib/getUnitDirection.tsx create mode 100644 hera/lib/getWinCriteriaName.tsx create mode 100644 hera/lib/isFakeEndTurn.tsx create mode 100644 hera/lib/isInView.tsx create mode 100644 hera/lib/maskClassName.tsx create mode 100644 hera/lib/sleep.tsx create mode 100644 hera/lib/spawn.tsx create mode 100644 hera/lib/sprite.tsx create mode 100644 hera/lib/startGameAnimation.tsx create mode 100644 hera/lib/throwActionError.tsx create mode 100644 hera/lib/tick.tsx create mode 100644 hera/lib/toTransformOrigin.tsx create mode 100644 hera/lib/upgradeUnits.tsx create mode 100644 hera/package.json create mode 100644 hera/render/Images.tsx create mode 100644 hera/render/renderFloatingTile.tsx create mode 100644 hera/render/renderTile.tsx create mode 100644 hera/types/@emotion__babel-plugin.d.ts create mode 100644 hera/types/Fbt.tsx create mode 100644 hera/types/athena-crisis-images.d.ts create mode 100644 hera/types/babel-plugin-fbt-import.d.ts create mode 100644 hera/types/babel-plugin-fbt-runtime.d.ts create mode 100644 hera/types/babel-plugin-fbt.d.ts create mode 100644 hera/ui/ActionBar.tsx create mode 100644 hera/ui/ActionWheel.tsx create mode 100644 hera/ui/AdminActions.tsx create mode 100644 hera/ui/Banner.tsx create mode 100644 hera/ui/CharacterMessage.tsx create mode 100644 hera/ui/CurrentGameCard.tsx create mode 100644 hera/ui/EntityPickerFlyout.tsx create mode 100644 hera/ui/ErrorOverlay.tsx create mode 100644 hera/ui/FlashFlyout.tsx create mode 100644 hera/ui/Flyout.tsx create mode 100644 hera/ui/Funds.tsx create mode 100644 hera/ui/GameActions.tsx create mode 100644 hera/ui/GameDialog.tsx create mode 100644 hera/ui/MapDetails.tsx create mode 100644 hera/ui/MapInfo.tsx create mode 100644 hera/ui/Message.tsx create mode 100644 hera/ui/MiniPlayerIcon.tsx create mode 100644 hera/ui/ModeSelectButton.tsx create mode 100644 hera/ui/NewVersionNotification.tsx create mode 100644 hera/ui/Notice.tsx create mode 100644 hera/ui/Notification.tsx create mode 100644 hera/ui/PlayerCard.tsx create mode 100644 hera/ui/PlayerIcon.tsx create mode 100644 hera/ui/PlayerPosition.tsx create mode 100644 hera/ui/PlayerSelector.tsx create mode 100644 hera/ui/ReplayBar.tsx create mode 100644 hera/ui/SelectEntity.tsx create mode 100644 hera/ui/SkillDescription.tsx create mode 100644 hera/ui/SkillDialog.tsx create mode 100644 hera/ui/TeamSelector.tsx create mode 100644 hera/ui/UILabel.tsx create mode 100644 hera/ui/Vs.tsx create mode 100644 hera/ui/fps/Fps.tsx create mode 100644 hera/ui/fps/FpsComponent.tsx create mode 100644 hera/ui/lib/UnknownUser.tsx create mode 100644 hera/ui/lib/formatCharacterText.tsx create mode 100644 hera/ui/lib/getClientCoordinates.tsx create mode 100644 hera/ui/lib/maybeFade.tsx create mode 100644 hera/ui/lib/measureText.tsx create mode 100644 hera/ui/lib/useSkipAnimation.tsx create mode 100644 hera/win-conditions/WinConditionDescription.tsx create mode 100644 hera/win-conditions/WinConditionTitle.tsx create mode 100644 hermes/Configuration.tsx create mode 100644 hermes/Rating.tsx create mode 100644 hermes/Types.tsx create mode 100644 hermes/game/onGameEnd.tsx create mode 100644 hermes/game/toClientGame.tsx create mode 100644 hermes/getCampaignLevelDepths.tsx create mode 100644 hermes/map-fixtures/demo-1.tsx create mode 100644 hermes/map-fixtures/demo-2.tsx create mode 100644 hermes/map-fixtures/demo-3.tsx create mode 100644 hermes/map-fixtures/shrine.tsx create mode 100644 hermes/map-fixtures/they-are-close-to-home.tsx create mode 100644 hermes/package.json create mode 100644 hermes/toCampaign.tsx create mode 100644 hermes/toLevelMap.tsx create mode 100644 hermes/toPlainCampaign.tsx create mode 100644 hermes/toPlainLevelList.tsx create mode 100644 hermes/unrollCampaign.tsx create mode 100644 hermes/validateCampaign.tsx create mode 100644 i18n/AvailableLanguages.tsx create mode 100644 i18n/Common.cjs create mode 100644 i18n/package.json create mode 100644 infra/assets/crowdin.svg create mode 100644 infra/assets/hetzner.svg create mode 100644 infra/assets/null.svg create mode 100644 infra/assets/polar.svg create mode 100644 infra/babelFbtPlugins.tsx create mode 100644 infra/isOpenSource.tsx create mode 100644 infra/resolver.tsx create mode 100644 infra/root.ts create mode 100644 infra/startServer.tsx create mode 100644 offline/apple-touch-icon.png create mode 100644 offline/fonts/AthenaNova.woff2 create mode 100644 offline/fonts/MadouFutoMaru.woff2 create mode 100644 offline/fonts/PressStart2P.woff2 create mode 100644 offline/index.html create mode 100644 offline/keyart.jpg create mode 100644 offline/package.json create mode 100644 offline/vite.config.ts create mode 100644 package.json create mode 100644 patches/@remix-run__router@1.16.1.patch create mode 100644 patches/eslint-plugin-import@2.29.1.patch create mode 100644 patches/fbt@1.0.2.patch create mode 100644 patches/graphql-helix@1.13.0.patch create mode 100644 patches/howler@2.2.4.patch create mode 100644 patches/p-limit@5.0.0.patch create mode 100644 patches/resend@3.2.0.patch create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prettier.config.mjs create mode 100644 scripts/package.json create mode 100644 tests/__tests__/AIBehavior.test.tsx create mode 100644 tests/__tests__/AITransportMove.test.tsx create mode 100644 tests/__tests__/Building.test.tsx create mode 100644 tests/__tests__/CreateBuildingFog.test.tsx create mode 100644 tests/__tests__/CreateUnitFog.test.tsx create mode 100644 tests/__tests__/Decorators.test.tsx create mode 100644 tests/__tests__/Effects.test.tsx create mode 100644 tests/__tests__/EndTurn.test.tsx create mode 100644 tests/__tests__/EntityLabel.test.tsx create mode 100644 tests/__tests__/Fog.test.tsx create mode 100644 tests/__tests__/FogMove.test.tsx create mode 100644 tests/__tests__/FormatActions.test.tsx create mode 100644 tests/__tests__/GameOver.test.tsx create mode 100644 tests/__tests__/HaltingProblem.test.tsx create mode 100644 tests/__tests__/HiddenAction.test.tsx create mode 100644 tests/__tests__/Lightning.test.tsx create mode 100644 tests/__tests__/MapGenerator.test.tsx create mode 100644 tests/__tests__/Misses.test.tsx create mode 100644 tests/__tests__/Move.test.tsx create mode 100644 tests/__tests__/Power.test.tsx create mode 100644 tests/__tests__/Rescue.test.tsx create mode 100644 tests/__tests__/Reward.test.tsx create mode 100644 tests/__tests__/Spawn.test.tsx create mode 100644 tests/__tests__/Statistics.test.tsx create mode 100644 tests/__tests__/Transport.test.tsx create mode 100644 tests/__tests__/Unfold.test.tsx create mode 100644 tests/__tests__/Unit.test.tsx create mode 100644 tests/__tests__/WinConditions.test.tsx create mode 100644 tests/__tests__/__image_snapshots__/AITransportMoveTest-AI-will-hop-between-islands-if-necessary-1.png create mode 100644 tests/__tests__/__image_snapshots__/AITransportMoveTest-transporters-do-not-stick-to-opposing-air-units-on-sea-when-loaded-with-other-units-1.png create mode 100644 tests/__tests__/__image_snapshots__/AITransportMoveTest-transporters-do-not-stick-to-opposing-naval-units-when-loaded-with-other-units-1.png create mode 100644 tests/__tests__/__image_snapshots__/CreateBuildingFogTest-buildings-appear-properly-when-they-are-created-in-fog-1.png create mode 100644 tests/__tests__/__image_snapshots__/CreateUnitFogTest-units-on-a-factory-do-not-disappear-when-a-unit-is-created-and-moved-into-fog-1.png create mode 100644 tests/__tests__/__image_snapshots__/DecoratorsTest-correctly-renders-decorators-1.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-1.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-2.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-3.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-4.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-5.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-6.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-7.png create mode 100644 tests/__tests__/__image_snapshots__/EndTurnTest-supply-works-correctly-for-units-when-a-turn-ends-in-fog-8.png create mode 100644 tests/__tests__/__image_snapshots__/FogTest-a-unit-that-gets-blocked-and-issues-a-'HiddenMove'-action-is-marked-as-completed-1.png create mode 100644 tests/__tests__/__image_snapshots__/FogTest-a-unit-that-gets-blocked-and-issues-a-`HiddenMove`-action-is-marked-as-completed-1.png create mode 100644 tests/__tests__/__image_snapshots__/FogTest-capturing-an-opponent-HQ-will-reveal-nearby-units-and-buildings-1.png create mode 100644 tests/__tests__/__image_snapshots__/FogTest-neutralizes-the-opponent-HQ-when-it-is-no-longer-visible-1.png create mode 100644 tests/__tests__/__image_snapshots__/FogTest-units-that-will-be-supplied-by-a-hidden-adjacent-supply-unit-are-not-destroyed-on-the-client-1.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-with-HQ-1.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-with-HQ-2.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-with-HQ-3.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-with-HQ-4.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-with-HQ-5.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-without-HQ-1.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-without-HQ-2.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-without-HQ-3.png create mode 100644 tests/__tests__/__image_snapshots__/GameOverTest-game-over-conditions-without-HQ-4.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-create-building-and-create-unit-actions-1.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-create-building-and-create-unit-actions-2.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-create-building-and-create-unit-actions-3.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-create-building-and-create-unit-actions-4.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-create-building-and-create-unit-actions-5.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-destroy-hidden-building-1.png create mode 100644 tests/__tests__/__image_snapshots__/HiddenActionTest-destroy-hidden-building-2.png create mode 100644 tests/__tests__/__image_snapshots__/LightningTest-can-turn-lightning-barriers-on-and-off-1.png create mode 100644 tests/__tests__/__image_snapshots__/LightningTest-can-turn-lightning-barriers-on-and-off-2.png create mode 100644 tests/__tests__/__image_snapshots__/LightningTest-can-turn-lightning-barriers-on-and-off-3.png create mode 100644 tests/__tests__/__image_snapshots__/LightningTest-can-turn-lightning-barriers-on-and-off-4.png create mode 100644 tests/__tests__/__image_snapshots__/LightningTest-can-turn-lightning-barriers-on-and-off-5.png create mode 100644 tests/__tests__/__image_snapshots__/SpawnTest-spawns-units-and-adds-new-players-1.png create mode 100644 tests/__tests__/__image_snapshots__/SpawnTest-spawns-units-and-adds-new-players-2.png create mode 100644 tests/__tests__/__image_snapshots__/UnfoldTest-unit-can-unfold-and-can-change-style-1.png create mode 100644 tests/__tests__/__image_snapshots__/UnfoldTest-unit-can-unfold-and-can-change-style-2.png create mode 100644 tests/__tests__/__image_snapshots__/UnfoldTest-unit-can-unfold-and-can-change-style-3.png create mode 100644 tests/__tests__/__image_snapshots__/UnfoldTest-unit-can-unfold-and-can-change-style-4.png create mode 100644 tests/__tests__/__image_snapshots__/UnitTest-correctly-palette-swaps-water-on-naval-units-1.png create mode 100644 tests/__tests__/__image_snapshots__/UnitTest-displays-all-units-and-all-possible-states-correctly-1.png create mode 100644 tests/__tests__/__image_snapshots__/UnitTest-displays-labels-correctly-1.png create mode 100644 tests/__tests__/__image_snapshots__/UnitTest-escort-radius-with-label-1.png create mode 100644 tests/__tests__/__image_snapshots__/UnitTest-renders-Dragons-differently-on-water-1.png create mode 100644 tests/display.html create mode 100644 tests/display.tsx create mode 100644 tests/executeGameActions.tsx create mode 100644 tests/package.json create mode 100644 tests/playwrightServer.tsx create mode 100644 tests/printGameState.tsx create mode 100644 tests/screenshot.tsx create mode 100644 tests/setup.tsx create mode 100644 tests/snapshotEncodedActionResponse.tsx create mode 100644 tests/snapshotGameState.tsx create mode 100644 tests/vite.config.ts create mode 100644 tests/viteServer.tsx create mode 100644 tsconfig.json create mode 100644 ui/ActiveLink.tsx create mode 100644 ui/App.tsx create mode 100644 ui/Audio.tsx create mode 100644 ui/AudioPlayer.tsx create mode 100644 ui/Box.tsx create mode 100644 ui/Breakpoints.tsx create mode 100644 ui/Browser.tsx create mode 100644 ui/Button.tsx create mode 100644 ui/CSS.tsx create mode 100644 ui/ClearableInput.tsx create mode 100644 ui/Container.tsx create mode 100644 ui/Dialog.tsx create mode 100644 ui/Empty.aac create mode 100644 ui/Empty.ogg create mode 100644 ui/ErrorText.tsx create mode 100644 ui/ExpandableMenuButton.tsx create mode 100644 ui/Form.tsx create mode 100644 ui/Icon.tsx create mode 100644 ui/InlineLink.tsx create mode 100644 ui/Link.tsx create mode 100644 ui/Menu.tsx create mode 100644 ui/MenuButton.tsx create mode 100644 ui/Navigate.tsx create mode 100644 ui/PageTransition.tsx create mode 100644 ui/Portal.tsx create mode 100644 ui/PrimaryExpandableMenuButton.tsx create mode 100644 ui/RainbowPulseStyle.tsx create mode 100644 ui/Reload.tsx create mode 100644 ui/ScrollContainer.tsx create mode 100644 ui/Select.tsx create mode 100644 ui/Slider.tsx create mode 100644 ui/Spinner.tsx create mode 100644 ui/Stack.tsx create mode 100644 ui/Storage.tsx create mode 100644 ui/Tag.tsx create mode 100644 ui/TagInput.tsx create mode 100644 ui/TagList.tsx create mode 100644 ui/Typeahead.tsx create mode 100644 ui/assets/Background.png create mode 100644 ui/clipBorder.tsx create mode 100644 ui/controls/Input.tsx create mode 100644 ui/controls/dynamicThrottle.tsx create mode 100644 ui/controls/isControlElement.tsx create mode 100644 ui/controls/setupGamePad.tsx create mode 100644 ui/controls/setupKeyboard.tsx create mode 100644 ui/controls/throttle.tsx create mode 100644 ui/controls/useAcceptNavigation.tsx create mode 100644 ui/controls/useActive.tsx create mode 100644 ui/controls/useBack.tsx create mode 100644 ui/controls/useBlockInput.tsx create mode 100644 ui/controls/useDirectionalNavigation.tsx create mode 100644 ui/controls/useHorizontalMenuNavigation.tsx create mode 100644 ui/controls/useHorizontalNavigation.tsx create mode 100644 ui/controls/useInput.tsx create mode 100644 ui/controls/useMenuNavigation.tsx create mode 100644 ui/cssVar.tsx create mode 100644 ui/ellipsis.tsx create mode 100644 ui/getColor.tsx create mode 100644 ui/gradient.tsx create mode 100644 ui/hooks/useAlert.tsx create mode 100644 ui/hooks/useBackgroundAnimation.tsx create mode 100644 ui/hooks/useFullScreen.tsx create mode 100644 ui/hooks/useLocation.tsx create mode 100644 ui/hooks/useMedia.tsx create mode 100644 ui/hooks/useNavigate.tsx create mode 100644 ui/hooks/usePress.tsx create mode 100644 ui/hooks/usePrompt.tsx create mode 100644 ui/hooks/useScale.tsx create mode 100644 ui/hooks/useScrollIntoView.tsx create mode 100644 ui/hooks/useScrollRestore.tsx create mode 100644 ui/hooks/useVisibilityState.tsx create mode 100644 ui/icons/Ammo.tsx create mode 100644 ui/icons/CreateMap.tsx create mode 100644 ui/icons/Crosshair.tsx create mode 100644 ui/icons/Drag.tsx create mode 100644 ui/icons/DropUnit.tsx create mode 100644 ui/icons/ExitFullscreen.tsx create mode 100644 ui/icons/Finished.tsx create mode 100644 ui/icons/Fold.tsx create mode 100644 ui/icons/Fullscreen.tsx create mode 100644 ui/icons/GamePad.tsx create mode 100644 ui/icons/Heart.tsx create mode 100644 ui/icons/Info.tsx create mode 100644 ui/icons/Label.tsx create mode 100644 ui/icons/Live.tsx create mode 100644 ui/icons/Magic.tsx create mode 100644 ui/icons/NoSkill.tsx create mode 100644 ui/icons/Paw.tsx create mode 100644 ui/icons/Rescue.tsx create mode 100644 ui/icons/Sabotage.tsx create mode 100644 ui/icons/Size.tsx create mode 100644 ui/icons/SkillBorder.tsx create mode 100644 ui/icons/Skills.tsx create mode 100644 ui/icons/Skull.tsx create mode 100644 ui/icons/StopCapture.tsx create mode 100644 ui/icons/Supply.tsx create mode 100644 ui/icons/Track.tsx create mode 100644 ui/icons/Tree.tsx create mode 100644 ui/icons/Volume1.tsx create mode 100644 ui/icons/Volume2.tsx create mode 100644 ui/icons/VolumeX.tsx create mode 100644 ui/icons/ZapOn.tsx create mode 100644 ui/icons/Zombie.tsx create mode 100644 ui/lib/getGameRoute.tsx create mode 100644 ui/lib/getTagColor.tsx create mode 100644 ui/lib/lazy.tsx create mode 100644 ui/lib/scrollToCenter.tsx create mode 100644 ui/package.json create mode 100644 ui/pixelBorder.tsx create mode 100644 ui/types/athena-crisis-audio.d.ts create mode 100644 vitest.config.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..e7d8c9b1 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,107 @@ +const { join } = require('node:path'); +const { existsSync, readFileSync } = require('node:fs'); + +module.exports = { + extends: ['@nkzw', 'plugin:@deities/strict'], + ignorePatterns: [ + 'artemis/prisma/pothos-types.ts', + 'dist/', + 'electron/out/', + 'hera/i18n/CampaignMap.tsx', + ], + overrides: [ + { + files: ['**/__generated__/**/*.ts'], + rules: { + 'unicorn/no-abusive-eslint-disable': 0, + }, + }, + { + files: ['scripts/fixtures/**/*.tsx'], + rules: { + 'unicorn/numeric-separators-style': 0, + }, + }, + { + files: ['i18n/**/*.cjs', 'hera/i18n/**/EntityMap.tsx'], + rules: { + 'sort-keys-fix/sort-keys-fix': 0, + }, + }, + { + files: [ + '{codegen,infra,scripts,tests}/**/*.tsx', + 'artemis/{prisma,scripts}/**/*.tsx', + 'artemis/artemis.tsx', + ], + rules: { + 'no-console': 0, + }, + }, + { + files: ['artemis/discord/**/*.tsx'], + rules: { + 'no-console': [2, { allow: ['error'] }], + }, + }, + ], + plugins: ['@deities'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 2, + { + paths: [ + { + allowTypeImports: true, + message: `Use 'react-relay/hooks' instead.`, + name: 'react-relay', + }, + { + message: `Use 'athena-prisma-client' instead.`, + name: '@prisma/client', + }, + ], + }, + ], + 'import/no-extraneous-dependencies': [ + 2, + { + devDependencies: [ + './{ares,artemis,deimos,offline}/vite.config.ts', + './{ares,artemis}/scripts/**/*.{js,cjs,tsx}', + './ares/ares.tsx', + './artemis/prisma/seed.tsx', + './codegen/**', + './docs/vocs.config.tsx', + './electron/**', + './infra/**', + './scripts/**', + './tests/**', + './vitest.config.ts', + '**/__tests__/**', + ], + packageDir: [__dirname].concat( + existsSync(join(__dirname, './electron')) ? ['./electron'] : [], + readFileSync('./pnpm-workspace.yaml', 'utf8') + .split('\n') + .slice(1) + .map((n) => + join( + __dirname, + n + .replaceAll(/\s*-\s+/g, '') + .replaceAll("'", '') + .replaceAll('\r', ''), + ), + ), + ), + }, + ], + 'import/no-unresolved': [ + 2, + { ignore: ['athena-crisis:*', 'glob', 'virtual:*'] }, + ], + 'no-extra-parens': 0, + 'no-restricted-globals': [2, 'alert', 'confirm'], + }, +}; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..4171c6bf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +name: test + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + +jobs: + action: + timeout-minutes: 20 + permissions: + contents: read + deployments: write + strategy: + matrix: + node-version: [22] + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Use Git Bash + if: matrix.os == 'windows-latest' + run: npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" + + - name: Install pnpm + uses: pnpm/action-setup@v2 + id: pnpm-install + with: + version: 9.* + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm-store- + + - name: Cache Playwright Browsers + id: cache-playwright-browsers + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: playwright-browsers-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Run setup + run: pnpm install && pnpm dev:setup + + - name: Setup Playwright + if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' + run: pnpx playwright install --with-deps chromium + + - name: Run tests + if: matrix.os != 'windows-latest' + run: pnpm test:ci + + - name: Run tests (Windows) + if: matrix.os == 'windows-latest' + run: pnpm vitest:run-ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5caf83b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +.eslintcache +.pnpm-debug.log +**/node_modules/ +/dist/ +/node_modules/ +/package-lock.json +/yarn.lock +ares/.enum_manifest.json +ares/.src_manifest.json +ares/**/__generated__ +ares/src/generated/* +ares/vite.config.ts.timestamp-* +artemis/prisma/pothos-types.ts +coverage +docs/vocs.config.tsx.timestamp-* +electron/demo +electron/log-output +electron/offline +electron/out +halting-problem-failure.json +source_strings.json +tests/testSetup +tsconfig.tsbuildinfo + +# Codegen +apollo/EncodedActions.tsx +apollo/FormatActions.tsx +apollo/Routes.tsx +artemis/graphql/schemaImportMap.tsx +hera/i18n/CampaignMap.tsx +hera/i18n/EntityMap.tsx +hermes/CampaignMapName.tsx +i18n/Entities.cjs diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..9a67ef95 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +auto-install-peers=false +resolution-mode=highest +strict-peer-dependencies=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..74cc57ff --- /dev/null +++ b/.prettierignore @@ -0,0 +1,33 @@ +ares/.enum_manifest.json +ares/.src_manifest.json +ares/**/__generated__ +ares/scripts/translateStorepage.js +ares/src/generated/ +ares/translations/ +artemis/graphql/schema.graphql +artemis/prisma/migrations/ +artemis/prisma/pothos-types.ts +coverage +dist/ +electron/out/ +git-hooks/ +halting-problem-failure.json +patches/ +pnpm-lock.yaml +source_strings.json +ui/music +ui/sounds +vite.config.ts.timestamp* + +# Codegen +apollo/EncodedActions.tsx +apollo/FormatActions.tsx +apollo/Routes.tsx +artemis/graphql/schemaImportMap.tsx +hera/i18n/CampaignMap.tsx +hera/i18n/EntityMap.tsx +hermes/CampaignMapName.tsx +i18n/Entities.cjs + +# Codegen but checked-in +hera/render/Images.tsx diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..0ede3b68 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "styled-components.vscode-styled-components", + "sysoev.vscode-open-in-github", + "usernamehw.errorlens", + "wix.vscode-import-cost" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..03eb8d5e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "editor.formatOnSave": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.reportStyleChecksAsWarnings": false +} diff --git a/@types/vitest.d.ts b/@types/vitest.d.ts new file mode 100644 index 00000000..45efca51 --- /dev/null +++ b/@types/vitest.d.ts @@ -0,0 +1,8 @@ +interface CustomMatchers { + toMatchImageSnapshot(): R; +} + +declare namespace Vi { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..10ad39bd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at coc@nakazawa.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..08c1f8ee --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +# Source Code License + +The MIT License (MIT) + +Copyright (c) 2024 Nakazawa Tech KK + +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. + +--- + +# Branding, Art, and Other Assets + +The MIT License applies to the source code and associated documentation of this software only. Other elements of the project, such as branding ("Athena Crisis"), game content, art and sound assets, including remotely loaded data, are subject to separate licensing terms. For more information or to negotiate a license fee, please contact: license@nakazawa.dev. diff --git a/README.md b/README.md new file mode 100644 index 00000000..d6c44f2f --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Athena Crisis + +

+ + Athena Crisis Logo + +

+ +[Athena Crisis](https://athenacrisis.com) is an Open Core video game developed by [Nakazawa Tech](https://nkzw.tech) and published by [Null](https://null.com). The source code in this repository is licensed under the [MIT License](./LICENSE.md) and can be used to improve Athena Crisis, build additional tools, study game development with JavaScript or create entirely new turn-based strategy games. + +The single-player campaign, multiplayer, art, music, and content are not open source. You can try a demo at [athenacrisis.com](https://athenacrisis.com) and you can [wishlist or purchase Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy Athena Crisis directly](https://app.athenacrisis.com/checkout) to play the full game. + +If you like Athena Crisis, [please consider a sponsorship to support its development](https://github.com/sponsors/cpojer). + + + Athena Crisis Gameplay + + +## Setup + +Athena Crisis requires [Node.js](https://nodejs.org/en/download/package-manager) and the latest major version of [`pnpm`](https://pnpm.io/installation). + +> [!NOTE] +> +>
Windows Specific Config +> Developers on Windows will want to ensure that they are using `bash` to run `package.json` scripts. You can configure npm to use git bash by default with the following: +> +> ```bash +> npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +> ``` +> +>
+ +```bash +pnpm install && pnpm dev:setup +pnpm dev +``` + +Visit [localhost:3003](http://localhost:3003/) to see the docs page. + +## Packages + +The codebase is split into multiple packages to enforce the separation of concerns. We suggest starting with these packages to get an end-to-end overview: + +- `athena` → Data structures and algorithms for manipulating _map_ state (_client/server_). +- `apollo` → Data structures and algorithms for manipulating _game_ state (_client/server_). +- `hera` → Game engine and rendering (_client_). +- `ui` → Design system (_client_). +- `docs` → Docs & Playground (_client_). + +These are secondary packages focused on specific domains: + +- `art` → Handling of assets (_client/build_). +- `codegen` → Run `pnpm codegen` when changing game `Action`s or `ActionResponse`s to generate encoded actions and formatters (_build_). +- `dionysus` → AI code (_client/server_). +- `hephaestus` → Iterator and Immutable data structures (_client/server_). +- `hermes` → Campaign related data structures and algorithms (_client/server_). +- `i18n` → Internationalization (_client/build_). +- `offline` → Offline splash screen for app (_client_). +- `tests` → e2e tests. + +## Documentation & Playground + +Check out our [Athena Crisis Open Source Docs & Playground](https://athenacrisis.com/open-source) site. + +# Q&A + +## What is open source and what isn't? + +About 75% of all non-content related Athena Crisis code – **almost 100,000 lines** – is open source, including the core data structures, algorithms, game engine, rendering, AI, and the map editor. Backend implementations such as user management, databases, APIs, realtime spectating, server configuration, and app wrappers for Steam or app stores are not open source. We aim to open source more of the game over time, but the content will remain the intellectual property of Nakazawa Tech KK and therefore not be open source. You can buy and enjoy [Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy it on athenacrisis.com](https://app.athenacrisis.com/checkout). + +## Why did you open source Athena Crisis? + +[Nakazawa Tech](https://nkzw.tech) is an Open Core company. See [the "Athena Crisis is now Open Source" blog post](https://cpojer.net/posts/athena-crisis-open-source) for more information. + +## How is this codebase used at Nakazawa Tech? + +We use a monorepo for Athena Crisis at Nakazawa Tech and are syncing a portion of the codebase to this repository. Code merged into this open-source repository is automatically synced to the internal monorepo, and vice versa. Athena Crisis always runs the latest version of code from this repository in production. + +### Why are some folders almost empty? + +To simplify dependency management with [`pnpm`](https://pnpm.io/), most of the internal `package.json` files and the `pnpm-lock.yaml` are public. This makes it easier to share code between the internal monorepo and this repository but leaves some placeholders in this repository, most notably the `ares` and `artemis` packages, which can be ignored. + +## Why are packages named after Greek gods? + +Why not!? At some point it became necessary to split the codebase into multiple packages to share code between the client and server. The first package was named `athena`, and it was hard to come up with meaningful names for the other packages. We decided to name them after Greek gods because it seemed cute. + +Over time, many pieces are expected to be extracted into separate packages and published on npm under the `@nkzw` organization. Please send a Pull Request if you find code that you think should be extracted into a separate package. + +## How do assets work in this codebase? + +Assets are not part of this codebase and are not open source. Art and other assets are loaded remotely from the Athena Crisis servers for testing and development. If you want to build your own game based on Athena Crisis, you can use the code as a starting point and replace the assets with your own. + +## I would like to build a commercial project based on this codebase. Can I? + +Yes, you can. However, any content such as art, music, story, characters and their descriptions are not open source and are the intellectual property of Nakazawa Tech KK. You can use the codebase to build your own game, but you must replace all content with your own. For example, you have to replace all references to assets in [`Images.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/render/Images.tsx) or change character descriptions in [`Unit.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/dc1c06b7f033e4c52a29db1524dc08226eacf63a/athena/info/Unit.tsx) if you want to publish your own game. + +If you'd like to use content from Athena Crisis for commercial or non-commercial purposes, you must obtain a license from Nakazawa Tech KK by emailing license@nakazwa.dev. + +# Contributing + +We welcome contributions to Athena Crisis. Some feature development is funded via [Polar](https://polar.sh): [`nkzw-tech/athena-crisis` on Polar](https://polar.sh/nkzw-tech/athena-crisis). Here are some guidelines to get you started: + +- The style guide is enforced through tests and linting. Please run `pnpm test` to run all checks. If they pass, you are good to send a Pull Request. +- Check out [The Perfect Development Environment](https://cpojer.net/posts/the-perfect-development-environment) and [Fastest Frontend Tooling](https://cpojer.net/posts/fastest-frontend-tooling-in-2022) for tips on how to optimize your environment setup. +- We suggest adding tests to Pull Requests. You can find many examples in the [`tests` folder](https://github.com/nkzw-tech/athena-crisis/tree/main/tests). + +We greatly appreciate contributions in the following areas: + +- Bug fixes. +- AI improvements. +- New game features. +- Balancing improvements. +- Experimental technical explorations. +- Tests to cover untested functionality. +- Performance Improvements to core data structures. +- Separation of concerns into smaller libraries that can be published on npm and consumed by other projects. + +# More information + +Check out these links to learn more about the tech behind Athena Crisis: + +- [Join us on Discord](https://discord.gg/2VBCCep7Fk) +- [How NOT to Build a Video Game](https://www.youtube.com/watch?v=m8SmXOTM8Ec) +- [Follow Athena Crisis on Twitter](https://twitter.com/TheAthenaCrisis) +- [Building the AI for Athena Crisis](https://www.youtube.com/watch?v=0V9XaIK0xlQ) +- [Athena Crisis Open Source Docs & Playground](https://athenacrisis.com/open-source) + +# Supporters + +| Hetzner | Crowdin | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Polar | Null | + + + Athena Crisis Keyart + diff --git a/apollo/Action.tsx b/apollo/Action.tsx new file mode 100644 index 00000000..4f86e79b --- /dev/null +++ b/apollo/Action.tsx @@ -0,0 +1,985 @@ +import { Behavior, getBuildingInfo } from '@deities/athena/info/Building.tsx'; +import { + getSkillConfig, + hasCounterAttackSkill, + Skill, +} from '@deities/athena/info/Skill.tsx'; +import { Lightning } from '@deities/athena/info/Tile.tsx'; +import { Ability, getUnitInfo, Weapon } from '@deities/athena/info/Unit.tsx'; +import { getDeterministicUnitName } from '@deities/athena/info/UnitNames.tsx'; +import calculateDamage from '@deities/athena/lib/calculateDamage.tsx'; +import calculateFunds from '@deities/athena/lib/calculateFunds.tsx'; +import canBuild from '@deities/athena/lib/canBuild.tsx'; +import canDeploy from '@deities/athena/lib/canDeploy.tsx'; +import canLoad from '@deities/athena/lib/canLoad.tsx'; +import canPlaceLightning from '@deities/athena/lib/canPlaceLightning.tsx'; +import canPlaceRailTrack from '@deities/athena/lib/canPlaceRailTrack.tsx'; +import followMovementPath from '@deities/athena/lib/followMovementPath.tsx'; +import getAttackStatusEffect from '@deities/athena/lib/getAttackStatusEffect.tsx'; +import getChargeValue from '@deities/athena/lib/getChargeValue.tsx'; +import getDefenseStatusEffect from '@deities/athena/lib/getDefenseStatusEffect.tsx'; +import getHealableVectors from '@deities/athena/lib/getHealableVectors.tsx'; +import getHealCost from '@deities/athena/lib/getHealCost.tsx'; +import getMovementPath from '@deities/athena/lib/getMovementPath.tsx'; +import getRescuableVectors from '@deities/athena/lib/getRescuableVectors.tsx'; +import getSabotageableVectors from '@deities/athena/lib/getSabotageableVectors.tsx'; +import getUnitsToRefill from '@deities/athena/lib/getUnitsToRefill.tsx'; +import { AIBehavior } from '@deities/athena/map/AIBehavior.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { + BuildingCover, + Charge, + CounterAttack, + CreateTracksCost, + MinDamage, + RaisedCounterAttack, +} from '@deities/athena/map/Configuration.tsx'; +import { + DynamicPlayerID, + PlayerID, + resolveDynamicPlayerID, +} from '@deities/athena/map/Player.tsx'; +import { Teams } from '@deities/athena/map/Team.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { getPathCost, moveable } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { ActionResponse } from './ActionResponse.tsx'; +import applyActionResponse from './actions/applyActionResponse.tsx'; + +export type MutateActionResponseFn = (action: ActionResponse) => ActionResponse; + +export type MoveAction = Readonly<{ + complete?: boolean; + from: Vector; + path?: ReadonlyArray; + to: Vector; + type: 'Move'; +}>; + +type AttackUnitAction = Readonly<{ + from: Vector; + to: Vector; + type: 'AttackUnit'; +}>; + +type AttackBuildingAction = Readonly<{ + from: Vector; + to: Vector; + type: 'AttackBuilding'; +}>; + +type CaptureAction = Readonly<{ + from: Vector; + type: 'Capture'; +}>; + +type SupplyAction = Readonly<{ + from: Vector; + type: 'Supply'; +}>; + +type CreateUnitAction = Readonly<{ + from: Vector; + id: number; + to: Vector; + type: 'CreateUnit'; +}>; + +type DropUnitAction = Readonly<{ + from: Vector; + index: number; + to: Vector; + type: 'DropUnit'; +}>; + +type CreateBuildingAction = Readonly<{ + from: Vector; + id: number; + type: 'CreateBuilding'; +}>; + +type CreateTracksAction = Readonly<{ + from: Vector; + type: 'CreateTracks'; +}>; + +type FoldAction = Readonly<{ + from: Vector; + type: 'Fold'; +}>; + +type UnfoldAction = Readonly<{ + from: Vector; + type: 'Unfold'; +}>; + +type CompleteUnitAction = Readonly<{ + from: Vector; + type: 'CompleteUnit'; +}>; + +type CompleteBuildingAction = Readonly<{ + from: Vector; + type: 'CompleteBuilding'; +}>; + +type EndTurnAction = Readonly<{ + type: 'EndTurn'; +}>; + +type MessageAction = Readonly<{ + message: string; + player?: PlayerID; + type: 'Message'; +}>; + +type ToggleLightningAction = Readonly<{ + from: Vector; + to: Vector; + type: 'ToggleLightning'; +}>; + +type HealAction = Readonly<{ + from: Vector; + to: Vector; + type: 'Heal'; +}>; + +type RescueAction = Readonly<{ + from: Vector; + to: Vector; + type: 'Rescue'; +}>; + +type SabotageAction = Readonly<{ + from: Vector; + to: Vector; + type: 'Sabotage'; +}>; + +type SpawnEffectAction = Readonly<{ + player?: DynamicPlayerID; + teams?: Teams; + type: 'SpawnEffect'; + units: ImmutableMap; +}>; + +type BuySkillAction = Readonly<{ + from: Vector; + skill: Skill; + type: 'BuySkill'; +}>; + +type ActivatePowerAction = Readonly<{ skill: Skill; type: 'ActivatePower' }>; + +export type CharacterMessageEffectAction = Readonly<{ + message: string; + player: DynamicPlayerID; + type: 'CharacterMessageEffect'; + unitId: number; + variant?: number; +}>; + +type StartAction = Readonly<{ + type: 'Start'; +}>; + +export type Action = + | ActivatePowerAction + | AttackBuildingAction + | AttackUnitAction + | BuySkillAction + | CaptureAction + | CompleteBuildingAction + | CompleteUnitAction + | CreateBuildingAction + | CreateTracksAction + | CreateUnitAction + | DropUnitAction + | EndTurnAction + | FoldAction + | HealAction + | MessageAction + | MoveAction + | RescueAction + | SabotageAction + | StartAction + | SupplyAction + | ToggleLightningAction + | UnfoldAction + // Effects + | CharacterMessageEffectAction + | SpawnEffectAction; + +export type Actions = ReadonlyArray; + +function move( + map: MapData, + vision: VisionT, + { complete, from, path: initialPath, to }: MoveAction, + isEffect: boolean, +) { + const unitA = map.units.get(from); + if (!unitA || from.equals(to) || !map.contains(to)) { + return null; + } + + if (!isEffect && (!map.isCurrentPlayer(unitA) || !unitA.canMove())) { + return null; + } + + const mapWithVision = vision.apply(map); + const lastVector = initialPath?.at(-1); + if (lastVector && !to.equals(lastVector)) { + return null; + } + + const infoA = unitA.info; + const fields = moveable(mapWithVision, unitA, from); + const { blockedBy, path } = initialPath?.length + ? followMovementPath(map, initialPath, vision) + : getMovementPath(map, to, fields, vision); + if (blockedBy) { + const lastItem = path.at(-1); + if (!lastItem) { + return null; + } + to = lastItem; + } + const radiusItem = fields.get(to); + if (!radiusItem) { + return null; + } + + const cost = initialPath?.length + ? getPathCost(mapWithVision, unitA, from, initialPath) + : radiusItem.cost; + + if (cost === -1) { + return null; + } + + const unitB = map.units.get(to); + if (unitB) { + if (canLoad(map, unitB, unitA, to)) { + return { + from, + fuel: unitA.fuel - cost, + path, + to, + type: 'Move', + } as const; + } + } else if ( + !map.buildings.has(to) || + infoA.hasAbility(Ability.AccessBuildings) + ) { + return { + ...(blockedBy || complete ? { completed: true } : null), + from, + fuel: unitA.fuel - cost, + path, + to, + type: 'Move', + } as const; + } + + return null; +} + +function _attackUnit( + map: MapData, + unitA: Unit, + unitB: Unit, + weapon: Weapon, + vectorA: Vector, + vectorB: Vector, + modifier = 1, +): [Unit, Unit] { + const luck = 1; + const tileInfoA = map.getTileInfo(vectorA); + const tileInfoB = map.getTileInfo(vectorB); + const damage = Math.floor( + Math.max( + MinDamage, + calculateDamage( + unitA, + unitB, + weapon, + tileInfoA.configuration.cover + + (map.buildings.has(vectorA) ? BuildingCover : 0), + tileInfoB.configuration.cover + + (map.buildings.has(vectorB) ? BuildingCover : 0), + getAttackStatusEffect(map, unitA, tileInfoA), + getDefenseStatusEffect(map, unitB, tileInfoB), + luck, + ) * modifier, + ), + ); + return [unitA.subtractAmmo(weapon), unitB.modifyHealth(-damage)]; +} + +function _attackBuilding( + map: MapData, + unitA: Unit, + buildingB: Building, + weapon: Weapon, + vectorA: Vector, + vectorB: Vector, +): [Unit, Building] { + const luck = 1; + const tileInfoA = map.getTileInfo(vectorA); + const tileInfoB = map.getTileInfo(vectorB); + const damage = Math.floor( + calculateDamage( + unitA, + buildingB, + weapon, + tileInfoA.configuration.cover + + (map.buildings.has(vectorA) ? BuildingCover : 0), + tileInfoB.configuration.cover + BuildingCover, + getAttackStatusEffect(map, unitA, tileInfoA), + getDefenseStatusEffect(map, buildingB, tileInfoB), + luck, + ), + ); + return [unitA.subtractAmmo(weapon), buildingB.modifyHealth(-damage)]; +} + +function _counterAttack( + map: MapData, + unitA: Unit, + unitB: Unit, + vA: Vector, + vB: Vector, + previousHealth = unitB.health, +): [Unit, Unit] | null { + const distance = vA.distance(vB); + const playerB = map.getPlayer(unitB); + const weaponB = unitB.getAttackWeapon(unitA); + if ( + distance == 1 && + !unitB.isDead() && + unitB.canAttackAt(distance, playerB) && + weaponB && + weaponB.getDamage(unitA) > 0 + ) { + const hasCounterAttackPower = hasCounterAttackSkill(playerB.activeSkills); + const counterAttack = hasCounterAttackPower + ? 1 + : hasCounterAttackSkill(playerB.skills) + ? RaisedCounterAttack + : CounterAttack; + + const [a, b] = _attackUnit( + map, + hasCounterAttackPower ? unitB.setHealth(previousHealth) : unitB, + unitA, + weaponB, + vB, + vA, + counterAttack, + ); + return [b, unitB.setAmmo(a.ammo)]; + } + return null; +} + +function attackUnit( + map: MapData, + vision: VisionT, + { from, to }: AttackUnitAction, +) { + const unitA = map.units.get(from); + const unitB = map.units.get(to); + const playerA = unitA && map.getPlayer(unitA); + if ( + unitA && + unitB && + playerA && + map.isCurrentPlayer(unitA) && + map.isOpponent(unitA, unitB) && + !unitA.isCompleted() && + unitA.canAttackAt(from.distance(to), playerA) && + vision.isVisible(map, to) + ) { + const weaponA = unitA.getAttackWeapon(unitB); + if (!weaponA || weaponA.getDamage(unitB) <= 0) { + return null; + } + + let [a, b] = _attackUnit(map, unitA, unitB, weaponA, from, to); + const counter = + unitB.player > 0 + ? _counterAttack(map, a, b, from, to, unitB.health) + : null; + if (counter) { + [a, b] = counter; + } + + const playerB = map.getPlayer(unitB); + return { + chargeA: + playerA.charge + + getChargeValue(unitB, playerB, b, 0.33) + + (counter ? getChargeValue(unitA, playerA, a) : 0), + chargeB: + unitB.player > 0 + ? playerB.charge + getChargeValue(unitB, playerB, b) + : undefined, + from, + hasCounterAttack: !!counter, + playerA: a.player, + playerB: b.player, + to, + type: 'AttackUnit', + unitA: a.isDead() ? undefined : a.dry(), + unitB: b.isDead() ? undefined : b.dry(), + } as const; + } + return null; +} + +function attackBuilding( + map: MapData, + vision: VisionT, + { from, to }: AttackBuildingAction, +) { + const unitA = map.units.get(from); + const buildingB = map.buildings.get(to); + if (!unitA || !buildingB) { + return null; + } + + const playerA = map.getPlayer(unitA); + const unitC = map.units.get(to); + if ( + map.isCurrentPlayer(unitA) && + map.isOpponent(unitA, buildingB) && + !unitA.isCompleted() && + unitA.canAttackAt(from.distance(to), playerA) && + (!unitC || map.isOpponent(unitC, unitA)) && + vision.isVisible(map, to) + ) { + const weaponA = unitA.getAttackWeapon(buildingB); + if (!weaponA || weaponA.getDamage(buildingB) <= 0) { + return null; + } + + const result = _attackBuilding(map, unitA, buildingB, weaponA, from, to); + let a = result[0]; + const b = result[1]; + let c: Unit | null = null; + if (!b.isDead() && unitC) { + const counter = _counterAttack(map, a, unitC, from, to); + if (counter) { + [a, c] = counter; + } + } + + const playerB = map.getPlayer(buildingB); + const playerC = unitC ? map.getPlayer(unitC) : undefined; + const chargeB = + buildingB.player > 0 + ? playerB.charge + getChargeValue(buildingB, playerB, b) + : undefined; + return { + building: b.isDead() ? undefined : b, + chargeA: c + ? playerA.charge + getChargeValue(unitA, playerA, a, 0.5) + : undefined, + chargeB, + chargeC: + playerC && unitC + ? (playerC.id === playerB.id ? chargeB || 0 : playerC.charge) + + getChargeValue(unitC, playerC, c || unitC.setHealth(0)) + : undefined, + from, + hasCounterAttack: !!c, + playerA: a.player, + // `playerC` should be provided when `unitC` exists, not when it attacks. + // That way it can be used to check whether the player lost. + playerC: unitC ? unitC.player : undefined, + to, + type: 'AttackBuilding', + unitA: a.isDead() ? undefined : a.dry(), + unitC: c ? c.dry() : undefined, + } as const; + } + return null; +} + +function capture(map: MapData, { from }: CaptureAction) { + const unit = map.units.get(from); + const buildingA = map.buildings.get(from); + if ( + unit && + buildingA && + map.isCurrentPlayer(unit) && + map.isOpponent(unit, buildingA) && + !unit.isCompleted() && + unit.info.hasAbility(Ability.Capture) + ) { + return unit.isCapturing() + ? ({ + building: buildingA.capture(map.getPlayer(unit)), + from, + player: buildingA.player, + type: 'Capture', + } as const) + : ({ from, type: 'Capture' } as const); + } + return null; +} + +function supply(map: MapData, vision: VisionT, { from }: SupplyAction) { + const unit = map.units.get(from); + const unitsToSupply = + unit && getUnitsToRefill(map, vision, map.getPlayer(unit), from); + if ( + unit && + map.isCurrentPlayer(unit) && + !unit.isCompleted() && + unit.info.hasAbility(Ability.Supply) && + unitsToSupply?.size + ) { + return { from, player: unit.player, type: 'Supply' } as const; + } + return null; +} + +function createUnit( + map: MapData, + { from, id, to }: CreateUnitAction, + isEffect: boolean, +) { + const building = map.buildings.get(from); + const player = building && map.getPlayer(building); + const unit = map.units.get(from); + const infoA = getUnitInfo(id); + const infoB = building?.info; + + if ( + from.distance(to) <= 1 && + building && + player && + infoA && + infoB && + canDeploy(map, infoA, to, player.skills.has(Skill.NoUnitRestrictions)) && + (!unit || map.matchesTeam(unit, building)) && + (isEffect || + (map.isCurrentPlayer(building) && + !building.isCompleted() && + player.funds >= infoA.getCostFor(player) && + new Set(building.getBuildableUnits(player)).has(infoA))) + ) { + const behavior = building.getFirstAIBehavior(); + const skipBehaviorRotation = + behavior != null && behavior === AIBehavior.Stay && !infoA.hasAttack(); + return { + free: isEffect, + from, + skipBehaviorRotation, + to, + type: 'CreateUnit', + unit: infoA + .create(building.player, { + behavior: skipBehaviorRotation ? undefined : behavior, + label: building.label, + name: getDeterministicUnitName(map, from, building.player, infoA), + }) + .complete(), + } as const; + } + return null; +} + +function dropUnit(map: MapData, { from, index, to }: DropUnitAction) { + const unitA = map.units.get(from); + const unitB = unitA?.getTransportedUnit(index); + const infoA = unitA?.info; + const infoB = unitB?.info; + if ( + from.distance(to) <= 1 && + infoA && + infoB && + map.isCurrentPlayer(unitA) && + !unitA.isCompleted() && + unitA.isTransportingUnits() && + canDeploy(map, infoB, to, true) && + infoA.canDropFrom(map.getTileInfo(from)) + ) { + return { from, index, to, type: 'DropUnit' } as const; + } + return null; +} + +function createBuilding(map: MapData, { from, id }: CreateBuildingAction) { + const unit = map.units.get(from); + if (!unit) { + return null; + } + + const infoB = getBuildingInfo(id); + if ( + infoB && + map.isCurrentPlayer(unit) && + !unit.isCompleted() && + !map.buildings.has(from) && + unit.info.hasAbility(Ability.CreateBuildings) && + map.getPlayer(unit).funds >= infoB.configuration.cost && + canBuild(map, infoB, unit.player, from) + ) { + return { + building: infoB.create(unit.player, { label: unit.label }).complete(), + from, + type: 'CreateBuilding', + } as const; + } + return null; +} + +function createTracks(map: MapData, { from }: CreateTracksAction) { + const unit = map.units.get(from); + if (!unit) { + return null; + } + + if ( + unit.info.hasAbility(Ability.CreateTracks) && + map.isCurrentPlayer(unit) && + !unit.isCompleted() && + map.getPlayer(unit).funds >= CreateTracksCost && + canPlaceRailTrack(map, from) + ) { + return { + from, + type: 'CreateTracks', + } as const; + } + return null; +} + +function canFold(map: MapData, position: Vector, type: 'fold' | 'unfold') { + const unit = map.units.get(position); + return !!( + unit && + map.isCurrentPlayer(unit) && + !unit.isCompleted() && + unit.info.hasAbility(Ability.Unfold) && + ((type === 'fold' && unit.isUnfolded()) || + (type === 'unfold' && !unit.isUnfolded())) + ); +} + +function fold(map: MapData, { from }: FoldAction) { + return canFold(map, from, 'fold') ? ({ from, type: 'Fold' } as const) : null; +} + +function unfold(map: MapData, { from }: UnfoldAction) { + return canFold(map, from, 'unfold') + ? ({ from, type: 'Unfold' } as const) + : null; +} + +function completeUnit(map: MapData, { from }: CompleteUnitAction) { + const unit = map.units.get(from); + return unit && map.isCurrentPlayer(unit) && !unit.isCompleted() + ? ({ from, type: 'CompleteUnit' } as const) + : null; +} + +function completeBuilding(map: MapData, { from }: CompleteBuildingAction) { + const building = map.buildings.get(from); + const unitOnBuilding = map.units.get(from); + + return building && + map.isCurrentPlayer(building) && + (!unitOnBuilding || map.matchesPlayer(unitOnBuilding, building)) && + !building.isCompleted() + ? ({ from, type: 'CompleteBuilding' } as const) + : null; +} + +function endTurn(map: MapData) { + const currentPlayer = map.getCurrentPlayer(); + let next = map.getNextPlayer(); + const funds = calculateFunds(map, next); + const round = map.round + (map.isEndOfRound() ? 1 : 0); + next = next.modifyFunds(funds); + return { + current: { funds: currentPlayer.funds, player: currentPlayer.id }, + next: { funds: next.funds, player: next.id }, + round, + type: 'EndTurn', + } as const; +} + +function message(_: MapData, { message, player }: MessageAction) { + return { + message, + player, + type: 'Message', + } as const; +} + +function toggleLightning(map: MapData, { from, to }: ToggleLightningAction) { + const building = map.buildings.get(from); + const tile = map.contains(to) && map.getTileInfo(to); + + if ( + from.equals(to) || + !building || + building.isCompleted() || + !map.isCurrentPlayer(building) || + map.getCurrentPlayer().charge < Charge + ) { + return null; + } + + return tile === Lightning || canPlaceLightning(map, to) + ? ({ from, to, type: 'ToggleLightning' } as const) + : null; +} + +function heal(map: MapData, { from, to }: HealAction) { + const unitA = map.units.get(from); + const unitB = map.units.get(to); + const player = unitA && map.getPlayer(unitA); + + if ( + from.equals(to) || + !unitA || + !unitB || + !player || + unitA.isCompleted() || + !map.isCurrentPlayer(unitA) || + !getHealableVectors(map, from).has(to) || + getHealCost(unitB, player) > map.getPlayer(unitA).funds + ) { + return null; + } + + return { from, to, type: 'Heal' } as const; +} + +function rescue(map: MapData, { from, to }: RescueAction) { + const unitA = map.units.get(from); + const unitB = map.units.get(to); + + if ( + from.equals(to) || + !unitA || + unitA.isCompleted() || + !map.isCurrentPlayer(unitA) || + !unitB || + unitB.player !== 0 || + !getRescuableVectors(map, from).has(to) + ) { + return null; + } + + return { from, player: unitA.player, to, type: 'Rescue' } as const; +} + +function sabotage(map: MapData, { from, to }: SabotageAction) { + const unitA = map.units.get(from); + const unitB = map.units.get(to); + + if ( + from.equals(to) || + !unitA || + unitA.isCompleted() || + !map.isCurrentPlayer(unitA) || + !unitB || + !getSabotageableVectors(map, from).has(to) + ) { + return null; + } + + return { from, to, type: 'Sabotage' } as const; +} + +function spawnEffect( + map: MapData, + { player: dynamicPlayer, teams, units }: SpawnEffectAction, +) { + const player = dynamicPlayer + ? resolveDynamicPlayerID(map, dynamicPlayer) + : null; + units = units + .filter((unit, vector) => canDeploy(map, unit.info, vector, false)) + .map((unit) => (player != null ? unit.setPlayer(player) : unit)); + return units.size + ? ({ + teams, + type: 'Spawn', + units, + } as const) + : null; +} + +function characterMessageEffect( + map: MapData, + { message, player, unitId, variant }: CharacterMessageEffectAction, +) { + return { + message, + player, + type: 'CharacterMessage', + unitId, + variant, + } as const; +} + +function buySkill(map: MapData, { from, skill }: BuySkillAction) { + const building = map.buildings.get(from); + const player = building && map.getPlayer(building); + const unit = map.units.get(from); + const { cost } = getSkillConfig(skill); + + if ( + building && + player && + !player.skills.has(skill) && + !building.isCompleted() && + building.info.hasBehavior(Behavior.SellSkills) && + building.skills?.has(skill) && + cost != null && + player.funds >= cost && + (!unit || map.matchesTeam(unit, building)) && + map.isCurrentPlayer(building) + ) { + return { + from, + player: player.id, + skill, + type: 'BuySkill', + } as const; + } + return null; +} + +function activatePower(map: MapData, { skill }: ActivatePowerAction) { + const player = map.getCurrentPlayer(); + const { charges } = getSkillConfig(skill); + + if ( + player && + player.skills?.has(skill) && + !player.activeSkills?.has(skill) && + charges && + charges > 0 && + player.charge >= charges * Charge + ) { + return { + skill, + type: 'ActivatePower', + } as const; + } + + return null; +} + +function applyAction( + map: MapData, + vision: VisionT, + action: Action, + isEffect: boolean, +): ActionResponse | null { + switch (action.type) { + case 'Move': + return move(map, vision, action, isEffect); + case 'AttackUnit': + return attackUnit(map, vision, action); + case 'AttackBuilding': + return attackBuilding(map, vision, action); + case 'Capture': + return capture(map, action); + case 'Supply': + return supply(map, vision, action); + case 'CreateUnit': + return createUnit(map, action, isEffect); + case 'DropUnit': + return dropUnit(map, action); + case 'CreateBuilding': + return createBuilding(map, action); + case 'CreateTracks': + return createTracks(map, action); + case 'Fold': + return fold(map, action); + case 'Unfold': + return unfold(map, action); + case 'CompleteUnit': + return completeUnit(map, action); + case 'CompleteBuilding': + return completeBuilding(map, action); + case 'EndTurn': + return endTurn(map); + case 'Message': + return message(map, action); + case 'ToggleLightning': + return toggleLightning(map, action); + case 'Heal': + return heal(map, action); + case 'Rescue': + return rescue(map, action); + case 'Sabotage': + return sabotage(map, action); + case 'Start': + return { type: 'Start' } as const; + // Effects + case 'SpawnEffect': + return isEffect ? spawnEffect(map, action) : null; + case 'CharacterMessageEffect': + return isEffect ? characterMessageEffect(map, action) : null; + case 'BuySkill': + return buySkill(map, action); + case 'ActivatePower': + return activatePower(map, action); + default: { + const _exhaustiveCheck: never = action; + return _exhaustiveCheck; + } + } +} + +function executeAction( + map: MapData, + vision: VisionT, + action: Action, + mutateAction: MutateActionResponseFn | undefined, + isEffect: boolean, +): readonly [ActionResponse, MapData] | null { + let actionResponse = applyAction(map, vision, action, isEffect || false); + if (actionResponse && mutateAction) { + actionResponse = mutateAction(actionResponse); + } + + return actionResponse + ? ([ + actionResponse, + applyActionResponse(map, vision, actionResponse), + ] as const) + : null; +} + +export function execute( + map: MapData, + vision: VisionT, + action: Action, + mutateAction?: MutateActionResponseFn, +): readonly [ActionResponse, MapData] | null { + return executeAction(map, vision, action, mutateAction, false); +} + +export function executeEffect( + map: MapData, + vision: VisionT, + action: Action, +): readonly [ActionResponse, MapData] | null { + return executeAction(map, vision, action, undefined, true); +} diff --git a/apollo/ActionMap.json b/apollo/ActionMap.json new file mode 100644 index 00000000..20cc617f --- /dev/null +++ b/apollo/ActionMap.json @@ -0,0 +1,175 @@ +[ + [ + "Move", + [0, ["type", "from", "to", "complete", "fuel", "completed", "path"]] + ], + [ + "AttackUnit", + [ + 1, + [ + "type", + "from", + "to", + "hasCounterAttack", + "playerA", + "playerB", + "unitA", + "unitB", + "chargeA", + "chargeB" + ] + ] + ], + [ + "AttackBuilding", + [ + 2, + [ + "type", + "from", + "to", + "hasCounterAttack", + "playerA", + "building", + "playerC", + "unitA", + "unitC", + "chargeA", + "chargeB", + "chargeC" + ] + ] + ], + ["Capture", [3, ["type", "from", "building", "player"]]], + ["Supply", [4, ["type", "from", "player"]]], + [ + "CreateUnit", + [5, ["type", "from", "id", "to", "unit", "free", "skipBehaviorRotation"]] + ], + ["DropUnit", [6, ["type", "from", "index", "to"]]], + ["CreateBuilding", [7, ["type", "from", "id", "building"]]], + ["Fold", [8, ["type", "from"]]], + ["Unfold", [9, ["type", "from"]]], + ["CompleteUnit", [10, ["type", "from"]]], + [ + "EndTurn", + [ + 11, + [ + "type", + "current", + "funds", + "player", + "next", + "round", + "rotatePlayers", + "supply", + "miss" + ] + ] + ], + ["Message", [12, ["type", "message", "player"]]], + ["AttackUnitGameOver", [13, ["type", "fromPlayer", "toPlayer"]]], + ["BeginTurnGameOver", [14, ["type"]]], + ["CaptureGameOver", [15, ["type", "fromPlayer", "toPlayer"]]], + ["GameEnd", [16, ["type", "condition", "conditionId", "toPlayer"]]], + ["HiddenMove", [17, ["type", "path", "completed", "fuel", "unit"]]], + [ + "HiddenSourceAttackUnit", + [ + 18, + [ + "type", + "direction", + "hasCounterAttack", + "playerB", + "to", + "unitB", + "weapon", + "chargeB", + "newPlayerB" + ] + ] + ], + [ + "HiddenTargetAttackUnit", + [ + 19, + [ + "type", + "direction", + "from", + "hasCounterAttack", + "playerA", + "unitA", + "weapon", + "chargeA", + "newPlayerA" + ] + ] + ], + [ + "HiddenSourceAttackBuilding", + [ + 20, + [ + "type", + "direction", + "hasCounterAttack", + "to", + "building", + "playerC", + "unitC", + "weapon", + "chargeB", + "chargeC" + ] + ] + ], + [ + "HiddenTargetAttackBuilding", + [ + 21, + [ + "type", + "direction", + "from", + "hasCounterAttack", + "playerA", + "to", + "unitA", + "weapon", + "chargeA", + "newPlayerA" + ] + ] + ], + ["HiddenDestroyedBuilding", [22, ["type", "to"]]], + ["HiddenFundAdjustment", [23, ["type", "funds"]]], + ["ToggleLightning", [24, ["type", "from", "to", "player"]]], + ["CompleteBuilding", [25, ["type", "from"]]], + ["Spawn", [26, ["type", "units", "teams"]]], + ["Heal", [27, ["type", "from", "to"]]], + ["MoveUnit", [28, ["type", "from"]]], + ["Sabotage", [29, ["type", "from", "to"]]], + [ + "CharacterMessage", + [30, ["type", "message", "player", "unitId", "variant"]] + ], + ["CreateTracks", [31, ["type", "from"]]], + ["SpawnEffect", [32, ["type", "units", "player", "teams"]]], + [ + "CharacterMessageEffect", + [33, ["type", "message", "player", "unitId", "variant"]] + ], + ["Start", [34, ["type"]]], + ["SetViewer", [35, ["type"]]], + ["BeginGame", [36, ["type"]]], + ["Rescue", [37, ["type", "from", "to", "player"]]], + ["ReceiveReward", [38, ["type", "player", "reward"]]], + ["BuySkill", [39, ["type", "from", "skill", "player"]]], + ["ActivatePower", [40, ["type", "skill"]]], + ["PreviousTurnGameOver", [41, ["type", "fromPlayer"]]], + ["SecretDiscovered", [42, ["type", "condition"]]] +] diff --git a/apollo/ActionResponse.tsx b/apollo/ActionResponse.tsx new file mode 100644 index 00000000..edb354c1 --- /dev/null +++ b/apollo/ActionResponse.tsx @@ -0,0 +1,241 @@ +import { Skill } from '@deities/athena/info/Skill.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { DynamicPlayerID, PlayerID } from '@deities/athena/map/Player.tsx'; +import { Reward } from '@deities/athena/map/Reward.tsx'; +import { Teams } from '@deities/athena/map/Team.tsx'; +import Unit, { DryUnit } from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import { WinCondition } from '@deities/athena/WinConditions.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { GameOverActionResponses } from './GameOver.tsx'; +import { HiddenActionResponse } from './HiddenAction.tsx'; + +export type MoveActionResponse = Readonly<{ + completed?: boolean; + from: Vector; + fuel: number; + path?: ReadonlyArray; + to: Vector; + type: 'Move'; +}>; + +export type AttackUnitActionResponse = Readonly<{ + chargeA?: number; + chargeB?: number; + from: Vector; + hasCounterAttack: boolean; + playerA: PlayerID; + playerB: PlayerID; + to: Vector; + type: 'AttackUnit'; + unitA?: DryUnit; + unitB?: DryUnit; +}>; + +export type AttackBuildingActionResponse = Readonly<{ + building?: Building; + chargeA?: number; + chargeB?: number; + chargeC?: number; + from: Vector; + hasCounterAttack: boolean; + playerA: PlayerID; + playerC?: PlayerID; + to: Vector; + type: 'AttackBuilding'; + unitA?: DryUnit; + unitC?: DryUnit; +}>; + +export type CaptureActionResponse = Readonly<{ + building?: Building; + from: Vector; + player?: PlayerID; + type: 'Capture'; +}>; + +export type SupplyActionResponse = Readonly<{ + from: Vector; + player: PlayerID; + type: 'Supply'; +}>; + +export type CreateUnitActionResponse = Readonly<{ + free?: boolean; + from: Vector; + skipBehaviorRotation?: boolean; + to: Vector; + type: 'CreateUnit'; + unit: Unit; +}>; + +export type DropUnitActionResponse = Readonly<{ + from: Vector; + index: number; + to: Vector; + type: 'DropUnit'; +}>; + +export type CreateBuildingActionResponse = Readonly<{ + building: Building; + from: Vector; + type: 'CreateBuilding'; +}>; + +export type CreateTracksActionResponse = Readonly<{ + from: Vector; + type: 'CreateTracks'; +}>; + +export type FoldActionResponse = Readonly<{ + from: Vector; + type: 'Fold'; +}>; + +export type UnfoldActionResponse = Readonly<{ + from: Vector; + type: 'Unfold'; +}>; + +export type CompleteUnitActionResponse = Readonly<{ + from: Vector; + type: 'CompleteUnit'; +}>; + +export type CompleteBuildingActionResponse = Readonly<{ + from: Vector; + type: 'CompleteBuilding'; +}>; + +export type EndTurnActionResponse = Readonly<{ + current: Readonly<{ funds: number; player: PlayerID }>; + miss?: boolean; + next: Readonly<{ funds: number; player: PlayerID }>; + rotatePlayers?: boolean; + round: number; + supply?: ReadonlyArray; + type: 'EndTurn'; +}>; + +export type MessageActionResponse = Readonly<{ + message: string; + player?: PlayerID; + type: 'Message'; +}>; + +export type CharacterMessageActionResponse = Readonly<{ + message: string; + player: DynamicPlayerID; + type: 'CharacterMessage'; + unitId: number; + variant?: number; +}>; + +export type ToggleLightningActionResponse = Readonly<{ + from?: Vector; + player?: PlayerID; + to: Vector; + type: 'ToggleLightning'; +}>; + +export type SpawnActionResponse = Readonly<{ + teams?: Teams; + type: 'Spawn'; + units: ImmutableMap; +}>; + +export type HealActionResponse = Readonly<{ + from?: Vector; + to: Vector; + type: 'Heal'; +}>; + +export type RescueActionResponse = Readonly<{ + from?: Vector; + player: PlayerID; + to: Vector; + type: 'Rescue'; +}>; + +export type SabotageActionResponse = Readonly<{ + from?: Vector; + to: Vector; + type: 'Sabotage'; +}>; + +export type MoveUnitActionResponse = Readonly<{ + from: Vector; + type: 'MoveUnit'; +}>; + +export type StartActionResponse = Readonly<{ + type: 'Start'; +}>; + +export type BeginGameActionResponse = Readonly<{ + type: 'BeginGame'; +}>; + +export type SetViewerActionResponse = Readonly<{ + type: 'SetViewer'; +}>; + +export type ReceiveRewardActionResponse = Readonly<{ + player: PlayerID; + reward: Reward; + type: 'ReceiveReward'; +}>; + +export type BuySkillActionResponse = Readonly<{ + from: Vector; + player: PlayerID; + skill: Skill; + type: 'BuySkill'; +}>; + +export type ActivatePowerActionResponse = Readonly<{ + skill: Skill; + type: 'ActivatePower'; +}>; + +export type SecretDiscoveredActionResponse = Readonly<{ + condition: WinCondition; + type: 'SecretDiscovered'; +}>; + +export type ActionResponse = + | ActivatePowerActionResponse + | AttackBuildingActionResponse + | AttackUnitActionResponse + | BeginGameActionResponse + | BuySkillActionResponse + | CaptureActionResponse + | CharacterMessageActionResponse + | CompleteBuildingActionResponse + | CompleteUnitActionResponse + | CreateBuildingActionResponse + | CreateTracksActionResponse + | CreateUnitActionResponse + | DropUnitActionResponse + | EndTurnActionResponse + | FoldActionResponse + | HealActionResponse + | MessageActionResponse + | MoveActionResponse + | MoveUnitActionResponse + | ReceiveRewardActionResponse + | RescueActionResponse + | SabotageActionResponse + | SecretDiscoveredActionResponse + | SetViewerActionResponse + | SpawnActionResponse + | StartActionResponse + | SupplyActionResponse + | ToggleLightningActionResponse + | UnfoldActionResponse + // List of further Action Responses. + | HiddenActionResponse + | GameOverActionResponses; + +export type ActionResponses = ReadonlyArray; +export type ActionResponseType = ActionResponse['type']; diff --git a/apollo/CharacterMessage.tsx b/apollo/CharacterMessage.tsx new file mode 100644 index 00000000..e5918cea --- /dev/null +++ b/apollo/CharacterMessage.tsx @@ -0,0 +1,17 @@ +import { getUnitInfoOrThrow, UnitInfo } from '@deities/athena/info/Unit.tsx'; +import { DynamicPlayerID } from '@deities/athena/map/Player.tsx'; + +export default function CharacterMessage( + unit: UnitInfo | number, + message: string, + player: DynamicPlayerID, + variant?: number, +) { + return { + message, + player, + type: 'CharacterMessageEffect', + unitId: (typeof unit === 'number' ? getUnitInfoOrThrow(unit) : unit).id, + variant, + } as const; +} diff --git a/apollo/Condition.tsx b/apollo/Condition.tsx new file mode 100644 index 00000000..3e4fa54c --- /dev/null +++ b/apollo/Condition.tsx @@ -0,0 +1,141 @@ +import { getUnitInfo } from '@deities/athena/info/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { WinCriteria } from '@deities/athena/WinConditions.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { ActionResponse } from './ActionResponse.tsx'; +import transformEffectValue from './lib/transformEffectValue.tsx'; + +export type WinConditionID = 'win' | 'lose' | 'draw' | number; +const WinConditionIDs = new Set(['win', 'lose', 'draw']); + +export type PlainWinConditionID = number; + +type UnitEqualsCondition = Readonly<{ + from: Vector; + target: ReadonlyArray; + type: 'UnitEquals'; +}>; + +export type GameEndCondition = Readonly<{ + type: 'GameEnd'; + value: WinConditionID; +}>; + +export type Condition = UnitEqualsCondition | GameEndCondition; +export type Conditions = ReadonlyArray; + +const equalsUnit = ( + previousMap: MapData, + activeMap: MapData, + actionResponse: ActionResponse, + originalCondition: UnitEqualsCondition, +) => { + const { from, target } = transformEffectValue( + activeMap, + actionResponse, + originalCondition, + ); + const unit = activeMap.contains(from) && activeMap.units.get(from); + return !!(unit && target.includes(unit.id)); +}; + +const gameEnd = ( + previousMap: MapData, + activeMap: MapData, + actionResponse: ActionResponse, + { value }: GameEndCondition, +) => { + if (actionResponse.type !== 'GameEnd') { + return false; + } + + if (!actionResponse.toPlayer) { + return value === 'draw'; + } + + if ( + activeMap.getCurrentPlayer().teamId === + activeMap.getTeam(actionResponse.toPlayer).id + ) { + return ( + (value === 'win' && !actionResponse.condition) || + (typeof value === 'number' && value === actionResponse.conditionId) + ); + } + + return value === 'lose'; +}; + +export function evaluateCondition( + previousMap: MapData, + activeMap: MapData, + actionResponse: ActionResponse, + condition: Condition, +): boolean { + const { type } = condition; + switch (type) { + case 'UnitEquals': + return equalsUnit(previousMap, activeMap, actionResponse, condition); + case 'GameEnd': + return gameEnd(previousMap, activeMap, actionResponse, condition); + default: { + condition satisfies never; + throw new UnknownTypeError('evaluateCondition', type); + } + } +} + +export function encodeWinConditionID(id: WinConditionID): PlainWinConditionID { + switch (id) { + case 'win': + return -1; + case 'lose': + return -2; + case 'draw': + return -3; + default: + return id; + } +} + +export function decodeWinConditionID(id: PlainWinConditionID): WinConditionID { + switch (id) { + case -1: + return 'win'; + case -2: + return 'lose'; + case -3: + return 'draw'; + default: + return id; + } +} + +export function validateCondition(map: MapData, condition: Condition) { + const { type } = condition; + switch (type) { + case 'UnitEquals': { + if (condition.target.some((id) => !getUnitInfo(id))) { + return false; + } + return true; + } + case 'GameEnd': { + const { + config: { winConditions }, + } = map; + const { value } = condition; + return ( + WinConditionIDs.has(value) || + (typeof value === 'number' && + winConditions[value] && + winConditions[value].type !== WinCriteria.Default) + ); + } + default: { + condition satisfies never; + throw new UnknownTypeError('validateCondition', type); + } + } +} diff --git a/apollo/ConditionMap.json b/apollo/ConditionMap.json new file mode 100644 index 00000000..779eb94b --- /dev/null +++ b/apollo/ConditionMap.json @@ -0,0 +1,4 @@ +[ + ["UnitEquals", [0, ["type", "from", "target"]]], + ["GameEnd", [1, ["type", "value"]]] +] diff --git a/apollo/Effects.tsx b/apollo/Effects.tsx new file mode 100644 index 00000000..6e1fb2bc --- /dev/null +++ b/apollo/Effects.tsx @@ -0,0 +1,208 @@ +import { + PlayerIDs, + PlayerIDSet, + toPlayerIDs, +} from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { Actions, executeEffect } from './Action.tsx'; +import { ActionResponse, ActionResponseType } from './ActionResponse.tsx'; +import validateAction from './actions/validateAction.tsx'; +import { + Conditions, + evaluateCondition, + validateCondition, +} from './Condition.tsx'; +import { + decodeActionID, + decodeActions, + decodeCondition, + encodeActionID, + encodeActions, + encodeCondition, + EncodedActionResponseType, + EncodedActions, + EncodedConditions, +} from './EncodedActions.tsx'; +import transformEffectValue from './lib/transformEffectValue.tsx'; +import { GameStateWithEffects } from './Types.tsx'; + +export type Effect = Readonly<{ + actions: Actions; + conditions?: Conditions; + occurrence?: 'once'; + players?: PlayerIDSet; +}>; + +export type EffectTrigger = ActionResponseType; +export type Scenario = Readonly<{ effect: Effect; trigger: EffectTrigger }>; +export type Effects = ReadonlyMap>; + +type EncodedEffect = [ + actions: EncodedActions, + conditions?: EncodedConditions | null, + players?: PlayerIDs | null, + occurrence?: 1 | null, +]; +export type EncodedEffects = ReadonlyArray< + [EncodedActionResponseType, ReadonlyArray] +>; + +const applyActions = ( + map: MapData | null, + lastActionResponse: ActionResponse, + actions: Actions, + effects: Effects, +): GameStateWithEffects => { + const newGameState = []; + let actionResponse: ActionResponse | null; + for (const action of actions) { + try { + [actionResponse, map] = (map && + executeEffect( + map, + map.createVisionObject(map.currentPlayer), + transformEffectValue(map, lastActionResponse, action), + )) || [null, null]; + if (actionResponse && map) { + newGameState.push([actionResponse, map, effects] as const); + } else { + return []; + } + } catch { + return []; + } + } + return newGameState; +}; + +export function applyEffects( + previousMap: MapData, + activeMap: MapData, + effects: Effects, + actionResponse: ActionResponse, +): GameStateWithEffects | null { + const trigger = actionResponse.type; + const possibleEffects = effects.get(trigger); + if (!possibleEffects) { + return actionResponse.type === 'Start' + ? [[{ type: 'BeginGame' }, activeMap, effects]] + : null; + } + + let gameState: GameStateWithEffects = []; + for (const effect of possibleEffects) { + const { actions, conditions, occurrence, players } = effect; + if ( + (!players || players.has(activeMap.currentPlayer)) && + (!conditions?.length || + conditions.every((condition) => + evaluateCondition(previousMap, activeMap, actionResponse, condition), + )) + ) { + if (occurrence === 'once') { + const newEffects = new Map(effects); + const list = new Set(possibleEffects); + list.delete(effect); + if (list.size) { + newEffects.set(trigger, list); + } else { + newEffects.delete(trigger); + } + effects = newEffects; + } + gameState = [ + ...gameState, + ...applyActions(activeMap, actionResponse, actions, effects), + ]; + const lastMap = gameState.at(-1)?.[1]; + previousMap = + gameState.at(-2)?.[1] || (lastMap ? activeMap : previousMap); + activeMap = lastMap || activeMap; + } + } + + if (actionResponse.type === 'Start') { + gameState = [...gameState, [{ type: 'BeginGame' }, activeMap, effects]]; + } + + return gameState?.length ? gameState : null; +} + +function encodeEffect({ + actions, + conditions, + occurrence, + players, +}: Effect): EncodedEffect { + return removeNull([ + encodeActions(actions), + conditions?.map(encodeCondition) || null, + players?.size ? [...players] : null, + occurrence === 'once' ? 1 : null, + ]); +} + +const removeNull = (array: T): T => { + let index = array.length - 1; + while (array[index as number] == null) { + index--; + } + array.length = (index + 1) as typeof array.length; + return array; +}; + +export function encodeEffects(effects: Effects): EncodedEffects { + return [...effects].map(([trigger, list]) => [ + encodeActionID(trigger), + [...list].map(encodeEffect), + ]); +} + +export function decodeEffect(encodedEffect: EncodedEffect): Effect { + return { + actions: decodeActions(encodedEffect[0]), + conditions: encodedEffect[1]?.map(decodeCondition), + occurrence: encodedEffect[3] === 1 ? 'once' : undefined, + players: encodedEffect[2] ? new Set(encodedEffect[2]) : undefined, + }; +} + +export function decodeEffects(encodedEffects: EncodedEffects): Effects { + return new Map( + encodedEffects.map(([trigger, list]) => [ + decodeActionID(trigger), + new Set(list.map(decodeEffect)), + ]), + ); +} + +export function validateEffects(map: MapData, effects: Effects): Effects { + const newEffects = new Map(); + + for (const [trigger, effectList] of effects) { + encodeActionID(trigger); + + const newEffectList = []; + for (const { actions, conditions, occurrence, players } of effectList) { + const newActions = actions.map(validateAction).filter(Boolean); + const newConditions = conditions?.filter( + validateCondition.bind(null, map), + ); + + if (newActions.length) { + newEffectList.push({ + actions: newActions, + conditions: newConditions, + occurrence: occurrence === 'once' ? occurrence : undefined, + players: players ? new Set(toPlayerIDs([...players])) : undefined, + }); + } + } + + if (newEffectList.length) { + newEffects.set(trigger, new Set(newEffectList)); + } + } + + return newEffects; +} diff --git a/apollo/GameOver.tsx b/apollo/GameOver.tsx new file mode 100644 index 00000000..bea10e61 --- /dev/null +++ b/apollo/GameOver.tsx @@ -0,0 +1,399 @@ +import matchesPlayerList from '@deities/athena/lib/matchesPlayerList.tsx'; +import { AllowedMisses } from '@deities/athena/map/Configuration.tsx'; +import type Player from '@deities/athena/map/Player.tsx'; +import { + PlayerID, + resolveDynamicPlayerID, +} from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import Vision from '@deities/athena/Vision.tsx'; +import { WinCondition, WinCriteria } from '@deities/athena/WinConditions.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { EndTurnAction } from './action-mutators/ActionMutators.tsx'; +import { execute } from './Action.tsx'; +import { + ActionResponse, + AttackBuildingActionResponse, + AttackUnitActionResponse, + ToggleLightningActionResponse, +} from './ActionResponse.tsx'; +import checkWinConditions, { + isDestructiveAction, + shouldCheckDefaultWinConditions, +} from './lib/checkWinCondition.tsx'; +import { processRewards } from './lib/processRewards.tsx'; +import { GameState, MutableGameState } from './Types.tsx'; + +export type AttackUnitGameOverActionResponse = Readonly<{ + fromPlayer: PlayerID; + toPlayer: PlayerID; + type: 'AttackUnitGameOver'; +}>; + +export type BeginTurnGameOverActionResponse = Readonly<{ + type: 'BeginTurnGameOver'; +}>; + +export type PreviousTurnGameOverActionResponse = Readonly<{ + fromPlayer: PlayerID; + type: 'PreviousTurnGameOver'; +}>; + +export type CaptureGameOverActionResponse = Readonly<{ + fromPlayer: PlayerID; + toPlayer: PlayerID; + type: 'CaptureGameOver'; +}>; + +export type GameEndActionResponse = Readonly<{ + condition?: WinCondition; + conditionId?: number; + toPlayer?: PlayerID; + type: 'GameEnd'; +}>; + +export type GameOverActionResponses = + | AttackUnitGameOverActionResponse + | BeginTurnGameOverActionResponse + | CaptureGameOverActionResponse + | GameEndActionResponse + | PreviousTurnGameOverActionResponse; + +function check( + previousMap: MapData, + activeMap: MapData, + actionResponse: ActionResponse, +) { + if (shouldCheckDefaultWinConditions(previousMap, actionResponse)) { + switch (actionResponse.type) { + case 'AttackUnit': + return checkAttackUnit(activeMap, actionResponse); + case 'AttackBuilding': + return checkAttackBuilding(activeMap, actionResponse); + case 'Capture': + return checkCapture(previousMap, activeMap, actionResponse); + case 'ToggleLightning': + return checkToggleLightning(previousMap, activeMap, actionResponse); + case 'EndTurn': + return checkEndTurn(previousMap, activeMap); + } + } + return null; +} + +const pickWinningPlayer = ( + previousMap: MapData, + activeMap: MapData, + actionResponse: ActionResponse, + condition: WinCondition, +) => { + if (condition.type === WinCriteria.DefeatAmount) { + return ( + condition.players?.length ? condition.players : activeMap.active + ).find( + (playerID) => + activeMap.getPlayer(playerID).stats.destroyedUnits >= condition.amount, + ); + } + + if ( + actionResponse.type === 'EndTurn' && + condition.type !== WinCriteria.Survival + ) { + return previousMap.currentPlayer; + } + + if ( + (condition.type === WinCriteria.RescueLabel || + condition.type === WinCriteria.CaptureLabel) && + isDestructiveAction(actionResponse) && + matchesPlayerList(condition.players, activeMap.currentPlayer) + ) { + return resolveDynamicPlayerID(activeMap, 'opponent'); + } + + return activeMap.currentPlayer; +}; + +export function checkGameOverConditions( + previousMap: MapData, + activeMap: MapData, + lastActionResponse: ActionResponse, +): GameState | null { + const condition = checkWinConditions( + previousMap, + activeMap, + lastActionResponse, + ); + const actionResponse = !condition + ? check(previousMap, activeMap, lastActionResponse) + : null; + if (!actionResponse && !condition) { + return null; + } + + let map = actionResponse + ? applyGameOverActionResponse(activeMap, actionResponse) + : activeMap; + const gameState: MutableGameState = actionResponse + ? [[actionResponse, map]] + : []; + const gameEndResponse = condition + ? ({ + condition, + conditionId: activeMap.config.winConditions.indexOf(condition), + toPlayer: pickWinningPlayer( + previousMap, + activeMap, + lastActionResponse, + condition, + ), + type: 'GameEnd', + } as const) + : checkGameEnd(map); + + if (gameEndResponse) { + let newGameState: GameState = []; + [newGameState, map] = processRewards(map, gameEndResponse); + return [ + ...gameState, + ...newGameState, + [gameEndResponse, applyGameOverActionResponse(map, gameEndResponse)], + ]; + } + + if ( + actionResponse?.type === 'AttackUnitGameOver' || + actionResponse?.type === 'BeginTurnGameOver' + ) { + // If the user self-destructs, issue an `EndTurnAction`. + const fromPlayer = + actionResponse.type === 'AttackUnitGameOver' + ? map.getPlayer(actionResponse.fromPlayer) + : map.getCurrentPlayer(); + if (map.isCurrentPlayer(fromPlayer)) { + const [endTurnActionResponse, newMap] = + execute( + map.copy({ active: activeMap.active }), + new Vision(fromPlayer.id), + EndTurnAction(), + ) || []; + if ( + newMap && + endTurnActionResponse && + endTurnActionResponse.type == 'EndTurn' + ) { + return [ + ...gameState, + [endTurnActionResponse, newMap.copy({ active: map.active })], + ]; + } + } + } + + return gameState; +} + +export function applyGameOverActionResponse( + map: MapData, + actionResponse: GameOverActionResponses, +) { + const { type } = actionResponse; + switch (type) { + case 'AttackUnitGameOver': + case 'PreviousTurnGameOver': + case 'BeginTurnGameOver': { + const fromPlayer = + actionResponse.type === 'AttackUnitGameOver' || + actionResponse.type === 'PreviousTurnGameOver' + ? map.getPlayer(actionResponse.fromPlayer) + : map.getCurrentPlayer(); + return removePlayer( + map.copy({ + buildings: convertBuildings(map, fromPlayer, 0), + }), + fromPlayer, + ); + } + case 'CaptureGameOver': { + const fromPlayer = map.getPlayer(actionResponse.fromPlayer); + const toPlayer = map.getPlayer(actionResponse.toPlayer); + return updateCapture( + removePlayer( + map.copy({ + buildings: convertBuildings(map, fromPlayer, toPlayer), + units: deleteUnits(map, fromPlayer), + }), + fromPlayer, + ), + toPlayer, + ); + } + case 'GameEnd': + return map; + default: { + actionResponse satisfies never; + throw new UnknownTypeError('applyGameOverActionResponse', type); + } + } +} + +export function checkCapture( + previousMap: MapData, + map: MapData, + action: ActionResponse, +) { + if (action.type === 'Capture' && action.building && action.player) { + const fromPlayer = map.getPlayer(action.player); + const building = map.buildings.get(action.from); + const previousBuilding = previousMap.buildings.get(action.from); + if (previousBuilding?.info.isHQ() && building && !building.info.isHQ()) { + return { + fromPlayer: fromPlayer.id, + toPlayer: map.getPlayer(building).id, + type: 'CaptureGameOver', + } as const; + } + } + return null; +} + +export function checkAttackUnit( + map: MapData, + { + from, + hasCounterAttack, + playerA, + playerB, + to, + unitA, + unitB, + }: AttackUnitActionResponse, +) { + const fromPlayer = map.getPlayer(playerA); + const toPlayer = map.getPlayer(playerB); + return ( + (hasCounterAttack && (!unitA || playerA !== map.units.get(from)?.player) + ? checkHasUnits(map, fromPlayer, toPlayer) + : null) || + (playerB > 0 && (!unitB || playerB !== map.units.get(to)?.player) + ? checkHasUnits(map, toPlayer, fromPlayer) + : null) + ); +} + +export function checkToggleLightning( + previousMap: MapData, + map: MapData, + { from, player, to }: ToggleLightningActionResponse, +) { + const playerA = player || (from && previousMap.buildings.get(from)?.player); + const unitB = previousMap.units.get(to); + if (!playerA || !unitB) { + return null; + } + + return !map.units.has(to) + ? checkHasUnits( + map, + previousMap.getPlayer(unitB.player), + previousMap.getPlayer(playerA), + ) + : null; +} + +export function checkAttackBuilding( + map: MapData, + { + building, + from, + hasCounterAttack, + playerA, + playerC, + unitA, + unitC, + }: AttackBuildingActionResponse, +) { + if (playerC == null || playerC === 0) { + return null; + } + const toPlayer = map.getPlayer(playerC); + const fromPlayer = map.getPlayer(playerA); + return ( + (hasCounterAttack && (!unitA || playerA !== map.units.get(from)?.player) + ? checkHasUnits(map, fromPlayer, toPlayer) + : null) || + (!building && !unitC ? checkHasUnits(map, toPlayer, fromPlayer) : null) + ); +} + +const checkEndTurn = (previousMap: MapData, activeMap: MapData) => { + const previousPlayer = activeMap.getPlayer(previousMap.getCurrentPlayer().id); + if (previousPlayer.misses >= AllowedMisses) { + return { + fromPlayer: previousPlayer.id, + type: 'PreviousTurnGameOver', + } as const; + } + + const currentPlayer = activeMap.getCurrentPlayer(); + return hasUnits(previousMap, currentPlayer) && + !hasUnits(activeMap, currentPlayer) + ? ({ type: 'BeginTurnGameOver' } as const) + : null; +}; + +const checkHasUnits = (map: MapData, fromPlayer: Player, toPlayer: Player) => { + return !hasUnits(map, fromPlayer) + ? ({ + fromPlayer: fromPlayer.id, + toPlayer: toPlayer.id, + type: 'AttackUnitGameOver', + } as const) + : null; +}; + +const checkGameEnd = (map: MapData) => { + const teams = new Set(map.active.map((playerId) => map.getTeam(playerId))); + return teams.size === 1 + ? ({ + toPlayer: [...teams][0].players.first()!.id, + type: 'GameEnd', + } as const) + : null; +}; + +const convertBuildings = ( + map: MapData, + fromPlayer: Player, + toPlayer: Player | 0, +) => + map.buildings.map((building) => + map.matchesPlayer(building, fromPlayer) + ? building.capture(toPlayer) + : building, + ); + +const deleteUnits = (map: MapData, fromPlayer: Player) => + map.units.filter((unit) => !map.matchesPlayer(unit, fromPlayer)); + +const updateCapture = (map: MapData, toPlayer: Player) => + map.copy({ + units: map.units.map((unit, vector) => { + const building = map.buildings.get(vector); + return building && + unit.isCapturing() && + map.matchesPlayer(building, toPlayer) && + !map.isOpponent(unit, toPlayer) + ? unit.stopCapture() + : unit; + }), + }); + +const hasUnits = (map: MapData, player: Player) => + map.units.some((unit) => map.matchesPlayer(unit, player)); + +const removePlayer = (map: MapData, player: Player) => + map.copy({ + active: map.active.filter((playerId) => playerId !== player.id), + }); diff --git a/apollo/HiddenAction.tsx b/apollo/HiddenAction.tsx new file mode 100644 index 00000000..129e5292 --- /dev/null +++ b/apollo/HiddenAction.tsx @@ -0,0 +1,351 @@ +import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import Unit, { DryUnit } from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { AttackDirection } from './attack-direction/getAttackDirection.tsx'; + +export type HiddenMoveActionResponse = Readonly<{ + completed?: boolean; + fuel?: number; + path: ReadonlyArray; + type: 'HiddenMove'; + unit?: Unit; +}>; + +export type HiddenSourceAttackUnitActionResponse = Readonly<{ + chargeB?: number; + direction: AttackDirection; + hasCounterAttack: boolean; + newPlayerB?: PlayerID; + playerB: PlayerID; + to: Vector; + type: 'HiddenSourceAttackUnit'; + unitB?: DryUnit; + weapon?: number; +}>; + +export type HiddenTargetAttackUnitActionResponse = Readonly<{ + chargeA?: number; + direction: AttackDirection; + from: Vector; + hasCounterAttack: boolean; + newPlayerA?: PlayerID; + playerA: PlayerID; + type: 'HiddenTargetAttackUnit'; + unitA?: DryUnit; + weapon?: number; +}>; + +export type HiddenSourceAttackBuildingActionResponse = Readonly<{ + building?: Building; + chargeB?: number; + chargeC?: number; + direction: AttackDirection; + hasCounterAttack: boolean; + playerC?: PlayerID; + to: Vector; + type: 'HiddenSourceAttackBuilding'; + unitC?: DryUnit; + weapon?: number; +}>; + +export type HiddenTargetAttackBuildingActionResponse = Readonly<{ + chargeA?: number; + direction: AttackDirection; + from: Vector; + hasCounterAttack: boolean; + newPlayerA?: PlayerID; + playerA: PlayerID; + to?: Vector; + type: 'HiddenTargetAttackBuilding'; + unitA?: DryUnit; + weapon?: number; +}>; + +export type HiddenDestroyedBuildingActionResponse = Readonly<{ + to: Vector; + type: 'HiddenDestroyedBuilding'; +}>; + +export type HiddenFundAdjustmentActionResponse = Readonly<{ + funds: number; + type: 'HiddenFundAdjustment'; +}>; + +export type HiddenActionResponse = + | HiddenDestroyedBuildingActionResponse + | HiddenFundAdjustmentActionResponse + | HiddenMoveActionResponse + | HiddenSourceAttackBuildingActionResponse + | HiddenSourceAttackUnitActionResponse + | HiddenTargetAttackBuildingActionResponse + | HiddenTargetAttackUnitActionResponse; + +function applyHiddenMoveAction( + map: MapData, + vision: VisionT, + { completed, fuel, path, unit: unitA }: HiddenMoveActionResponse, +) { + const unit = unitA || map.units.get(path[0]); + if (!unit) { + return map; + } + + const from = path[0]; + const last = path.at(-1); + if (!last) { + return map; + } + const to = last; + const units = map.units.delete(from); + + if (unit && vision.isVisible(map, to)) { + const unitB = units.get(to); + const newUnit = fuel ? unit.move().setFuel(fuel) : unit.move(); + return map.copy({ + units: units.set( + to, + unitB + ? unitB.load(newUnit.transport()) + : !completed && unit.info.canAct(map.getPlayer(unit)) + ? newUnit + : newUnit.complete(), + ), + }); + } + + return map.copy({ + units: units.delete(to), + }); +} + +function applyHiddenSourceAttackUnitAction( + map: MapData, + { chargeB, newPlayerB, to, unitB }: HiddenSourceAttackUnitActionResponse, +) { + const existingUnit = map.units.get(to); + if (!existingUnit) { + return map; + } + + return map.copy({ + teams: + existingUnit.player > 0 + ? updatePlayer( + map.teams, + map + .getPlayer(existingUnit) + .modifyStatistics({ + lostUnits: unitB && newPlayerB == null ? 0 : 1, + }) + .maybeSetCharge(chargeB), + ) + : map.teams, + units: unitB + ? map.units.set( + to, + existingUnit + .copy(unitB) + .maybeUpdateAIBehavior() + .maybeSetPlayer(newPlayerB, 'complete'), + ) + : map.units.delete(to), + }); +} + +function applyHiddenSourceAttackBuildingAction( + map: MapData, + { + building, + chargeB, + chargeC, + to, + unitC, + }: HiddenSourceAttackBuildingActionResponse, +) { + const existingBuilding = map.buildings.get(to); + if (!existingBuilding) { + return map; + } + + // Update statistics first because `playerB` (building) might match `playerC` (unit). + if (existingBuilding.player > 0) { + map = map.copy({ + teams: updatePlayer( + map.teams, + map + .getPlayer(existingBuilding) + .modifyStatistics({ + lostBuildings: building ? 0 : 1, + }) + .maybeSetCharge(chargeB), + ), + }); + } + + const existingUnit = unitC && map.units.get(to); + if (!building) { + return map.copy({ + buildings: map.buildings.delete(to), + teams: + existingUnit && existingUnit.player > 0 + ? updatePlayer( + map.teams, + map + .getPlayer(existingUnit) + .modifyStatistics({ + lostUnits: 1, + }) + .maybeSetCharge(chargeC), + ) + : map.teams, + units: map.units.delete(to), + }); + } + + return map.copy({ + buildings: map.buildings.set( + to, + existingBuilding.setHealth(building.health), + ), + ...(existingUnit + ? { + units: map.units.set( + to, + existingUnit.copy(unitC).maybeUpdateAIBehavior(), + ), + } + : null), + }); +} + +function applyHiddenTargetAttackUnitAction( + map: MapData, + { chargeA, from, newPlayerA, unitA }: HiddenTargetAttackUnitActionResponse, +) { + const unit = map.units.get(from); + return unit + ? map.copy({ + teams: + unitA && newPlayerA == null + ? map.teams + : updatePlayer( + map.teams, + map + .getPlayer(unit) + .modifyStatistics({ + lostUnits: 1, + }) + .maybeSetCharge(chargeA), + ), + units: unitA + ? map.units.set( + from, + unit + .copy(unitA) + .maybeUpdateAIBehavior() + .complete() + .maybeSetPlayer(newPlayerA, 'recover'), + ) + : map.units.delete(from), + }) + : map; +} + +function applyHiddenTargetAttackBuildingAction( + map: MapData, + { + chargeA, + from, + newPlayerA, + to, + unitA, + }: HiddenTargetAttackBuildingActionResponse, +) { + const unit = map.units.get(from); + if (!unit) { + return map; + } + + const units = unitA + ? map.units.set( + from, + unit + .copy(unitA) + .maybeUpdateAIBehavior() + .complete() + .maybeSetPlayer(newPlayerA, 'recover'), + ) + : map.units.delete(from); + + if (to) { + return map.copy({ + buildings: map.buildings.delete(to), + units, + }); + } + + return map.copy({ + teams: + unitA && newPlayerA == null + ? map.teams + : updatePlayer( + map.teams, + map + .getPlayer(unit) + .modifyStatistics({ + lostUnits: 1, + }) + .maybeSetCharge(chargeA), + ), + units, + }); +} + +function applyHiddenDestroyedBuildingAction( + map: MapData, + { to }: HiddenDestroyedBuildingActionResponse, +) { + return map.copy({ + buildings: map.buildings.delete(to), + }); +} + +function applyHiddenFundAdjustmentAction( + map: MapData, + { funds }: HiddenFundAdjustmentActionResponse, +) { + return map.copy({ + teams: updatePlayer(map.teams, map.getCurrentPlayer().setFunds(funds)), + }); +} + +export function applyHiddenActionResponse( + map: MapData, + vision: VisionT, + actionResponse: HiddenActionResponse, +) { + switch (actionResponse.type) { + case 'HiddenMove': + return applyHiddenMoveAction(map, vision, actionResponse); + case 'HiddenSourceAttackUnit': + return applyHiddenSourceAttackUnitAction(map, actionResponse); + case 'HiddenTargetAttackUnit': + return applyHiddenTargetAttackUnitAction(map, actionResponse); + case 'HiddenSourceAttackBuilding': + return applyHiddenSourceAttackBuildingAction(map, actionResponse); + case 'HiddenTargetAttackBuilding': + return applyHiddenTargetAttackBuildingAction(map, actionResponse); + case 'HiddenDestroyedBuilding': + return applyHiddenDestroyedBuildingAction(map, actionResponse); + case 'HiddenFundAdjustment': + return applyHiddenFundAdjustmentAction(map, actionResponse); + default: { + const _exhaustiveCheck: never = actionResponse; + return _exhaustiveCheck; + } + } +} diff --git a/apollo/MapMetadata.tsx b/apollo/MapMetadata.tsx new file mode 100644 index 00000000..3a684a0e --- /dev/null +++ b/apollo/MapMetadata.tsx @@ -0,0 +1,10 @@ +import { Effects } from './Effects.tsx'; + +export type MapMetadata = Readonly<{ + effects?: Effects; + name: string; + rating?: number; + tags?: ReadonlyArray; + teamPlay: boolean; + totalRatings?: number; +}>; diff --git a/apollo/Types.tsx b/apollo/Types.tsx new file mode 100644 index 00000000..90e70a86 --- /dev/null +++ b/apollo/Types.tsx @@ -0,0 +1,57 @@ +import Building, { PlainBuilding } from '@deities/athena/map/Building.tsx'; +import { PlainEntitiesList } from '@deities/athena/map/PlainMap.tsx'; +import Unit, { PlainUnit } from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { ActionResponse } from './ActionResponse.tsx'; +import { Effects } from './Effects.tsx'; +import { EncodedActionResponse } from './EncodedActions.tsx'; + +export type GameStateEntry = readonly [ActionResponse, MapData]; +export type GameState = ReadonlyArray; +export type MutableGameState = Array; +export type GameStateWithEffects = ReadonlyArray< + readonly [...GameStateEntry, Effects] +>; + +export type EncodedGameActionResponseWithError = + | EncodedGameActionResponse + // Error + | { n: 'x' } + // Passthrough + | { n: 'p' }; + +export type EncodedGameActionResponseItem = [ + EncodedActionResponse, + PlainEntitiesList?, + PlainEntitiesList?, +]; + +export type EncodedGameActionResponse = [ + actionResponse: EncodedGameActionResponseItem | null, + actionResponseItems?: ReadonlyArray< + [ + EncodedActionResponse, + PlainEntitiesList?, + PlainEntitiesList?, + ] + >, + timeout?: number | null, +]; + +export type GameActionResponses = ReadonlyArray<{ + actionResponse: ActionResponse; + buildings?: ImmutableMap; + units?: ImmutableMap; +}>; + +export type GameActionResponse = { + others?: GameActionResponses | undefined; + self: { + actionResponse: ActionResponse; + buildings?: ImmutableMap; + units?: ImmutableMap; + } | null; + timeout?: number | null; +}; diff --git a/apollo/__tests__/Action.test.tsx b/apollo/__tests__/Action.test.tsx new file mode 100644 index 00000000..1a49b06e --- /dev/null +++ b/apollo/__tests__/Action.test.tsx @@ -0,0 +1,118 @@ +import { Factory } from '@deities/athena/info/Building.tsx'; +import { APU, Jeep, SmallTank } from '@deities/athena/info/Unit.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { expect, test } from 'vitest'; +import { + CreateUnitAction, + SupplyAction, +} from '../action-mutators/ActionMutators.tsx'; +import { execute } from '../Action.tsx'; +import { formatActionResponse } from '../FormatActions.tsx'; + +const initialMap = withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, 1, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 1000, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 1000, id: 2, userId: '4' }], + }, + ], + }), +); +const player1 = initialMap.getPlayer(1); +const vision = initialMap.createVisionObject(player1); + +test('supplying surrounding units', () => { + const from = vec(2, 2); + const to = vec(3, 2); + const map = initialMap.copy({ + units: initialMap.units + .set(from, Jeep.create(1)) + .set(to, SmallTank.create(1).setFuel(1)), + }); + const [response, newMap] = execute(map, vision, SupplyAction(from))!; + + expect( + formatActionResponse(response, { colors: false }), + ).toMatchInlineSnapshot('"Supply (2,2) { player: 1 }"'); + + const newUnit = newMap.units.get(to)!; + expect(newUnit).not.toEqual(map.units.get(to)); + expect(newUnit.fuel).toEqual(newUnit.info.configuration.fuel); +}); + +test('creating units', () => { + const to = vec(2, 2); + const map = initialMap.copy({ + buildings: initialMap.buildings.set(to, Factory.create(1)), + }); + const [response, newMap] = execute( + map, + vision, + CreateUnitAction(to, APU.id, to), + )!; + + expect( + formatActionResponse(response, { colors: false }), + ).toMatchInlineSnapshot( + '"CreateUnit (2,2 → 2,2) { unit: APU { id: 4, health: 100, player: 1, fuel: 40, ammo: [ [ 1, 6 ] ], moved: true, name: \'Nora\', completed: true }, free: false, skipBehaviorRotation: false }"', + ); + + const unit = APU.create(player1).complete(); + expect(newMap.units.get(to)!.withName(null)).toEqual(unit); + expect(newMap.getPlayer(1).funds < map.getPlayer(1).funds).toBe(true); + + const secondMap = map.copy({ + units: initialMap.units.set(to, APU.create(2)), + }); + + expect( + execute(secondMap, vision, CreateUnitAction(to, APU.id, to.left())), + ).toBe(null); +}); + +test('creating units with a friendly player on the building', () => { + const to = vec(2, 2); + const map = MapData.fromObject({ + ...initialMap.toJSON(), + teams: [ + { + id: 1, + name: '', + players: [ + { funds: 1000, id: 1, userId: '1' }, + { funds: 1000, id: 3, userId: '5' }, + ], + }, + { + id: 2, + name: '', + players: [{ funds: 1000, id: 2, userId: '4' }], + }, + ], + }).copy({ + buildings: initialMap.buildings.set(to, Factory.create(1)), + units: initialMap.units.set(to, APU.create(3)), + }); + const [response] = execute( + map, + vision, + CreateUnitAction(to, APU.id, to.left()), + )!; + + expect( + formatActionResponse(response, { colors: false }), + ).toMatchInlineSnapshot( + '"CreateUnit (2,2 → 1,2) { unit: APU { id: 4, health: 100, player: 1, fuel: 40, ammo: [ [ 1, 6 ] ], moved: true, name: \'Nora\', completed: true }, free: false, skipBehaviorRotation: false }"', + ); +}); diff --git a/apollo/__tests__/AttackBuilding.test.tsx b/apollo/__tests__/AttackBuilding.test.tsx new file mode 100644 index 00000000..d77f42b1 --- /dev/null +++ b/apollo/__tests__/AttackBuilding.test.tsx @@ -0,0 +1,68 @@ +import { House } from '@deities/athena/info/Building.tsx'; +import { APU, HeavyTank, Pioneer } from '@deities/athena/info/Unit.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import { HumanPlayer } from '@deities/athena/map/Player.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { expect, test } from 'vitest'; +import { AttackBuildingAction } from '../action-mutators/ActionMutators.tsx'; +import { execute } from '../Action.tsx'; + +const map = withModifiers( + MapData.createMap({ + buildings: [ + [1, 1, House.create(2).toJSON()], + [5, 5, House.create(2).toJSON()], + ], + map: [ + 8, 1, 3, 1, 3, 1, 1, 1, 3, 1, 1, 3, 1, 1, 1, 3, 1, 3, 1, 1, 2, 2, 2, 8, 8, + ], + size: { height: 5, width: 5 }, + teams: [ + { id: 1, name: '', players: [{ funds: 500, id: 1, userId: '1' }] }, + { id: 2, name: '', players: [{ funds: 500, id: 2, name: 'AI' }] }, + ], + units: [ + [1, 1, Pioneer.create(2).toJSON()], + [2, 1, HeavyTank.create(1).toJSON()], + [1, 2, HeavyTank.create(1).toJSON()], + [5, 5, APU.create(2).toJSON()], + [4, 5, HeavyTank.create(1).toJSON()], + [5, 4, HeavyTank.create(1).toJSON()], + ], + }), +); +const player1 = HumanPlayer.from(map.getPlayer(1), '1'); + +test('units do not disappear when a building is attacked and there is no counter attack', async () => { + const to = vec(1, 1); + const vision = map.createVisionObject(player1); + const [, mapState1] = execute( + map, + vision, + AttackBuildingAction(vec(2, 1), to), + )!; + const [, mapState2] = execute( + mapState1, + vision, + AttackBuildingAction(vec(1, 2), to), + )!; + expect(mapState1.units.get(to)).toEqual(Pioneer.create(2)); + expect(mapState2.units.get(to)).toBeUndefined(); + + const to2 = vec(5, 5); + const [, mapState3] = execute( + map, + vision, + AttackBuildingAction(vec(4, 5), to2), + )!; + const [, mapState4] = execute( + mapState3, + vision, + AttackBuildingAction(vec(5, 4), to2), + )!; + expect(mapState3.units.get(to2)).toEqual( + APU.create(2).subtractAmmo(APU.attack.weapons!.get(1)!, 1), + ); + expect(mapState4.units.get(to2)).toBeUndefined(); +}); diff --git a/apollo/__tests__/Skill.test.tsx b/apollo/__tests__/Skill.test.tsx new file mode 100644 index 00000000..ab487d9e --- /dev/null +++ b/apollo/__tests__/Skill.test.tsx @@ -0,0 +1,600 @@ +import { ResearchLab } from '@deities/athena/info/Building.tsx'; +import { Skill } from '@deities/athena/info/Skill.tsx'; +import { Forest, Forest2, RailTrack } from '@deities/athena/info/Tile.tsx'; +import { + BazookaBear, + Cannon, + Infantry, + Pioneer, + Saboteur, + SmallTank, + Sniper, + Zombie, +} from '@deities/athena/info/Unit.tsx'; +import { generateUnitName } from '@deities/athena/info/UnitNames.tsx'; +import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import { Charge, MaxCharges } from '@deities/athena/map/Configuration.tsx'; +import { HumanPlayer } from '@deities/athena/map/Player.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { expect, test } from 'vitest'; +import { + ActivatePowerAction, + AttackUnitAction, + CaptureAction, +} from '../action-mutators/ActionMutators.tsx'; +import { execute } from '../Action.tsx'; + +const map = withModifiers( + MapData.createMap({ + buildings: [[1, 1, ResearchLab.create(0).toJSON()]], + map: [1, 1, 1, 1, 1, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { id: 1, name: '', players: [{ funds: 500, id: 1, userId: '1' }] }, + { id: 2, name: '', players: [{ funds: 500, id: 2, name: 'AI' }] }, + ], + units: [ + [1, 1, Pioneer.create(1).capture().toJSON()], + [2, 1, SmallTank.create(1).toJSON()], + [3, 1, SmallTank.create(2).toJSON()], + [1, 2, SmallTank.create(1).toJSON()], + [2, 2, SmallTank.create(2).toJSON()], + ], + }), +); +const player1 = HumanPlayer.from(map.getPlayer(1), '1'); +const vision = map.createVisionObject(player1); + +const from = vec(2, 1); +const to = vec(3, 1); + +test('status effects from leaders are applied', async () => { + const leader = { + name: generateUnitName(true), + }; + const regular = { + name: generateUnitName(false), + }; + const [, state1] = execute( + map.copy({ + units: map.units + .set(from, SmallTank.create(1, leader)) + .set(to, SmallTank.create(2, regular)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const [, state2] = execute( + map.copy({ + units: map.units + .set(from, SmallTank.create(1, regular)) + .set(to, SmallTank.create(2, regular)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const [, state3] = execute( + map.copy({ + units: map.units + .set(from, SmallTank.create(1, leader)) + .set(to, SmallTank.create(2, leader)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const unitA1 = state1.units.get(from)!; + const unitB1 = state1.units.get(to)!; + + const unitA2 = state2.units.get(from)!; + const unitB2 = state2.units.get(to)!; + + const unitA3 = state3.units.get(from)!; + const unitB3 = state3.units.get(to)!; + + // A1 has more defense than A2. + expect(unitA1.health).toBeGreaterThan(unitA2.health); + // A1 has more attack than A2. + expect(unitB1.health).toBeLessThan(unitB2.health); + + // A2 and B2 are weaker, therefore dealing less damage than A3 and B3. + expect(unitA2.health).toBeGreaterThanOrEqual(unitA3.health); + expect(unitB2.health).toBeGreaterThan(unitB3.health); + + // A3 is attacked by a stronger unit than A1. + expect(unitA1.health).toBeGreaterThan(unitA3.health); + // B3 has higher defense than B1. + expect(unitB1.health).toBeLessThan(unitB3.health); +}); + +test('status effects from research labs are applied', async () => { + const [, state1] = execute(map, vision, AttackUnitAction(from, to))!; + const [, state2] = execute(state1, vision, CaptureAction(vec(1, 1)))!; + const [, state3] = execute( + state2, + vision, + AttackUnitAction(vec(1, 2), vec(2, 2)), + )!; + const unitB = state3.units.get(to)!; + const unitC = state3.units.get(vec(2, 2))!; + expect(unitB.health).toBeGreaterThan(unitC.health); + expect([unitB.format(), unitC.format()]).toMatchInlineSnapshot(` + [ + { + "ammo": [ + [ + 1, + 6, + ], + ], + "fuel": 30, + "health": 51, + "id": 5, + "player": 2, + }, + { + "ammo": [ + [ + 1, + 6, + ], + ], + "fuel": 30, + "health": 42, + "id": 5, + "player": 2, + }, + ] + `); +}); + +test('status effects from skills are applied', async () => { + const options = { + name: generateUnitName(false), + }; + const [, state1] = execute( + map.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(1).copy({ skills: new Set([Skill.AttackIncreaseMinor]) }), + ), + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const [, state2] = execute( + map.copy({ + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const [, state3] = execute( + map.copy({ + teams: updatePlayer( + map.teams, + map + .getPlayer(2) + .copy({ skills: new Set([Skill.DefenseIncreaseMinor]) }), + ), + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const unitB1 = state1.units.get(to)!; + const unitBDefault = state2.units.get(to)!; + const unitB3 = state3.units.get(to)!; + + // A1 has more attack than A2. + expect(unitB1.health).toBeLessThan(unitBDefault.health); + + // B3 has higher defense than B1. + expect(unitB3.health).toBeGreaterThan(unitBDefault.health); +}); + +test('status effects from skills can increase and decrease attack or defense', async () => { + const options = { + name: generateUnitName(false), + }; + const initialMap = map.copy({ + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }); + const initialMapWithSkills = initialMap.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(1).copy({ + skills: new Set([Skill.AttackIncreaseMajorDefenseDecreaseMinor]), + }), + ), + }); + + const [, defaultAttackAtoB] = execute( + initialMap, + vision, + AttackUnitAction(from, to), + )!; + const [, defaultAttackBtoA] = execute( + initialMap.copy({ + currentPlayer: 2, + }), + vision, + AttackUnitAction(to, from), + )!; + + const [, skillAttackAtoB] = execute( + initialMapWithSkills, + vision, + AttackUnitAction(from, to), + )!; + const [, skillAttackBtoA] = execute( + initialMapWithSkills.copy({ + currentPlayer: 2, + }), + vision, + AttackUnitAction(to, from), + )!; + + // A with skill has more attack than without. + expect(skillAttackAtoB.units.get(to)!.health).toBeLessThan( + defaultAttackAtoB.units.get(to)!.health, + ); + + // But A with skill also has lower defense. + expect(skillAttackBtoA.units.get(from)!.health).toBeLessThan( + defaultAttackBtoA.units.get(from)!.health, + ); +}); + +test('status effects can increase defense on specific tiles', async () => { + const options = { + name: generateUnitName(false), + }; + + const newMapA = map.map.slice(); + const newMapB = map.map.slice(); + newMapA[map.getTileIndex(to)] = Forest.id; + newMapB[map.getTileIndex(to)] = Forest2.id; + const initialMap = map.copy({ + map: newMapA, + units: map.units + .set(from, Infantry.create(1, options)) + .set(to, Saboteur.create(2, options)), + }); + const mapWithSkill = initialMap.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(2).copy({ + skills: new Set([Skill.UnitInfantryForestDefenseIncrease]), + }), + ), + }); + const mapWithForestVariant = initialMap.copy({ + map: newMapB, + teams: updatePlayer( + map.teams, + map.getPlayer(2).copy({ + skills: new Set([Skill.UnitInfantryForestDefenseIncrease]), + }), + ), + }); + + const [, defaultAttackAtoB] = execute( + initialMap, + vision, + AttackUnitAction(from, to), + )!; + + const [, skillAttackAtoB] = execute( + mapWithSkill, + vision, + AttackUnitAction(from, to), + )!; + + const [, forestVariantAttackAtoB] = execute( + mapWithForestVariant, + vision, + AttackUnitAction(from, to), + )!; + + // Player 2 with skill has more defense than without. + expect(skillAttackAtoB.units.get(to)!.health).toBeGreaterThan( + defaultAttackAtoB.units.get(to)!.health, + ); + + // This skill is also applied to Forest variants. + expect(skillAttackAtoB.units.get(to)!.health).toEqual( + forestVariantAttackAtoB.units.get(to)!.health, + ); +}); + +test('status effects can increase attack on specific tiles', async () => { + const options = { + name: generateUnitName(false), + }; + const newMap = map.map.slice(); + newMap[map.getTileIndex(from)] = RailTrack.id; + + const initialMap = map.copy({ + map: newMap, + units: map.units + .set(from, Infantry.create(1, options)) + .set(to, Saboteur.create(2, options)), + }); + + const initialMapWithSkills = initialMap.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(1).copy({ + activeSkills: new Set([ + Skill.UnitRailDefenseIncreasePowerAttackIncrease, + ]), + }), + ), + }); + + const [, defaultAttackAtoB] = execute( + initialMap, + vision, + AttackUnitAction(from, to), + )!; + + const [, skillAttackAtoB] = execute( + initialMapWithSkills, + vision, + AttackUnitAction(from, to), + )!; + + // A with skill has more attack than without. + expect(skillAttackAtoB.units.get(to)!.health).toBeLessThan( + defaultAttackAtoB.units.get(to)!.health, + ); +}); + +test('status effects from skills may lower unit costs', async () => { + const player = map.getPlayer(1); + const playerWithSkill = player.copy({ + skills: new Set([Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor]), + }); + + expect(SmallTank.getCostFor(player)).toBeGreaterThan( + SmallTank.getCostFor(playerWithSkill), + ); + + const playerWithCannon = player.copy({ + skills: new Set([Skill.BuyUnitCannon]), + }); + expect(Cannon.getCostFor(player)).toBeGreaterThan( + Cannon.getCostFor(playerWithCannon), + ); + + // Skills can also be stacked. + const playerWithCannonAndCostSkills = player.copy({ + skills: new Set([ + Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, + Skill.BuyUnitCannon, + ]), + }); + expect(Cannon.getCostFor(playerWithCannon)).toBeGreaterThan( + Cannon.getCostFor(playerWithCannonAndCostSkills), + ); +}); + +test('snipers can attack without unfolding', async () => { + const player = map.getPlayer(1); + const playerWithSkill = player.copy({ + skills: new Set([Skill.UnitAbilitySniperImmediateAction]), + }); + + const unit = Sniper.create(1); + expect(unit.canAttack(player)).toBeFalsy(); + expect(unit.unfold().canAttack(player)).toBeTruthy(); + + expect(unit.canAttack(playerWithSkill)).toBeTruthy(); + expect(unit.unfold().canAttack(playerWithSkill)).toBeTruthy(); +}); + +test('skills can extend the range of units', async () => { + const skills = new Set([Skill.MovementIncreaseGroundUnitDefenseDecrease]); + const player = map.getPlayer(1); + const playerWithSkill = player.copy({ + skills, + }); + const playerWithActiveSkill = player.copy({ + activeSkills: skills, + skills, + }); + + expect(SmallTank.getRadiusFor(player1)).toBeLessThan( + SmallTank.getRadiusFor(playerWithSkill), + ); + + expect(Pioneer.getRadiusFor(player1)).toEqual( + Pioneer.getRadiusFor(playerWithSkill), + ); + + expect(SmallTank.getRadiusFor(playerWithActiveSkill)).toBeGreaterThan( + SmallTank.getRadiusFor(playerWithSkill), + ); +}); + +test('activating a power adds the active status effect of a power', () => { + const skills = new Set([Skill.AttackIncreaseMinor]); + const options = { + name: generateUnitName(false), + }; + const [, state1] = execute( + map.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(1).copy({ activeSkills: skills, skills }), + ), + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const [, state2] = execute( + map.copy({ + teams: updatePlayer(map.teams, map.getPlayer(1).copy({ skills })), + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + const unitB1 = state1.units.get(to)!; + const unitBDefault = state2.units.get(to)!; + + // A1 has more attack than A2. + expect(unitB1.health).toBeLessThan(unitBDefault.health); +}); + +test(`cannot activate a skill that the player doesn't own or if the player doesn't have enough charges`, () => { + const skills = new Set([Skill.AttackIncreaseMinor]); + const mapA = map.copy({ + teams: updatePlayer(map.teams, map.getPlayer(1).copy({ skills })), + }); + + // Player does not own this skill. + expect( + execute( + mapA, + vision, + ActivatePowerAction(Skill.AttackIncreaseMajorDefenseDecreaseMinor), + ), + ).toBe(null); + + // Player does not have enough charges. + expect( + execute(mapA, vision, ActivatePowerAction(Skill.AttackIncreaseMinor)), + ).toBe(null); + + // Skill is already activated. + expect( + execute( + mapA.copy({ + teams: updatePlayer( + mapA.teams, + mapA + .getPlayer(1) + .copy({ activeSkills: skills, charge: Charge * MaxCharges }), + ), + }), + vision, + ActivatePowerAction(Skill.AttackIncreaseMinor), + ), + ).toBe(null); + + // It works when the player has enough charges: + const [actionResponse] = execute( + mapA.copy({ + teams: updatePlayer( + mapA.teams, + mapA.getPlayer(1).copy({ charge: Charge * MaxCharges }), + ), + }), + vision, + ActivatePowerAction(Skill.AttackIncreaseMinor), + )!; + + expect(actionResponse.type).toBe('ActivatePower'); + if (actionResponse.type !== 'ActivatePower') { + throw new Error(`Expected 'actionResponse' type to be 'ActivatePower'.`); + } + + expect(actionResponse.skill).toBe(Skill.AttackIncreaseMinor); +}); + +test('can enable some units and disable others', async () => { + const player = map.getPlayer(1); + const playerWithSkill = player.copy({ + skills: new Set([Skill.BuyUnitZombieDefenseDecreaseMajor]), + }); + + expect(Zombie.getCostFor(player)).toBe(Number.POSITIVE_INFINITY); + expect(Zombie.getCostFor(playerWithSkill)).toBeLessThan( + Number.POSITIVE_INFINITY, + ); + + expect(Pioneer.getCostFor(player)).toBeLessThan(Number.POSITIVE_INFINITY); + expect(Pioneer.getCostFor(playerWithSkill)).toBe(Number.POSITIVE_INFINITY); +}); + +test('can modify the range of units using a skill', async () => { + const player = map.getPlayer(1); + const skills = new Set([Skill.BuyUnitBazookaBear]); + const playerWithSkill = player.copy({ + skills, + }); + const playerWithActiveSkill = player.copy({ + activeSkills: skills, + skills, + }); + + expect(BazookaBear.getRangeFor(player)).toMatchInlineSnapshot(` + [ + 1, + 2, + ] + `); + expect(BazookaBear.getRangeFor(playerWithSkill)).toMatchInlineSnapshot(` + [ + 1, + 2, + ] + `); + expect(BazookaBear.getRangeFor(playerWithActiveSkill)).toMatchInlineSnapshot(` + [ + 1, + 3, + ] + `); +}); + +test('the counter attack skill makes counter attacks more powerful', () => { + const skills = new Set([Skill.CounterAttackPower]); + const options = { + name: generateUnitName(false), + }; + const [, state1] = execute(map, vision, AttackUnitAction(from, to))!; + const [, state2] = execute( + map.copy({ + teams: updatePlayer( + map.teams, + map.getPlayer(2).copy({ activeSkills: skills, skills }), + ), + units: map.units + .set(from, SmallTank.create(1, options)) + .set(to, SmallTank.create(2, options)), + }), + vision, + AttackUnitAction(from, to), + )!; + + expect(state1.units.get(from)!.health).toBeGreaterThan( + state1.units.get(to)!.health, + ); + expect(state2.units.get(from)!.health).toEqual(state2.units.get(to)!.health); +}); diff --git a/apollo/__tests__/Supply.test.tsx b/apollo/__tests__/Supply.test.tsx new file mode 100644 index 00000000..1230c68c --- /dev/null +++ b/apollo/__tests__/Supply.test.tsx @@ -0,0 +1,114 @@ +import { + AmphibiousTank, + Battleship, + Bomber, + FighterJet, + Frigate, + Helicopter, + Infantry, + Jeep, + PatrolShip, + SmallTank, + SupportShip, + TransportHelicopter, +} from '@deities/athena/info/Unit.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { expect, test } from 'vitest'; +import { SupplyAction } from '../action-mutators/ActionMutators.tsx'; +import { execute } from '../Action.tsx'; + +const initialMap = withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, 1, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 1000, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 1000, id: 2, userId: '4' }], + }, + ], + }), +); +const player1 = initialMap.getPlayer(1); +const vision = initialMap.createVisionObject(player1); + +test('supply surrounding units with a Jeep', () => { + const from = vec(2, 2); + const toA = vec(1, 2); + const toB = vec(2, 1); + const toC = vec(3, 2); + const toD = vec(2, 3); + const map = initialMap.copy({ + units: initialMap.units + .set(from, Jeep.create(1)) + .set(toA, SmallTank.create(1).setFuel(1)) + .set(toB, AmphibiousTank.create(1).setFuel(1)) + .set(toC, PatrolShip.create(1).setFuel(1)) + .set(toD, Infantry.create(1).setFuel(1)), + }); + const [, newMap] = execute(map, vision, SupplyAction(from))!; + + for (const to of [toA, toB, toC, toD]) { + const newUnit = newMap.units.get(to)!; + expect(map.units.get(to)!.fuel).toBeLessThan(newUnit.fuel); + expect(newUnit.fuel).toEqual(newUnit.info.configuration.fuel); + } +}); + +test('supply surrounding units with a Transport Chopper', () => { + const from = vec(2, 2); + const toA = vec(1, 2); + const toB = vec(2, 1); + const toC = vec(3, 2); + const toD = vec(2, 3); + const map = initialMap.copy({ + units: initialMap.units + .set(from, TransportHelicopter.create(1)) + .set(toA, Helicopter.create(1).setFuel(1)) + .set(toB, SmallTank.create(1).setFuel(1)) + .set(toC, FighterJet.create(1).setFuel(1)) + .set(toD, Bomber.create(1).setFuel(1)), + }); + const [, newMap] = execute(map, vision, SupplyAction(from))!; + + for (const to of [toA, toC, toD]) { + const newUnit = newMap.units.get(to)!; + expect(map.units.get(to)!.fuel).toBeLessThan(newUnit.fuel); + expect(newUnit.fuel).toEqual(newUnit.info.configuration.fuel); + } + + expect(map.units.get(toB)!.fuel).toEqual(newMap.units.get(toB)!.fuel); +}); + +test('supply surrounding units with a Support Ship', () => { + const from = vec(2, 2); + const toA = vec(1, 2); + const toB = vec(2, 1); + const toC = vec(3, 2); + const toD = vec(2, 3); + const map = initialMap.copy({ + units: initialMap.units + .set(from, SupportShip.create(1)) + .set(toA, Helicopter.create(1).setFuel(1)) + .set(toB, SmallTank.create(1).setFuel(1)) + .set(toC, Frigate.create(1).setFuel(1)) + .set(toD, Battleship.create(1).setFuel(1)), + }); + const [, newMap] = execute(map, vision, SupplyAction(from))!; + + for (const to of [toB, toC, toD]) { + const newUnit = newMap.units.get(to)!; + expect(map.units.get(to)!.fuel).toBeLessThan(newUnit.fuel); + expect(newUnit.fuel).toEqual(newUnit.info.configuration.fuel); + } + + expect(map.units.get(toA)!.fuel).toEqual(newMap.units.get(toA)!.fuel); +}); diff --git a/apollo/action-mutators/ActionMutators.tsx b/apollo/action-mutators/ActionMutators.tsx new file mode 100644 index 00000000..84a66ae9 --- /dev/null +++ b/apollo/action-mutators/ActionMutators.tsx @@ -0,0 +1,151 @@ +import { Skill } from '@deities/athena/info/Skill.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; + +export const MoveAction = ( + from: Vector, + to: Vector, + path?: ReadonlyArray | null, + complete?: boolean, +) => + ({ + ...(complete ? { complete } : null), + from, + ...(path ? { path } : null), + to, + type: 'Move', + }) as const; + +export const AttackUnitAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'AttackUnit', + }) as const; + +export const AttackBuildingAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'AttackBuilding', + }) as const; + +export const CaptureAction = (from: Vector) => + ({ + from, + type: 'Capture', + }) as const; + +export const SupplyAction = (from: Vector) => + ({ + from, + type: 'Supply', + }) as const; + +export const CreateUnitAction = (from: Vector, id: number, to: Vector) => + ({ + from, + id, + to, + type: 'CreateUnit', + }) as const; + +export const DropUnitAction = (from: Vector, index: number, to: Vector) => + ({ + from, + index, + to, + type: 'DropUnit', + }) as const; + +export const CreateBuildingAction = (from: Vector, id: number) => + ({ + from, + id, + type: 'CreateBuilding', + }) as const; + +export const CreateTracksAction = (from: Vector) => + ({ + from, + type: 'CreateTracks', + }) as const; + +export const FoldAction = (from: Vector) => + ({ + from, + type: 'Fold', + }) as const; + +export const UnfoldAction = (from: Vector) => + ({ + from, + type: 'Unfold', + }) as const; + +export const CompleteUnitAction = (from: Vector) => + ({ + from, + type: 'CompleteUnit', + }) as const; + +export const CompleteBuildingAction = (from: Vector) => + ({ + from, + type: 'CompleteBuilding', + }) as const; + +export const EndTurnAction = () => ({ type: 'EndTurn' }) as const; + +export const MessageAction = (message: string, player?: PlayerID) => + ({ + message, + player, + type: 'Message', + }) as const; + +export const ToggleLightningAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'ToggleLightning', + }) as const; + +export const HealAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'Heal', + }) as const; + +export const RescueAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'Rescue', + }) as const; + +export const SabotageAction = (from: Vector, to: Vector) => + ({ + from, + to, + type: 'Sabotage', + }) as const; + +export const StartAction = () => + ({ + type: 'Start', + }) as const; + +export const BuySkillAction = (from: Vector, skill: Skill) => + ({ + from, + skill, + type: 'BuySkill', + }) as const; + +export const ActivatePowerAction = (skill: Skill) => + ({ + skill, + type: 'ActivatePower', + }) as const; diff --git a/apollo/actions/applyActionResponse.tsx b/apollo/actions/applyActionResponse.tsx new file mode 100644 index 00000000..0614f0c5 --- /dev/null +++ b/apollo/actions/applyActionResponse.tsx @@ -0,0 +1,556 @@ +import { applyPower, getSkillConfig } from '@deities/athena/info/Skill.tsx'; +import { RailBridge, RailTrack, River } from '@deities/athena/info/Tile.tsx'; +import getActivePlayers from '@deities/athena/lib/getActivePlayers.tsx'; +import getHealCost from '@deities/athena/lib/getHealCost.tsx'; +import getUnitsToRefill from '@deities/athena/lib/getUnitsToRefill.tsx'; +import maybeConvertPlayer from '@deities/athena/lib/maybeConvertPlayer.tsx'; +import mergeTeams from '@deities/athena/lib/mergeTeams.tsx'; +import refillUnits from '@deities/athena/lib/refillUnits.tsx'; +import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; +import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; +import verifyTiles from '@deities/athena/lib/verifyTiles.tsx'; +import { + Charge, + CreateTracksCost, + HealAmount, + MaxHealth, +} from '@deities/athena/map/Configuration.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import toggleLightningTile from '@deities/athena/mutation/toggleLightningTile.tsx'; +import writeTile from '@deities/athena/mutation/writeTile.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; +import { applyGameOverActionResponse } from '../GameOver.tsx'; +import { applyHiddenActionResponse } from '../HiddenAction.tsx'; +import applyEndTurnActionResponse from './applyEndTurnActionResponse.tsx'; + +export default function applyActionResponse( + map: MapData, + vision: VisionT, + actionResponse: ActionResponse, +): MapData { + const { type } = actionResponse; + + const currentPlayer = map.getCurrentPlayer(); + if ( + currentPlayer.misses > 0 && + (type !== 'EndTurn' || + (actionResponse.current.player === currentPlayer.id && + !actionResponse.miss)) + ) { + map = map.copy({ + teams: updatePlayer(map.teams, currentPlayer.copy({ misses: 0 })), + }); + } + + switch (type) { + case 'Move': { + const { completed, from, fuel, to } = actionResponse; + let unitA = map.units.get(from); + const units = map.units.delete(from); + const unitB = units.get(to); + const canAct = !completed && unitA?.info.canAct(map.getPlayer(unitA)); + if (unitA) { + unitA = unitA.setFuel(fuel).move(); + } + return map.copy({ + units: unitA + ? units.set( + to, + unitB + ? unitB.load(unitA.transport()) + : canAct + ? unitA + : unitA.complete(), + ) + : map.units, + }); + } + case 'AttackUnit': { + const { + chargeA, + chargeB, + from, + hasCounterAttack, + playerA, + playerB, + to, + unitA, + unitB, + } = actionResponse; + let { units } = map; + const originalUnitA = map.units.get(from); + const originalUnitB = map.units.get(to); + + units = + unitA && originalUnitA + ? units.set( + from, + maybeConvertPlayer( + originalUnitA.copy(unitA).maybeUpdateAIBehavior().complete(), + hasCounterAttack ? originalUnitB : null, + 'recover', + ), + ) + : units.delete(from); + units = + unitB && originalUnitB + ? units.set( + to, + maybeConvertPlayer( + originalUnitB.copy(unitB).maybeUpdateAIBehavior(), + originalUnitA, + 'complete', + ), + ) + : units.delete(to); + + const lostUnits = + unitA && + originalUnitA && + units.get(from)?.player === originalUnitA?.player + ? 0 + : 1; + const destroyedUnits = + unitB && + originalUnitB && + units.get(to)?.player === originalUnitB?.player + ? 0 + : 1; + return map.copy({ + teams: updatePlayers(map.teams, [ + map + .getPlayer(playerA) + .modifyStatistics({ + damage: originalUnitB + ? Math.max(0, originalUnitB.health - (unitB?.health || 0)) + : 0, + destroyedUnits, + lostUnits, + }) + .maybeSetCharge(chargeA), + playerB !== 0 + ? map + .getPlayer(playerB) + .modifyStatistics({ + damage: Math.max( + 0, + hasCounterAttack && originalUnitA + ? originalUnitA.health - (unitA?.health || 0) + : 0, + ), + destroyedUnits: lostUnits, + lostUnits: destroyedUnits, + }) + .maybeSetCharge(chargeB) + : null, + ]), + units, + }); + } + case 'AttackBuilding': { + const { + building, + chargeA, + chargeB, + chargeC, + from, + hasCounterAttack, + playerA, + playerC, + to, + unitA, + unitC, + } = actionResponse; + let { units } = map; + const originalUnitA = map.units.get(from); + const originalBuilding = map.buildings.get(to); + const originalUnitC = map.units.get(to); + units = + unitA && originalUnitA + ? units.set( + from, + maybeConvertPlayer( + originalUnitA.copy(unitA).maybeUpdateAIBehavior().complete(), + hasCounterAttack ? originalUnitC : null, + 'recover', + ), + ) + : units.delete(from); + units = + unitC && originalUnitC + ? units.set(to, originalUnitC.copy(unitC).maybeUpdateAIBehavior()) + : !building + ? units.delete(to) + : units; + + const lostUnits = + unitA && + originalUnitA && + units.get(from)?.player === originalUnitA?.player + ? 0 + : 1; + // Update `playerA` and `playerB` first, then update `playerC` which might equal `playerB`. + const teams = originalBuilding + ? updatePlayers(map.teams, [ + map + .getPlayer(playerA) + .modifyStatistics({ + damage: Math.max( + 0, + originalBuilding.health - (building?.health || 0), + ), + destroyedBuildings: building ? 0 : 1, + lostUnits, + }) + .maybeSetCharge(chargeA), + originalBuilding.player > 0 + ? map + .getPlayer(originalBuilding.player) + .modifyStatistics({ + lostBuildings: building ? 0 : 1, + }) + .maybeSetCharge(chargeB) + : null, + ]) + : map.teams; + + return map.copy({ + buildings: building + ? map.buildings.set(to, building) + : map.buildings.delete(to), + teams: + originalUnitC && originalUnitC.player > 0 && playerC + ? updatePlayer( + teams, + map + .getPlayer(playerC) + .modifyStatistics({ + damage: + hasCounterAttack && originalUnitA + ? Math.max( + 0, + originalUnitA.health - (unitA?.health || 0), + ) + : 0, + destroyedUnits: lostUnits, + lostUnits: unitC ? 0 : 1, + }) + .maybeSetCharge(chargeC), + ) + : teams, + units, + }); + } + case 'Capture': { + const { building, from } = actionResponse; + const unit = map.units.get(from); + return building + ? map.copy({ + buildings: map.buildings.set( + from, + building.setHealth(MaxHealth).complete(), + ), + teams: updatePlayer( + map.teams, + map.getPlayer(building.player).modifyStatistics({ + captured: 1, + }), + ), + units: unit + ? map.units.set(from, unit.move().complete()) + : map.units, + }) + : map.copy({ + units: unit + ? map.units.set(from, unit.capture().complete()) + : map.units, + }); + } + case 'Supply': { + const { from, player } = actionResponse; + const unit = map.units.get(from); + const units = unit ? map.units.set(from, unit.complete()) : map.units; + return map.copy({ + units: refillUnits( + map.copy({ units }), + getUnitsToRefill(map, vision, map.getPlayer(player), from), + ).units, + }); + } + case 'CreateUnit': { + const { free, from, skipBehaviorRotation, to, unit } = actionResponse; + const building = map.buildings.get(from)!; + const player = map.getPlayer(unit); + return map.copy({ + buildings: map.buildings.set( + from, + (skipBehaviorRotation + ? building + : building.rotateAIBehavior() + ).complete(), + ), + teams: updatePlayer( + map.teams, + player + .modifyFunds(free ? 0 : -unit.info.getCostFor(player)) + .modifyStatistic('createdUnits', 1), + ), + units: map.units.set(to, unit), + }); + } + case 'DropUnit': { + const { from, index, to } = actionResponse; + const unitA = map.units.get(from); + const unitB = unitA?.getTransportedUnit(index); + return map.copy({ + units: + unitA && unitB + ? map.units + .set(from, unitA.move().drop(unitB)) + .set(to, unitB.deploy()) + : map.units, + }); + } + case 'CreateBuilding': { + const { building, from } = actionResponse; + const teams = map.isNeutral(building) + ? map.teams + : updatePlayer( + map.teams, + map + .getPlayer(building) + .modifyFunds(-building.info.configuration.cost) + .modifyStatistic('createdBuildings', 1), + ); + return map.copy({ + buildings: map.buildings.set(from, building), + teams, + units: map.units.delete(from), + }); + } + case 'CreateTracks': { + const { from } = actionResponse; + const newMap = map.map.slice(); + const newModifiers = map.modifiers.slice(); + + const unit = map.units.get(from); + const tile = map.getTileInfo(from); + writeTile( + newMap, + newModifiers, + map.getTileIndex(from), + tile === River ? RailBridge : RailTrack, + 0, + ); + return verifyTiles( + map.copy({ + map: newMap, + modifiers: newModifiers, + teams: unit + ? updatePlayer( + map.teams, + map.getPlayer(unit.player).modifyFunds(-CreateTracksCost), + ) + : map.teams, + units: unit ? map.units.set(from, unit.complete()) : map.units, + }), + new Set(from.expandWithDiagonals()), + ); + } + case 'Fold': { + const { from } = actionResponse; + const unit = map.units.get(from); + return unit + ? map.copy({ + units: map.units.set(from, unit.fold().complete()), + }) + : map; + } + case 'Unfold': { + const { from } = actionResponse; + const unit = map.units.get(from); + return unit + ? map.copy({ + units: map.units.set(from, unit.unfold().complete()), + }) + : map; + } + case 'CompleteUnit': { + const { from } = actionResponse; + const unit = map.units.get(from); + return unit + ? map.copy({ units: map.units.set(from, unit.complete()) }) + : map; + } + case 'CompleteBuilding': { + const { from } = actionResponse; + const building = map.buildings.get(from)!; + return map.copy({ + buildings: map.buildings.set(from, building.complete()), + }); + } + case 'EndTurn': + return applyEndTurnActionResponse(map, actionResponse); + case 'Heal': { + const { from, to } = actionResponse; + const unitA = from && map.units.get(from); + const unitB = map.units.get(to)!; + const player = map.getPlayer(unitB); + const units = map.units.set(to, unitB.modifyHealth(HealAmount)); + return map.copy({ + teams: updatePlayer( + map.teams, + player.modifyFunds(-getHealCost(unitB, player)), + ), + units: unitA ? units.set(from, unitA.complete()) : units, + }); + } + case 'Message': + case 'CharacterMessage': + return map; + case 'MoveUnit': { + const { from } = actionResponse; + const unit = map.units.get(from)!; + return map.copy({ units: map.units.set(from, unit.move()) }); + } + case 'Rescue': { + const { from, player, to } = actionResponse; + const unitA = from && map.units.get(from)!; + const unitB = map.units.get(to)!; + const units = map.units.set( + to, + unitB.isBeingRescuedBy(player) + ? unitB + .stopBeingRescued() + .setPlayer(player) + .setHealth(MaxHealth) + .recover() + : unitB.rescue(player), + ); + return map.copy({ + units: unitA ? units.set(from, unitA.complete()) : units, + }); + } + case 'Sabotage': { + const { from, to } = actionResponse; + const unitA = from && map.units.get(from)!; + const unitB = map.units.get(to)!; + const units = map.units.set(to, unitB.sabotage()); + return map.copy({ + units: unitA ? units.set(from, unitA.complete()) : units, + }); + } + case 'Spawn': { + const { teams, units } = actionResponse; + const newMap = mergeTeams(map, teams).copy({ + units: map.units.merge(units), + }); + return newMap.copy({ + active: getActivePlayers(newMap), + }); + } + case 'ToggleLightning': { + const { from, player: playerID, to } = actionResponse; + const building = from && map.buildings.get(from); + const unit = map.units.get(to); + const player = map.getPlayer((playerID || building?.player)!); + return toggleLightningTile(map, to).copy({ + buildings: building + ? map.buildings.set(from, building.complete()) + : undefined, + teams: updatePlayers(map.teams, [ + player.setCharge(player.charge - Charge).modifyStatistics({ + damage: unit ? unit.health : 0, + destroyedUnits: unit ? 1 : 0, + }), + unit + ? map.getPlayer(unit.player).modifyStatistic('lostUnits', 1) + : null, + ]), + units: unit ? map.units.delete(to) : undefined, + }); + } + case 'HiddenFundAdjustment': + case 'HiddenMove': + case 'HiddenSourceAttackBuilding': + case 'HiddenSourceAttackUnit': + case 'HiddenDestroyedBuilding': + case 'HiddenTargetAttackBuilding': + case 'HiddenTargetAttackUnit': + return applyHiddenActionResponse(map, vision, actionResponse); + case 'AttackUnitGameOver': + case 'BeginTurnGameOver': + case 'CaptureGameOver': + case 'PreviousTurnGameOver': + case 'GameEnd': + return applyGameOverActionResponse(map, actionResponse); + case 'SetViewer': { + const currentPlayer = map.maybeGetPlayer(vision.currentViewer)?.id; + return currentPlayer + ? map.copy({ + currentPlayer, + }) + : map; + } + case 'BuySkill': { + const { from, player, skill } = actionResponse; + const building = map.buildings.get(from); + const playerA = map.getPlayer(player); + const { cost } = getSkillConfig(skill); + return map.copy({ + buildings: + building && vision.isVisible(map, from) + ? map.buildings.set(from, building.complete()) + : map.buildings, + teams: updatePlayer( + map.teams, + playerA.modifyFunds(cost != null ? -cost : 0).copy({ + skills: new Set([...playerA.skills, skill]), + }), + ), + }); + } + case 'ActivatePower': { + const { skill } = actionResponse; + const playerA = map.getCurrentPlayer(); + const { charges } = getSkillConfig(skill); + + return applyPower( + skill, + map.copy({ + teams: updatePlayer( + map.teams, + playerA + .activateSkill(skill) + .setCharge(playerA.charge - (charges || 0) * Charge), + ), + }), + ); + } + case 'ReceiveReward': { + const { player, reward } = actionResponse; + if (reward.type === 'skill') { + const playerA = map.getPlayer(player); + return map.copy({ + teams: updatePlayer( + map.teams, + playerA.copy({ + skills: new Set([...playerA.skills, reward.skill]), + }), + ), + }); + } + return map; + } + case 'BeginGame': + case 'SecretDiscovered': + case 'Start': + return map; + default: { + actionResponse satisfies never; + throw new UnknownTypeError('applyActionResponse', type); + } + } +} diff --git a/apollo/actions/applyEndTurnActionResponse.tsx b/apollo/actions/applyEndTurnActionResponse.tsx new file mode 100644 index 00000000..682a3261 --- /dev/null +++ b/apollo/actions/applyEndTurnActionResponse.tsx @@ -0,0 +1,79 @@ +import getUnitsToHealOnBuildings from '@deities/athena/lib/getUnitsToHealOnBuildings.tsx'; +import shouldRemoveUnit from '@deities/athena/lib/shouldRemoveUnit.tsx'; +import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; +import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; +import { HealAmount } from '@deities/athena/map/Configuration.tsx'; +import { + Bot, + HumanPlayer, + isHumanPlayer, + resolveDynamicPlayerID, +} from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { EndTurnActionResponse } from '../ActionResponse.tsx'; +import getColorName from '../lib/getColorName.tsx'; +import nameGenerator from '../lib/nameGenerator.tsx'; + +const generateName = nameGenerator(); + +export default function applyEndTurnActionResponse( + map: MapData, + { current, miss, next, rotatePlayers, round, supply }: EndTurnActionResponse, +): MapData { + const nextPlayer = map + .getPlayer(next.player) + .setFunds(next.funds) + .disableActiveSkills(); + + let currentPlayer = map.getCurrentPlayer().setFunds(current.funds); + if (miss) { + currentPlayer = currentPlayer.copy({ misses: currentPlayer.misses + 1 }); + } + + let teams = updatePlayers(map.teams, [nextPlayer, currentPlayer]); + const destroyedUnits = map + .subtractFuel(nextPlayer.id) + .units.filter((unit, vector) => + shouldRemoveUnit(map, vector, unit, nextPlayer.id), + ).size; + + if (destroyedUnits > 0) { + teams = updatePlayer( + teams, + map + .copy({ teams }) + .getPlayer(resolveDynamicPlayerID(map, 'opponent', nextPlayer.id)) + .modifyStatistics({ + destroyedUnits, + }), + ); + } + + if (rotatePlayers && isHumanPlayer(currentPlayer)) { + const temporaryMap = map.copy({ teams }); + teams = updatePlayers(teams, [ + Bot.from( + temporaryMap.getPlayer(current.player), + `${getColorName(current.player)} ${generateName()}`, + ).copy({ teamId: currentPlayer.teamId }), + HumanPlayer.from( + temporaryMap.getPlayer(next.player), + currentPlayer.userId, + ).copy({ teamId: nextPlayer.teamId }), + ]); + } + + return map + .copy({ + currentPlayer: nextPlayer.id, + round, + teams, + units: map.units.merge( + getUnitsToHealOnBuildings(map, nextPlayer).map((unit) => + unit.refill().modifyHealth(HealAmount), + ), + ), + }) + .recover(currentPlayer) + .refill(nextPlayer, supply); +} diff --git a/apollo/actions/encodeGameActionResponse.tsx b/apollo/actions/encodeGameActionResponse.tsx new file mode 100644 index 00000000..8b616ba1 --- /dev/null +++ b/apollo/actions/encodeGameActionResponse.tsx @@ -0,0 +1,107 @@ +import Building, { PlainBuilding } from '@deities/athena/map/Building.tsx'; +import { PlainEntitiesList } from '@deities/athena/map/PlainMap.tsx'; +import { encodeEntities } from '@deities/athena/map/Serialization.tsx'; +import Unit, { PlainUnit } from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { ActionResponse } from '../ActionResponse.tsx'; +import { encodeActionResponse } from '../EncodedActions.tsx'; +import computeVisibleActions from '../lib/computeVisibleActions.tsx'; +import getVisibleEntities from '../lib/getVisibleEntities.tsx'; +import { + EncodedGameActionResponse, + EncodedGameActionResponseItem, + GameState, +} from '../Types.tsx'; + +const removeActionedEntities = ( + entities: ImmutableMap | null, + actionResponse?: ActionResponse | null, +): ImmutableMap => { + if (!entities || !entities.size) { + return ImmutableMap(); + } + + // Delete entities from this map that were being acted on by the user. + // Those will be visible and updated through the action itself. + if (actionResponse && 'from' in actionResponse && actionResponse.from) { + entities = entities.delete(actionResponse.from); + } + if (actionResponse && 'to' in actionResponse && actionResponse.to) { + entities = entities.delete(actionResponse.to); + } + return entities; +}; + +const addVisibleEntities = ( + previousMap: MapData, + currentMap: MapData, + vision: VisionT, + actionResponse: ActionResponse, +): + | [ + PlainEntitiesList | undefined, + PlainEntitiesList | undefined, + ] + | null => { + if (previousMap.config.fog) { + const [buildings, units] = getVisibleEntities( + previousMap, + currentMap, + vision, + ); + const actualBuildings = + actionResponse.type === 'Move' + ? buildings + : removeActionedEntities(buildings, actionResponse); + const actualUnits = removeActionedEntities(units, actionResponse); + return [ + actualBuildings.size ? encodeEntities(actualBuildings) : undefined, + actualUnits.size ? encodeEntities(actualUnits) : undefined, + ]; + } + return null; +}; + +const encodeItem = ( + actionResponse: ActionResponse, + vision: VisionT, + previousMap?: MapData, + currentMap?: MapData, +): EncodedGameActionResponseItem => { + const visible = + previousMap && currentMap + ? addVisibleEntities(previousMap, currentMap, vision, actionResponse) + : null; + return [encodeActionResponse(actionResponse), ...(visible || [])]; +}; + +export default function encodeGameActionResponse( + clientMap: MapData, + initialMap: MapData, + vision: VisionT, + gameState: GameState | null, + timeout: Date | null | undefined, + actionResponse?: ActionResponse | null, +) { + const response: EncodedGameActionResponse = [ + actionResponse + ? encodeItem(actionResponse, vision, clientMap, initialMap) + : null, + ]; + + if (gameState?.length) { + response[1] = computeVisibleActions(initialMap, vision, gameState).map( + ([actionResponse, previousMap, currentMap]) => + encodeItem(actionResponse, vision, previousMap, currentMap), + ); + } + + if (timeout !== undefined) { + response[2] = timeout?.getTime() || null; + } + + return response; +} diff --git a/apollo/actions/executeGameAction.tsx b/apollo/actions/executeGameAction.tsx new file mode 100644 index 00000000..7e5ce3ec --- /dev/null +++ b/apollo/actions/executeGameAction.tsx @@ -0,0 +1,107 @@ +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { Action, execute, MutateActionResponseFn } from '../Action.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; +import { Effects } from '../Effects.tsx'; +import applyConditions from '../lib/applyConditions.tsx'; +import gameHasEnded from '../lib/gameHasEnded.tsx'; +import { GameState } from '../Types.tsx'; + +type AIClass = { + new (effects: Effects): AIType; +}; + +type AIType = { + act(map: MapData): MapData | null; + retrieveEffects(): Effects; + retrieveGameState(): GameState; +}; + +export function executeAIAction( + activeMap: MapData | null, + AIClass: AIClass, + effects: Effects, + gameState: GameState = [], +): [GameState, Effects] { + let iterations = 0; + const maxIterations = 100 * (activeMap?.active.length || 1); + + while ( + activeMap && + activeMap.active.length > 1 && + activeMap.getCurrentPlayer().isBot() + ) { + const ai = new AIClass(effects); + while (activeMap) { + activeMap = ai.act(activeMap); + } + const aiGameState = ai.retrieveGameState(); + effects = ai.retrieveEffects(); + gameState = gameState.concat(aiGameState); + + if (gameHasEnded(aiGameState)) { + break; + } + + [, activeMap] = aiGameState.at(-1) || [null, null]; + + // Escape hatch. Issue a "Draw" if the AI is in a stalemate. + if (iterations++ >= maxIterations) { + const state = [...gameState]; + // Prune unnecessary game states. + for (let i = state.length - 1; i > 0; i--) { + const [actionResponse] = state[i]; + if ( + actionResponse.type === 'EndTurn' || + actionResponse.type === 'CompleteUnit' + ) { + state.pop(); + } else { + break; + } + } + gameState = state.concat([[{ type: 'GameEnd' }, state.at(-1)![1]]]); + break; + } + } + return [gameState, effects]; +} + +export default function executeGameAction( + map: MapData, + vision: VisionT, + effects: Effects, + action: Action, + AIClass: AIClass | null, + mutateAction?: MutateActionResponseFn, +): [ActionResponse, MapData, GameState, Effects] | [null, null, null, null] { + const actionResult = execute(map, vision, action, mutateAction); + if (!actionResult) { + return [null, null, null, null]; + } + const actionResponse = actionResult[0]; + let activeMap: MapData | null = actionResult[1]; + const [gameState, newEffects] = applyConditions( + map, + activeMap, + effects, + actionResponse, + ); + if (gameState.length) { + activeMap = gameState.at(-1)![1]; + } + const shouldInvokeAI = !!( + AIClass && + !gameHasEnded(gameState) && + (gameState.at(-1)?.[1] || activeMap).getCurrentPlayer().isBot() + ); + return [ + actionResponse, + activeMap, + ...((shouldInvokeAI && + executeAIAction(activeMap, AIClass, newEffects, gameState)) || [ + gameState, + newEffects, + ]), + ]; +} diff --git a/apollo/actions/validateAction.tsx b/apollo/actions/validateAction.tsx new file mode 100644 index 00000000..2505e7dd --- /dev/null +++ b/apollo/actions/validateAction.tsx @@ -0,0 +1,37 @@ +import { getUnitInfo } from '@deities/athena/info/Unit.tsx'; +import { MaxMessageLength } from '@deities/athena/map/Configuration.tsx'; +import { toDynamicPlayerID } from '@deities/athena/map/Player.tsx'; +import sanitizeText from '@deities/hephaestus/sanitizeText.tsx'; +import { Action } from '../Action.tsx'; + +export default function validateAction(action: Action) { + if (action.type !== 'CharacterMessageEffect') { + return null; + } + + try { + toDynamicPlayerID(action.player); + } catch { + return null; + } + + const unit = getUnitInfo(action.unitId); + if (!unit) { + return null; + } + + if ( + action.variant != null && + (action.variant > (unit.sprite.portrait.variants || 0) - 1 || + action.variant < 0) + ) { + return null; + } + + const message = sanitizeText(action.message); + if (message.length > MaxMessageLength) { + return null; + } + + return { ...action, message }; +} diff --git a/apollo/attack-direction/getAttackDirection.tsx b/apollo/attack-direction/getAttackDirection.tsx new file mode 100644 index 00000000..10e3d5a7 --- /dev/null +++ b/apollo/attack-direction/getAttackDirection.tsx @@ -0,0 +1,57 @@ +import SpriteVector from '@deities/athena/map/SpriteVector.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; + +export type PlainAttackDirection = 0 | 1 | 2 | 3; + +export class AttackDirection { + constructor(public readonly direction: 'right' | 'left' | 'up' | 'down') {} + + toJSON() { + return this.direction === 'right' + ? 0 + : this.direction === 'left' + ? 1 + : this.direction === 'up' + ? 2 + : 3; + } + + static fromJSON(direction: PlainAttackDirection): AttackDirection { + return new AttackDirection( + direction === 0 + ? 'right' + : direction === 1 + ? 'left' + : direction === 2 + ? 'up' + : 'down', + ); + } +} + +export const RightAttackDirection = new AttackDirection('right'); +export const LeftAttackDirection = new AttackDirection('left'); +export const UpAttackDirection = new AttackDirection('up'); +export const DownAttackDirection = new AttackDirection('down'); + +export default function getAttackDirection( + from: Vector, + to: Vector, +): [AttackDirection, AttackDirection] { + const { x, y } = new SpriteVector(to.x - from.x, to.y - from.y); + let direction: AttackDirection, oppositeDirection: AttackDirection; + if (x < 0) { + direction = LeftAttackDirection; + oppositeDirection = RightAttackDirection; + } else if (x > 0) { + direction = RightAttackDirection; + oppositeDirection = LeftAttackDirection; + } else if (y < 0) { + direction = UpAttackDirection; + oppositeDirection = DownAttackDirection; + } else { + /*if (y > 0)*/ direction = DownAttackDirection; + oppositeDirection = UpAttackDirection; + } + return [direction, oppositeDirection]; +} diff --git a/apollo/lib/GameTimerValue.tsx b/apollo/lib/GameTimerValue.tsx new file mode 100644 index 00000000..8214f831 --- /dev/null +++ b/apollo/lib/GameTimerValue.tsx @@ -0,0 +1,21 @@ +const GameTimerValue = [null, -1, 600, 3600, 86_400] as const; + +export default GameTimerValue; + +export type GameTimerValue = (typeof GameTimerValue)[number]; + +const timerSet = new Set(GameTimerValue); + +export function validateTimer( + timer: number | null | undefined, +): GameTimerValue { + return timerSet.has(timer as GameTimerValue) + ? (timer as GameTimerValue) + : null; +} + +export function isValidTimer( + timer: number | null | undefined, +): timer is GameTimerValue { + return !!timerSet.has(timer as GameTimerValue); +} diff --git a/apollo/lib/__tests__/nameGenerator.test.tsx b/apollo/lib/__tests__/nameGenerator.test.tsx new file mode 100644 index 00000000..3fe250bc --- /dev/null +++ b/apollo/lib/__tests__/nameGenerator.test.tsx @@ -0,0 +1,9 @@ +import { expect, test } from 'vitest'; +import nameGenerator from '../nameGenerator.tsx'; + +test('`nameGenerator` never runs out of names', () => { + const generator = nameGenerator(); + for (let i = 0; i < 300; i++) { + expect(generator()).not.toBe(undefined); + } +}); diff --git a/apollo/lib/applyConditions.tsx b/apollo/lib/applyConditions.tsx new file mode 100644 index 00000000..ce976d4f --- /dev/null +++ b/apollo/lib/applyConditions.tsx @@ -0,0 +1,116 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; +import { applyEffects, Effects } from '../Effects.tsx'; +import { checkGameOverConditions } from '../GameOver.tsx'; +import { GameState, GameStateWithEffects } from '../Types.tsx'; + +const getLosingPlayer = (gameState: GameState): PlayerID | null => { + for (const [actionResponse, map] of gameState) { + switch (actionResponse.type) { + case 'AttackUnitGameOver': + case 'CaptureGameOver': + return actionResponse.fromPlayer; + case 'BeginTurnGameOver': + return map.currentPlayer; + } + } + return null; +}; + +export default function applyConditions( + previousMap: MapData, + activeMap: MapData, + effects: Effects, + lastActionResponse: ActionResponse, +): [GameState, Effects] { + let gameState: GameStateWithEffects = []; + const queue: Array< + [ + previousMap: MapData, + activeMap: MapData, + lastActionResponse: ActionResponse, + addToGameState: boolean, + ] + > = [[previousMap, activeMap, lastActionResponse, false]]; + + while (queue.length) { + const [previousMap, activeMap, lastActionResponse, _addToGameState] = + queue.shift()!; + const currentEffects = gameState.at(-1)?.[2] || effects; + let addToGameState = _addToGameState; + + // `GameEnd` effects are player-specific and handled in `onGameEnd`. + let effectGameState = + lastActionResponse.type === 'GameEnd' + ? null + : applyEffects( + previousMap, + activeMap, + currentEffects, + lastActionResponse, + ); + + // If a `Spawn` effect was issued in response to a player that just lost, revert the player gameover event. + const lostPlayer = getLosingPlayer([[lastActionResponse, activeMap]]); + if ( + lostPlayer && + effectGameState?.length && + effectGameState.some( + ([actionResponse]) => + actionResponse.type === 'Spawn' && + actionResponse.units.some((unit) => + previousMap.matchesPlayer(unit, lostPlayer), + ), + ) + ) { + queue.length = 0; + addToGameState = false; + + // Reapply the same effects on a previous version of the game state in which the player has lost but + // lose criteria (ie. building posession) has not been updated yet. + effectGameState = applyEffects( + previousMap, + previousMap, + currentEffects, + lastActionResponse, + ); + } + + if (addToGameState) { + gameState = [ + ...gameState, + [lastActionResponse, activeMap, currentEffects], + ]; + } + + const gameOverState = checkGameOverConditions( + previousMap, + activeMap, + lastActionResponse, + ); + + if (gameOverState?.length) { + let currentMap = activeMap; + for (const [actionResponse, currentActiveMap] of gameOverState) { + queue.push([currentMap, currentActiveMap, actionResponse, true]); + currentMap = currentActiveMap; + } + continue; + } + + if (effectGameState?.length) { + gameState = [...gameState, ...effectGameState]; + } + } + + const lastEntry = gameState?.at(-1); + return lastEntry + ? [ + gameState.map( + ([actionResponse, map]) => [actionResponse, map] as const, + ), + lastEntry[2], + ] + : [[], effects]; +} diff --git a/apollo/lib/checkWinCondition.tsx b/apollo/lib/checkWinCondition.tsx new file mode 100644 index 00000000..9c276c63 --- /dev/null +++ b/apollo/lib/checkWinCondition.tsx @@ -0,0 +1,300 @@ +import matchesPlayerList from '@deities/athena/lib/matchesPlayerList.tsx'; +import Entity from '@deities/athena/map/Entity.tsx'; +import { PlayerID, PlayerIDSet } from '@deities/athena/map/Player.tsx'; +import Unit, { TransportedUnit } from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { + onlyHasDefaultWinCondition, + WinCondition, + WinCriteria, +} from '@deities/athena/WinConditions.tsx'; +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; + +const destructiveActions = new Set([ + 'AttackUnit', + 'AttackBuilding', + 'EndTurn', + 'ToggleLightning', +]); + +const moveActions = new Set(['CreateUnit', 'DropUnit', 'Move', 'Spawn']); + +export function isDestructiveAction(actionResponse: ActionResponse) { + return destructiveActions.has(actionResponse.type); +} + +export function shouldCheckDefaultWinConditions( + map: MapData, + actionResponse: ActionResponse, +) { + const { winConditions } = map.config; + if (isDestructiveAction(actionResponse)) { + return ( + onlyHasDefaultWinCondition(winConditions) || + winConditions.some( + (condition) => + condition.type === WinCriteria.Default || + (condition.type === WinCriteria.DefeatLabel && + matchesPlayerList(condition.players, map.currentPlayer)), + ) + ); + } else if (actionResponse.type === 'Capture' && actionResponse.building) { + return ( + onlyHasDefaultWinCondition(winConditions) || + winConditions.some( + (condition) => + condition.type === WinCriteria.Default || + ((condition.type === WinCriteria.CaptureAmount || + condition.type === WinCriteria.CaptureLabel) && + matchesPlayerList(condition.players, map.currentPlayer)), + ) + ); + } + return false; +} + +const filterByLabels = (label: PlayerIDSet) => (entity: Entity) => + entity.label != null && label.has(entity.label); + +const filterUnitsByLabels = (label: PlayerIDSet | undefined) => { + if (!label?.size) { + return Boolean; + } + + return (unit: Unit | TransportedUnit): boolean => + (unit.label != null && label?.has(unit.label)) || + (unit.isTransportingUnits() && + unit.transports.some( + (unit) => + (unit.label != null && label?.has(unit.label)) || + filterUnitsByLabels(label)(unit), + )); +}; + +const filterNeutral = (entity: Entity) => entity.player === 0; + +const filterEnemies = (map: MapData, player: PlayerID) => (entity: Entity) => + map.isOpponent(entity, player); + +export function capturedByPlayer(map: MapData, player: PlayerID) { + return map.buildings.filter((building) => map.matchesPlayer(building, player)) + .size; +} + +export function destroyedBuildingsByPlayer(map: MapData, player: PlayerID) { + return map.getPlayer(player).stats.destroyedBuildings; +} + +export function escortedByPlayer( + map: MapData, + player: PlayerID, + vectors: ReadonlySet, + label: PlayerIDSet | undefined, +) { + return [...vectors] + .map((vector) => { + const unit = map.units.get(vector); + return unit && map.matchesPlayer(unit, player) ? unit : null; + }) + .filter(isPresent) + .filter(filterUnitsByLabels(label)).length; +} + +function checkWinCondition( + previousMap: MapData, + map: MapData, + actionResponse: ActionResponse, + isDestructive: boolean, + isCapture: boolean, + isRescue: boolean, + isMove: boolean, + condition: WinCondition, +) { + const player = previousMap.currentPlayer; + const matchesPlayer = + condition.type !== WinCriteria.Default && + matchesPlayerList(condition.players, player); + + if (isDestructive) { + return ( + (condition.type === WinCriteria.DefeatLabel && + matchesPlayer && + map.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(map, player)).size === 0 && + previousMap.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(previousMap, player)).size > 0) || + (condition.type === WinCriteria.DefeatOneLabel && + matchesPlayer && + map.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(map, player)).size < + previousMap.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(previousMap, player)).size) || + (condition.type === WinCriteria.DefeatAmount && + matchesPlayer && + (condition.players?.length ? condition.players : map.active).find( + (playerID) => + map.getPlayer(playerID).stats.destroyedUnits >= condition.amount, + )) || + (condition.type === WinCriteria.EscortLabel && + !matchesPlayer && + map.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(map, player)).size < + previousMap.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(map, player)).size) || + (condition.type === WinCriteria.EscortAmount && + condition.label?.size && + !matchesPlayer && + map.units + .filter(filterUnitsByLabels(condition.label)) + .filter(filterEnemies(map, player)).size < condition.amount) || + (condition.type === WinCriteria.CaptureLabel && + map.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(map, player)).size < + previousMap.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(map, player)).size) || + (actionResponse.type === 'AttackBuilding' && + !actionResponse.building && + condition.type === WinCriteria.DestroyLabel && + map.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(map, player)).size === 0 && + previousMap.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(previousMap, player)).size > 0) || + (condition.type === WinCriteria.RescueLabel && + map.units.filter(filterNeutral).filter(filterByLabels(condition.label)) + .size < + previousMap.units + .filter(filterNeutral) + .filter(filterByLabels(condition.label)).size) || + (actionResponse.type === 'EndTurn' && + condition.type === WinCriteria.Survival && + matchesPlayerList(condition.players, actionResponse.next.player) && + condition.rounds <= actionResponse.round) || + (actionResponse.type === 'AttackBuilding' && + !actionResponse.building && + condition.type === WinCriteria.DestroyAmount && + matchesPlayer && + destroyedBuildingsByPlayer(map, player) >= condition.amount) + ); + } + + if (isCapture) { + return ( + (condition.type === WinCriteria.CaptureAmount && + matchesPlayer && + capturedByPlayer(map, player) >= condition.amount) || + (condition.type === WinCriteria.CaptureLabel && + matchesPlayer && + !map.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(map, player)).size && + previousMap.buildings + .filter(filterByLabels(condition.label)) + .filter(filterEnemies(map, player)).size > 0) + ); + } + + if (isRescue) { + return ( + condition.type === WinCriteria.RescueLabel && + matchesPlayer && + !map.units.filter(filterNeutral).filter(filterByLabels(condition.label)) + .size && + previousMap.units + .filter(filterNeutral) + .filter(filterByLabels(condition.label)).size > 0 + ); + } + + if (isMove) { + if (condition.type === WinCriteria.EscortLabel && matchesPlayer) { + const units = map.units + .filter(filterUnitsByLabels(condition.label)) + .filter((unit) => map.matchesPlayer(unit, player)); + return ( + units.size > 0 && + units.filter((_, vector) => !condition.vectors.has(vector)).size === 0 + ); + } + + return ( + condition.type === WinCriteria.EscortAmount && + matchesPlayer && + escortedByPlayer(map, player, condition.vectors, condition.label) >= + condition.amount + ); + } + + return false; +} + +export default function checkWinConditions( + previousMap: MapData, + map: MapData, + actionResponse: ActionResponse, +) { + const { winConditions } = map.config; + if (onlyHasDefaultWinCondition(winConditions)) { + return null; + } + + const isDestructive = isDestructiveAction(actionResponse); + const isCapture = + !!(actionResponse.type === 'Capture' && actionResponse.building) || + actionResponse.type === 'CreateBuilding'; + + const isMove = moveActions.has(actionResponse.type); + const isRescue = + actionResponse.type === 'Rescue' && + map.units.get(actionResponse.to)?.player === actionResponse.player; + + if (isDestructive || isCapture || isMove || isRescue) { + const check = checkWinCondition.bind( + null, + previousMap, + map, + actionResponse, + isDestructive, + isCapture, + isRescue, + isMove, + ); + if (winConditions.length === 1) { + const condition = winConditions[0]; + if (check(condition)) { + return condition; + } + } + + if (winConditions.length === 2) { + const conditionA = winConditions[0]; + if (check(conditionA)) { + return conditionA; + } + + const conditionB = winConditions[1]; + if (check(conditionB)) { + return conditionB; + } + } else { + for (const condition of winConditions) { + if (check(condition)) { + return condition; + } + } + } + } + + return null; +} diff --git a/apollo/lib/computeVisibleActions.tsx b/apollo/lib/computeVisibleActions.tsx new file mode 100644 index 00000000..0cfa7f79 --- /dev/null +++ b/apollo/lib/computeVisibleActions.tsx @@ -0,0 +1,550 @@ +import getAllUnitsToRefill from '@deities/athena/lib/getAllUnitsToRefill.tsx'; +import getMovementPath from '@deities/athena/lib/getMovementPath.tsx'; +import getUnitsToRefill from '@deities/athena/lib/getUnitsToRefill.tsx'; +import Entity from '@deities/athena/map/Entity.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { moveable } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { + ActionResponse, + ActionResponses, + AttackBuildingActionResponse, + AttackUnitActionResponse, + CompleteUnitActionResponse, + CreateBuildingActionResponse, + CreateUnitActionResponse, + DropUnitActionResponse, + EndTurnActionResponse, + HealActionResponse, + MoveActionResponse, + MoveUnitActionResponse, + RescueActionResponse, + SabotageActionResponse, + SpawnActionResponse, + SupplyActionResponse, + ToggleLightningActionResponse, +} from '../ActionResponse.tsx'; +import getAttackDirection from '../attack-direction/getAttackDirection.tsx'; +import { + HiddenDestroyedBuildingActionResponse, + HiddenMoveActionResponse, + HiddenSourceAttackBuildingActionResponse, + HiddenSourceAttackUnitActionResponse, + HiddenTargetAttackBuildingActionResponse, + HiddenTargetAttackUnitActionResponse, +} from '../HiddenAction.tsx'; +import { GameState } from '../Types.tsx'; + +const completeUnit = ({ from }: { from: Vector }) => + ({ + from, + type: 'CompleteUnit', + }) as const; + +const supplyActionWithDefault = + ( + defaultFn: (actionResponse: SupplyActionResponse) => ActionResponse | null, + ) => + ( + actionResponse: SupplyActionResponse, + map: MapData, + activeMap: MapData, + vision: VisionT, + ) => { + const unit = map.units.get(actionResponse.from); + return unit + ? getUnitsToRefill(map, vision, map.getPlayer(unit), actionResponse.from) + .size + ? actionResponse + : defaultFn(actionResponse) + : null; + }; + +const getAttackWeapon = ( + map: MapData, + from: Vector, + entityB: Entity | undefined, +): number | undefined => + entityB && map.units.get(from)?.getAttackWeapon(entityB)?.id; + +type VisibleModifierAction = + | true + | (( + actionResponse: T, + previousMap: MapData, + activeMap: MapData, + vision: VisionT, + ) => ActionResponse | ActionResponses | null); + +type VisibleModifier = + | false + | VisibleModifierAction + | { + Both?: VisibleModifierAction; + Hidden?: VisibleModifierAction; + Source?: VisibleModifierAction; + Target?: VisibleModifierAction; + }; + +const VisibleActionModifiers: Record< + ActionResponse['type'], + VisibleModifier +> = { + ActivatePower: true, + AttackBuilding: { + Both: true, + Hidden: ({ + building, + to, + }: AttackBuildingActionResponse): HiddenDestroyedBuildingActionResponse | null => + building ? null : { to, type: 'HiddenDestroyedBuilding' }, + Source: ( + { + building, + chargeA, + from, + hasCounterAttack, + playerA, + to, + unitA, + }: AttackBuildingActionResponse, + map: MapData, + activeMap: MapData, + ): HiddenTargetAttackBuildingActionResponse => { + const newUnitA = activeMap.units.get(from); + return { + chargeA, + direction: getAttackDirection(from, to)[0], + from, + hasCounterAttack, + newPlayerA: + unitA && newUnitA && newUnitA.player !== map.units.get(from)?.player + ? newUnitA.player + : undefined, + playerA, + ...(!building ? { to } : null), + type: 'HiddenTargetAttackBuilding', + unitA, + weapon: getAttackWeapon(map, from, map.buildings.get(to)), + }; + }, + Target: ( + { + building, + chargeB, + chargeC, + from, + hasCounterAttack, + playerC, + to, + unitC, + }: AttackBuildingActionResponse, + map: MapData, + ): HiddenSourceAttackBuildingActionResponse => ({ + building, + chargeB, + chargeC, + direction: getAttackDirection(from, to)[1], + hasCounterAttack, + playerC, + to, + type: 'HiddenSourceAttackBuilding', + unitC, + weapon: hasCounterAttack + ? getAttackWeapon(map, to, map.units.get(from)) + : undefined, + }), + }, + AttackUnit: { + Both: true, + Source: ( + { + chargeA, + from, + hasCounterAttack, + playerA, + to, + unitA, + }: AttackUnitActionResponse, + map: MapData, + activeMap: MapData, + ): HiddenTargetAttackUnitActionResponse => { + const newUnitA = activeMap.units.get(from); + return { + chargeA, + direction: getAttackDirection(from, to)[0], + from, + hasCounterAttack, + newPlayerA: + unitA && newUnitA && newUnitA.player !== map.units.get(from)?.player + ? newUnitA.player + : undefined, + playerA, + type: 'HiddenTargetAttackUnit', + unitA, + weapon: getAttackWeapon(map, from, map.units.get(to)), + }; + }, + Target: ( + { + chargeB, + from, + hasCounterAttack, + playerB, + to, + unitB, + }: AttackUnitActionResponse, + map: MapData, + activeMap: MapData, + ): HiddenSourceAttackUnitActionResponse => { + const newUnitB = activeMap.units.get(to); + return { + chargeB, + direction: getAttackDirection(from, to)[1], + hasCounterAttack, + newPlayerB: + unitB && newUnitB && newUnitB.player !== map.units.get(to)?.player + ? newUnitB.player + : undefined, + playerB, + to, + type: 'HiddenSourceAttackUnit', + unitB, + weapon: hasCounterAttack + ? getAttackWeapon(map, to, map.units.get(from)) + : undefined, + }; + }, + }, + AttackUnitGameOver: true, + BeginGame: true, + BeginTurnGameOver: true, + BuySkill: true, + Capture: { + Source: true, + }, + CaptureGameOver: true, + CharacterMessage: true, + CompleteBuilding: { Source: true }, + CompleteUnit: { Source: true }, + CreateBuilding: { + Hidden: ( + actionResponse: CreateBuildingActionResponse, + _: MapData, + activeMap: MapData, + ): CreateBuildingActionResponse => ({ + ...actionResponse, + building: actionResponse.building.hide(activeMap.config.biome), + }), + Source: true, + }, + CreateTracks: true, + CreateUnit: { + Both: true, + Source: true, + Target: ( + { from, to, unit }: CreateUnitActionResponse, + _: MapData, + activeMap: MapData, + ): HiddenMoveActionResponse => ({ + path: [from, to], + type: 'HiddenMove', + unit, + }), + }, + DropUnit: { + Both: true, + Source: true, + Target: ( + { from, index, to }: DropUnitActionResponse, + map: MapData, + ): [HiddenMoveActionResponse, CompleteUnitActionResponse] | null => { + const unitA = map.units.get(from); + const unitB = unitA && unitA.getTransportedUnit(index); + return unitB + ? [ + { + path: [from, to], + type: 'HiddenMove', + unit: unitB.deploy(), + }, + completeUnit({ from: to }), + ] + : null; + }, + }, + EndTurn: computeVisibleEndTurnActionResponse, + Fold: { Source: true }, + GameEnd: true, + Heal: { + Both: true, + Source: ( + { from }: HealActionResponse, + map: MapData, + activeMap: MapData, + ): null | MoveUnitActionResponse => + map.units.get(from!)!.hasMoved() + ? null + : { + from: from!, + type: 'MoveUnit', + }, + Target: ({ to, type }): HealActionResponse => ({ + to, + type, + }), + }, + HiddenDestroyedBuilding: false, + HiddenFundAdjustment: false, + HiddenMove: false, + HiddenSourceAttackBuilding: false, + HiddenSourceAttackUnit: false, + HiddenTargetAttackBuilding: false, + HiddenTargetAttackUnit: false, + Message: true, + Move: ( + actionResponse: MoveActionResponse, + map: MapData, + _: MapData, + vision: VisionT, + ): MoveActionResponse | HiddenMoveActionResponse | null => { + const { completed, from, fuel, path: initialPath, to } = actionResponse; + const unit = map.units.get(from); + if (!unit) { + return null; + } + + const isVisible = (vector: Vector) => vision.isVisible(map, vector); + const isHidden = (vector: Vector) => !vision.isVisible(map, vector); + const dropHidden = (path: Array): Array => { + let next = path; + path = [vec(0, 0), ...path]; + while (next.length > 0 && isHidden(next[0])) { + next = next.slice(1); + path = path.slice(1); + } + return path; + }; + + let path: Array = [ + from, + ...(initialPath || + getMovementPath(map, to, moveable(map, unit, from), null).path), + ]; + if (path.length <= 1 || path.every(isHidden)) { + return null; + } + if (path.every(isVisible)) { + return actionResponse; + } + + // Drop all parts of the path at the beginning that are hidden except the first one. + // Then, drop all parts of the path at the end that are hidden except the last one. + if (isHidden(path[0])) { + path = dropHidden(path); + } + + if (path.length > 0 && isHidden(path.at(-1)!)) { + path = dropHidden(path.reverse()).reverse(); + } + + return path.length > 1 + ? { + ...(isHidden(path[0]) || completed ? { unit } : null), + completed, + fuel, + path, + type: 'HiddenMove', + } + : null; + }, + MoveUnit: { + Source: true, + }, + PreviousTurnGameOver: true, + ReceiveReward: true, + Rescue: { + Source: ( + { from }: RescueActionResponse, + _: MapData, + activeMap: MapData, + ): CompleteUnitActionResponse => completeUnit({ from: from! }), + Target: ({ player, to, type }): RescueActionResponse => ({ + player, + to, + type, + }), + }, + Sabotage: { + Source: ( + { from }: SabotageActionResponse, + _: MapData, + activeMap: MapData, + ): CompleteUnitActionResponse => completeUnit({ from: from! }), + Target: ({ to, type }): SabotageActionResponse => ({ + to, + type, + }), + }, + SecretDiscovered: true, + SetViewer: true, + Spawn: ( + actionResponse: SpawnActionResponse, + map: MapData, + activeMap: MapData, + vision: VisionT, + ): SpawnActionResponse | null => { + const units = actionResponse.units + .filter((_, vector) => vision.isVisible(activeMap, vector)) + .sortBy((unit) => + activeMap.matchesTeam(unit, vision.currentViewer) ? -1 : 1, + ); + return units.size ? { ...actionResponse, units } : null; + }, + Start: true, + Supply: { + Hidden: supplyActionWithDefault(() => null), + Source: supplyActionWithDefault(completeUnit), + }, + ToggleLightning: ( + { from, to, type }: ToggleLightningActionResponse, + map: MapData, + _: MapData, + vision: VisionT, + ): ToggleLightningActionResponse => + from && vision.isVisible(map, from) + ? { from, to, type } + : { + player: map.buildings.get(from!)!.player, + to, + type, + }, + Unfold: { Source: true }, +}; + +const processVisibleAction = ( + previousMap: MapData, + activeMap: MapData, + vision: VisionT, + actionResponse: ActionResponse, +): ActionResponse | ActionResponses | null => { + const modifier = VisibleActionModifiers[actionResponse.type]; + if (modifier === true) { + return actionResponse; + } + + if (modifier === false) { + return null; + } + + if (typeof modifier === 'function') { + return modifier(actionResponse as never, previousMap, activeMap, vision); + } + + const from = 'from' in actionResponse ? actionResponse.from : null; + const to = 'to' in actionResponse ? actionResponse.to : null; + const sourceIsVisible = from && vision.isVisible(previousMap, from); + const targetIsVisible = to && vision.isVisible(previousMap, to); + const response = actionResponse as never; + if (sourceIsVisible && targetIsVisible && modifier.Both) { + return modifier.Both === true + ? actionResponse + : modifier.Both(response, previousMap, activeMap, vision); + } + + if (targetIsVisible && modifier.Target) { + return modifier.Target === true + ? actionResponse + : modifier.Target(response, previousMap, activeMap, vision); + } + + if (sourceIsVisible && modifier.Source) { + return modifier.Source === true + ? actionResponse + : modifier.Source(response, previousMap, activeMap, vision); + } + + if (!sourceIsVisible && !targetIsVisible && modifier.Hidden) { + return modifier.Hidden === true + ? actionResponse + : modifier.Hidden(response, previousMap, activeMap, vision); + } + + return null; +}; + +type ActionResponseWithMapData = [ActionResponse, MapData?, MapData?]; + +export default function computeVisibleActions( + previousMap: MapData, + vision: VisionT, + gameState: GameState, +): ReadonlyArray { + const responses: Array = []; + for (const [actionResponse, activeMap] of gameState) { + const hasFog = activeMap.config.fog; + if ( + hasFog && + (activeMap.isOpponent( + vision.currentViewer, + activeMap.getCurrentPlayer(), + ) || + actionResponse.type === 'Spawn') + ) { + const newActionResponse = processVisibleAction( + previousMap, + activeMap, + vision, + actionResponse, + ); + if (newActionResponse) { + const items: ReadonlyArray = Array.isArray( + newActionResponse, + ) + ? newActionResponse.map((actionResponse) => [actionResponse]) + : [[newActionResponse]]; + responses.push(...items); + } + } else { + responses.push( + hasFog ? [actionResponse, previousMap, activeMap] : [actionResponse], + ); + } + previousMap = activeMap; + } + return responses.filter(([actionResponse], index) => { + const nextActionResponse = responses[index + 1]?.[0]; + return !( + actionResponse.type === 'HiddenFundAdjustment' && + (nextActionResponse?.type === 'HiddenFundAdjustment' || + nextActionResponse?.type === 'EndTurn') + ); + }); +} + +export function computeVisibleEndTurnActionResponse( + actionResponse: EndTurnActionResponse, + map: MapData, + activeMap: MapData, + vision: VisionT, +): EndTurnActionResponse { + if (!map.config.fog) { + return actionResponse; + } + + const units = getAllUnitsToRefill( + map, + vision, + map.getPlayer(actionResponse.next.player), + 'hidden', + ); + + return units.size + ? { + ...actionResponse, + supply: [...units.keys()], + } + : actionResponse; +} diff --git a/apollo/lib/decodeGameActionResponse.tsx b/apollo/lib/decodeGameActionResponse.tsx new file mode 100644 index 00000000..0a5f6157 --- /dev/null +++ b/apollo/lib/decodeGameActionResponse.tsx @@ -0,0 +1,43 @@ +import { + decodeBuildings, + decodeUnits, +} from '@deities/athena/map/Serialization.tsx'; +import { decodeActionResponse } from '../EncodedActions.tsx'; +import { + EncodedGameActionResponseWithError, + GameActionResponse, +} from '../Types.tsx'; + +export default function decodeGameActionResponse( + response: EncodedGameActionResponseWithError, + detail?: unknown, +): GameActionResponse { + if (!Array.isArray(response)) { + if (response?.n === 'p') { + return { others: [], self: null, timeout: null }; + } + + throw new Error( + `Map: Error executing remote action.\n${detail ? `Detail: '${JSON.stringify(detail, null, 2)}'\n` : ``}Response: '${JSON.stringify(response, null, 2)}'`, + ); + } + + const [self, others] = response; + return { + others: + others && + others.map((otherResponse) => ({ + actionResponse: decodeActionResponse(otherResponse[0]), + buildings: otherResponse[1] && decodeBuildings(otherResponse[1]), + units: otherResponse[2] && decodeUnits(otherResponse[2]), + })), + self: self + ? { + actionResponse: decodeActionResponse(self[0]), + buildings: self[1] && decodeBuildings(self[1]), + units: self[2] && decodeUnits(self[2]), + } + : null, + timeout: response[2] ?? undefined, + }; +} diff --git a/apollo/lib/dropLabelsFromActionResponse.tsx b/apollo/lib/dropLabelsFromActionResponse.tsx new file mode 100644 index 00000000..dce83c52 --- /dev/null +++ b/apollo/lib/dropLabelsFromActionResponse.tsx @@ -0,0 +1,76 @@ +import { PlayerIDSet } from '@deities/athena/map/Player.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; + +export default function dropLabelsFromActionResponse( + actionResponse: ActionResponse, + labels: PlayerIDSet | null, +): ActionResponse { + if (!labels?.size) { + return actionResponse; + } + + const { type } = actionResponse; + switch (type) { + case 'CreateUnit': { + const { unit } = actionResponse; + return unit.label != null && labels.has(unit.label) + ? { ...actionResponse, unit: unit.dropLabel(labels) } + : actionResponse; + } + case 'Spawn': + return { + ...actionResponse, + units: actionResponse.units.map((unit) => unit.dropLabel(labels)), + }; + case 'AttackBuilding': + case 'Capture': + case 'CreateBuilding': { + const { building } = actionResponse; + return building?.label != null && labels.has(building.label) + ? { ...actionResponse, building: building.dropLabel(labels) } + : actionResponse; + } + case 'AttackUnit': + case 'DropUnit': + case 'Heal': + case 'Move': + case 'Rescue': + case 'Sabotage': + case 'BuySkill': + case 'CreateTracks': + case 'Fold': + case 'HiddenTargetAttackBuilding': + case 'HiddenTargetAttackUnit': + case 'Supply': + case 'Unfold': + case 'HiddenDestroyedBuilding': + case 'HiddenSourceAttackBuilding': + case 'HiddenSourceAttackUnit': + case 'ToggleLightning': + case 'HiddenMove': + case 'ActivatePower': + case 'EndTurn': + case 'CharacterMessage': + case 'CompleteBuilding': + case 'CompleteUnit': + case 'MoveUnit': + case 'AttackUnitGameOver': + case 'BeginGame': + case 'BeginTurnGameOver': + case 'CaptureGameOver': + case 'GameEnd': + case 'HiddenFundAdjustment': + case 'Message': + case 'PreviousTurnGameOver': + case 'ReceiveReward': + case 'SecretDiscovered': + case 'SetViewer': + case 'Start': + return actionResponse; + default: { + actionResponse satisfies never; + throw new UnknownTypeError('getActionResponseVectors', type); + } + } +} diff --git a/apollo/lib/dropLabelsFromGameState.tsx b/apollo/lib/dropLabelsFromGameState.tsx new file mode 100644 index 00000000..d32f169f --- /dev/null +++ b/apollo/lib/dropLabelsFromGameState.tsx @@ -0,0 +1,26 @@ +import { PlayerIDSet } from '@deities/athena/map/Player.tsx'; +import { GameState } from '../Types.tsx'; +import dropLabelsFromActionResponse from './dropLabelsFromActionResponse.tsx'; + +export default function dropLabelsFromGameState( + gameState: GameState, + labels: PlayerIDSet | null, +): GameState; +export default function dropLabelsFromGameState( + gameState: GameState | null, + labels: PlayerIDSet | null, +): GameState | null; + +export default function dropLabelsFromGameState( + gameState: GameState | null, + labels: PlayerIDSet | null, +): GameState | null { + return ( + (labels?.size && + gameState?.map( + ([actionResponse, map]) => + [dropLabelsFromActionResponse(actionResponse, labels), map] as const, + )) || + gameState + ); +} diff --git a/apollo/lib/gameHasEnded.tsx b/apollo/lib/gameHasEnded.tsx new file mode 100644 index 00000000..46d6caa7 --- /dev/null +++ b/apollo/lib/gameHasEnded.tsx @@ -0,0 +1,10 @@ +import { ActionResponse } from '../ActionResponse.tsx'; + +export default function gameHasEnded( + gameState: ReadonlyArray | null, +) { + return !!( + gameState?.length && + gameState.some(([actionResponse]) => actionResponse.type === 'GameEnd') + ); +} diff --git a/apollo/lib/getActionResponseVectors.tsx b/apollo/lib/getActionResponseVectors.tsx new file mode 100644 index 00000000..6fc47dba --- /dev/null +++ b/apollo/lib/getActionResponseVectors.tsx @@ -0,0 +1,102 @@ +import getAverageVector from '@deities/athena/lib/getAverageVector.tsx'; +import Entity from '@deities/athena/map/Entity.tsx'; +import { resolveDynamicPlayerID } from '@deities/athena/map/Player.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; + +export default function getActionResponseVectors( + map: MapData, + actionResponse: ActionResponse, +): ReadonlyArray { + const { type } = actionResponse; + switch (type) { + case 'AttackBuilding': + case 'AttackUnit': + case 'CreateUnit': + case 'DropUnit': + case 'Heal': + case 'Move': + case 'Rescue': + case 'Sabotage': { + const { from, to } = actionResponse; + return from ? [from, to] : [to]; + } + case 'BuySkill': + case 'Capture': + case 'CreateBuilding': + case 'CreateTracks': + case 'Fold': + case 'HiddenTargetAttackBuilding': + case 'HiddenTargetAttackUnit': + case 'Supply': + case 'Unfold': + return [actionResponse.from]; + case 'HiddenDestroyedBuilding': + case 'HiddenSourceAttackBuilding': + case 'HiddenSourceAttackUnit': + case 'ToggleLightning': { + return [actionResponse.to]; + } + case 'Spawn': + return actionResponse.units.keySeq().toArray(); + case 'HiddenMove': + return [...actionResponse.path]; + case 'ActivatePower': + case 'EndTurn': { + const player = + actionResponse.type === 'EndTurn' + ? actionResponse.next.player + : map.getCurrentPlayer(); + + const match = (entity: Entity) => map.matchesPlayer(entity, player); + let vectors = map.units.filter(match).keySeq().toArray(); + if (!vectors.length) { + vectors = map.buildings.filter(match).keySeq().toArray(); + } + // Average the vectors beforehand because the vectors in this list + // are not useful for calculating boundaries. + return vectors.length ? [getAverageVector(vectors)] : []; + } + case 'CharacterMessage': { + const { player: dynamicPlayer, unitId } = actionResponse; + const player = resolveDynamicPlayerID(map, dynamicPlayer); + const position = map.units.findKey( + (unit) => + unit.id === unitId && + unit.isLeader() && + map.matchesPlayer(unit, player), + ); + // Select the position above if possible because it shows the character name. + return position + ? [map.contains(position.up()) ? position.up() : position] + : []; + } + // These actions have vectors attached to them, but they are not animated + // and not interesting to scroll them into view. + case 'CompleteBuilding': + case 'CompleteUnit': + case 'MoveUnit': + break; + // These actions have no vectors attached to them. + case 'AttackUnitGameOver': + case 'BeginGame': + case 'BeginTurnGameOver': + case 'CaptureGameOver': + case 'GameEnd': + case 'HiddenFundAdjustment': + case 'Message': + case 'PreviousTurnGameOver': + case 'ReceiveReward': + case 'SecretDiscovered': + case 'SetViewer': + case 'Start': + break; + default: { + actionResponse satisfies never; + throw new UnknownTypeError('getActionResponseVectors', type); + } + } + return []; +} diff --git a/apollo/lib/getColorName.tsx b/apollo/lib/getColorName.tsx new file mode 100644 index 00000000..612d755e --- /dev/null +++ b/apollo/lib/getColorName.tsx @@ -0,0 +1,16 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; + +const NAMES: Record = { + 0: 'Neutral', + 1: 'Pink', + 2: 'Orange', + 3: 'Blue', + 4: 'Purple', + 5: 'Green', + 6: 'Red', + 7: 'Cyan', +}; + +export default function getColorName(player: PlayerID): string { + return NAMES[player]; +} diff --git a/apollo/lib/getMessageKey.tsx b/apollo/lib/getMessageKey.tsx new file mode 100644 index 00000000..680bd69c --- /dev/null +++ b/apollo/lib/getMessageKey.tsx @@ -0,0 +1,13 @@ +import jenkinsHash from '@deities/hephaestus/jenkinsHash.tsx'; +import { CharacterMessageEffectAction } from '../Action.tsx'; +import { CharacterMessageActionResponse } from '../ActionResponse.tsx'; + +export default function getMessageKey( + action: CharacterMessageEffectAction | CharacterMessageActionResponse, +) { + return jenkinsHash( + `$$${action.unitId}$$${action.player}$$${action.variant || 0}$$${ + action.message + }`, + ); +} diff --git a/apollo/lib/getVisibleEntities.tsx b/apollo/lib/getVisibleEntities.tsx new file mode 100644 index 00000000..d77f313e --- /dev/null +++ b/apollo/lib/getVisibleEntities.tsx @@ -0,0 +1,29 @@ +import Building from '@deities/athena/map/Building.tsx'; +import Entity from '@deities/athena/map/Entity.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; + +const equals = (a: Entity | undefined, b: Entity) => { + return ( + a && b && a.player === b.player && a.id === b.id && a.health === b.health + ); +}; + +export default function getVisibleEntities( + previousMap: MapData, + currentMap: MapData, + vision: VisionT, +): [ImmutableMap, ImmutableMap] { + const { buildings: previousBuildings, units: previousUnits } = + vision.apply(previousMap); + const { buildings, units } = vision.apply(currentMap); + return [ + buildings.filter( + (building, vector) => !equals(previousBuildings.get(vector), building), + ), + units.filter((unit, vector) => !equals(previousUnits.get(vector), unit)), + ]; +} diff --git a/apollo/lib/getWinningTeam.tsx b/apollo/lib/getWinningTeam.tsx new file mode 100644 index 00000000..2c7750d7 --- /dev/null +++ b/apollo/lib/getWinningTeam.tsx @@ -0,0 +1,13 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { GameEndActionResponse } from '../GameOver.tsx'; + +export default function getWinningTeam( + map: MapData, + actionResponse: GameEndActionResponse, +): 'draw' | PlayerID { + const isDraw = !actionResponse.toPlayer; + return isDraw + ? 'draw' + : map.getTeam(map.getPlayer(actionResponse.toPlayer)).id; +} diff --git a/apollo/lib/hasTimer.tsx b/apollo/lib/hasTimer.tsx new file mode 100644 index 00000000..de431c84 --- /dev/null +++ b/apollo/lib/hasTimer.tsx @@ -0,0 +1,18 @@ +import isPvP from '@deities/athena/lib/isPvP.tsx'; +import MapData from '@deities/athena/MapData.tsx'; + +export default function hasTimer( + game: T & { + ended: boolean; + timer: number | null; + }, + map: MapData, +): game is T & { ended: boolean; timer: number } { + const { ended, timer } = game; + return ( + !ended && + timer != null && + map.getCurrentPlayer().isHumanPlayer() && + isPvP(map) + ); +} diff --git a/apollo/lib/mapWithAIPlayers.tsx b/apollo/lib/mapWithAIPlayers.tsx new file mode 100644 index 00000000..d5ad298e --- /dev/null +++ b/apollo/lib/mapWithAIPlayers.tsx @@ -0,0 +1,21 @@ +import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; +import { Bot } from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import getColorName from './getColorName.tsx'; +import nameGenerator from './nameGenerator.tsx'; + +const generateName = nameGenerator(); + +export default function mapWithAIPlayers(map: MapData) { + return map.copy({ + teams: updatePlayers( + map.teams, + map.active + .map((id) => map.getPlayer(id)) + .filter((player) => player.isPlaceholder()) + .map((player) => + Bot.from(player, `${getColorName(player.id)} ${generateName()}`), + ), + ), + }); +} diff --git a/apollo/lib/maybeDecodeActionResponse.tsx b/apollo/lib/maybeDecodeActionResponse.tsx new file mode 100644 index 00000000..662b83e2 --- /dev/null +++ b/apollo/lib/maybeDecodeActionResponse.tsx @@ -0,0 +1,19 @@ +import { + decodeActionResponse, + EncodedActionResponse, +} from '../EncodedActions.tsx'; + +export default function maybeDecodeActionResponse( + lastAction: string | EncodedActionResponse | null | undefined, +) { + if (lastAction) { + try { + return decodeActionResponse( + typeof lastAction === 'string' ? JSON.parse(lastAction) : lastAction, + ); + } catch { + return { type: 'Start' } as const; + } + } + return null; +} diff --git a/apollo/lib/nameGenerator.tsx b/apollo/lib/nameGenerator.tsx new file mode 100644 index 00000000..77efc540 --- /dev/null +++ b/apollo/lib/nameGenerator.tsx @@ -0,0 +1,127 @@ +import arrayShuffle from 'array-shuffle'; + +const NAMES = [ + 'Albatross', + 'Alpaca', + 'Ant', + 'Antelope', + 'Ape', + 'Armadillo', + 'Baboon', + 'Barracuda', + 'Bat', + 'Bear', + 'Beaver', + 'Bee', + 'Bison', + 'Boar', + 'Buffalo', + 'Butterfly', + 'Camel', + 'Cassowary', + 'Cat', + 'Caterpillar', + 'Cheetah', + 'Chicken', + 'Chimpanzee', + 'Cobra', + 'Coyote', + 'Crab', + 'Crane', + 'Crocodile', + 'Crow', + 'Deer', + 'Dinosaur', + 'Dog', + 'Dolphin', + 'Donkey', + 'Dragonfly', + 'Duck', + 'Eagle', + 'Eel', + 'Elephant', + 'Elk', + 'Falcon', + 'Fish', + 'Flamingo', + 'Fox', + 'Frog', + 'Gazelle', + 'Giraffe', + 'Goat', + 'Goose', + 'Gorilla', + 'Grasshopper', + 'Hamster', + 'Hawk', + 'Hedgehog', + 'Hippopotamus', + 'Hornet', + 'Horse', + 'Hyena', + 'Ibex', + 'Ibis', + 'Jaguar', + 'Jellyfish', + 'Kangaroo', + 'Koala', + 'Lemur', + 'Leopard', + 'Lion', + 'Llama', + 'Lobster', + 'Mallard', + 'Mantis', + 'Meerkat', + 'Mole', + 'Mongoose', + 'Monkey', + 'Mouse', + 'Mosquito', + 'Mule', + 'Narwhal', + 'Nightingale', + 'Octopus', + 'Ostrich', + 'Otter', + 'Owl', + 'Oyster', + 'Panther', + 'Parrot', + 'Pelican', + 'Penguin', + 'Pony', + 'Quail', + 'Rabbit', + 'Raccoon', + 'Raven', + 'Reindeer', + 'Rhinoceros', + 'Scorpion', + 'Seahorse', + 'Shark', + 'Sheep', + 'Sparrow', + 'Squirrel', + 'Swan', + 'Tiger', + 'Turtle', + 'Viper', + 'Walrus', + 'Wasp', + 'Whale', + 'Wolf', + 'Wombat', + 'Zebra', +]; + +export default function nameGenerator(): () => string { + const names = arrayShuffle(NAMES); + let next = 0; + return () => { + if (next >= names.length) { + next = 0; + } + return names.at(next++)!; + }; +} diff --git a/apollo/lib/processRewards.tsx b/apollo/lib/processRewards.tsx new file mode 100644 index 00000000..ffa7fef3 --- /dev/null +++ b/apollo/lib/processRewards.tsx @@ -0,0 +1,48 @@ +import MapData from '@deities/athena/MapData.tsx'; +import { WinCriteria } from '@deities/athena/WinConditions.tsx'; +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import applyActionResponse from '../actions/applyActionResponse.tsx'; +import { GameEndActionResponse } from '../GameOver.tsx'; +import { GameState, MutableGameState } from '../Types.tsx'; +import getWinningTeam from './getWinningTeam.tsx'; + +export function processRewards( + map: MapData, + gameEndResponse: GameEndActionResponse, +): [GameState, MapData] { + const gameState: MutableGameState = []; + const winningTeam = getWinningTeam(map, gameEndResponse); + if (winningTeam !== 'draw') { + const rewards = new Set( + [ + 'condition' in gameEndResponse + ? gameEndResponse.condition?.reward + : null, + map.config.winConditions.find( + (condition) => condition.type === WinCriteria.Default, + )?.reward, + ].filter(isPresent), + ); + + if (rewards.size) { + for (const reward of rewards) { + for (const [, player] of map.getTeam(winningTeam).players) { + if (!player.skills.has(reward.skill)) { + const rewardActionResponse = { + player: player.id, + reward, + type: 'ReceiveReward', + } as const; + map = applyActionResponse( + map, + map.createVisionObject(player), + rewardActionResponse, + ); + gameState.push([rewardActionResponse, map]); + } + } + } + } + } + return [gameState, map]; +} diff --git a/apollo/lib/timeoutActionResponseMutator.tsx b/apollo/lib/timeoutActionResponseMutator.tsx new file mode 100644 index 00000000..a546a072 --- /dev/null +++ b/apollo/lib/timeoutActionResponseMutator.tsx @@ -0,0 +1,7 @@ +import { ActionResponse } from '../ActionResponse.tsx'; + +export default function (actionResponse: ActionResponse) { + return actionResponse.type === 'EndTurn' + ? { ...actionResponse, miss: true } + : actionResponse; +} diff --git a/apollo/lib/toCampaignSlug.tsx b/apollo/lib/toCampaignSlug.tsx new file mode 100644 index 00000000..6701b6e1 --- /dev/null +++ b/apollo/lib/toCampaignSlug.tsx @@ -0,0 +1,5 @@ +import toSlug from '@deities/hephaestus/toSlug.tsx'; + +export default function toCampaignSlug(username: string, slug: string) { + return `${username}/campaign/${toSlug(slug)}`; +} diff --git a/apollo/lib/toMapSlug.tsx b/apollo/lib/toMapSlug.tsx new file mode 100644 index 00000000..0c7d1385 --- /dev/null +++ b/apollo/lib/toMapSlug.tsx @@ -0,0 +1,5 @@ +import toSlug from '@deities/hephaestus/toSlug.tsx'; + +export default function toMapSlug(username: string, slug: string) { + return `${username}/${toSlug(slug)}`; +} diff --git a/apollo/lib/transformEffectValue.tsx b/apollo/lib/transformEffectValue.tsx new file mode 100644 index 00000000..d9b0595d --- /dev/null +++ b/apollo/lib/transformEffectValue.tsx @@ -0,0 +1,86 @@ +import { getUnitInfoOrThrow } from '@deities/athena/info/Unit.tsx'; +import getDeployableVectors from '@deities/athena/lib/getDeployableVectors.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import randomEntry from '@deities/hephaestus/randomEntry.tsx'; +import { Action } from '../Action.tsx'; +import { ActionResponse } from '../ActionResponse.tsx'; +import { Condition } from '../Condition.tsx'; + +type Mutable = { + -readonly [Key in keyof T]: T[Key]; +}; + +export const RelativeVectors = { + Any: vec(-2, -2), + Source: vec(-1, -1), + Target: vec(-2, -1), +} as const; + +const transformVector = ( + map: MapData, + actionResponse: ActionResponse, + value: T, + vector: Vector | null, +): Vector | null => { + if ( + 'from' in actionResponse && + vector === RelativeVectors.Source && + actionResponse.from + ) { + return actionResponse.from; + } else if ( + vector === RelativeVectors.Target && + 'to' in actionResponse && + actionResponse.to + ) { + return actionResponse.to; + } + + if ( + vector === RelativeVectors.Any && + 'to' in actionResponse && + 'from' in actionResponse && + actionResponse.from + ) { + if (value.type === 'CreateUnit') { + return ( + getDeployableVectors( + map, + getUnitInfoOrThrow(value.id), + actionResponse.from, + map.getCurrentPlayer().id, + )[0] || null + ); + } + + return randomEntry(actionResponse.from.expand()); + } + + return vector; +}; + +export default function transformEffectValue( + map: MapData, + actionResponse: ActionResponse, + value: T, +): T { + const newValue = { ...value } as Mutable; + const from = 'from' in value ? value.from : null; + const to = 'to' in value ? value.to : null; + if ('from' in newValue) { + newValue.from = transformVector(map, actionResponse, value, from); + } + if ('to' in newValue) { + newValue.to = transformVector(map, actionResponse, value, to); + } + + if (value.type === 'SpawnEffect' && 'units' in newValue) { + newValue.units = value.units.mapKeys((vector) => + transformVector(map, actionResponse, value, vector), + ); + } + + return newValue; +} diff --git a/apollo/lib/updateVisibleEntities.tsx b/apollo/lib/updateVisibleEntities.tsx new file mode 100644 index 00000000..9e243659 --- /dev/null +++ b/apollo/lib/updateVisibleEntities.tsx @@ -0,0 +1,28 @@ +import Building from '@deities/athena/map/Building.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; + +export default function updateVisibleEntities( + currentMap: MapData, + vision: VisionT, + { + buildings, + units, + }: { + buildings?: ImmutableMap; + units?: ImmutableMap; + }, +): MapData { + if (!currentMap.config.fog) { + return currentMap; + } + + const map = vision.apply(currentMap); + return map.copy({ + buildings: buildings ? map.buildings.merge(buildings) : map.buildings, + units: units ? map.units.merge(units) : map.units, + }); +} diff --git a/apollo/package.json b/apollo/package.json new file mode 100644 index 00000000..a8fc0d78 --- /dev/null +++ b/apollo/package.json @@ -0,0 +1,17 @@ +{ + "name": "@deities/apollo", + "version": "0.0.1", + "description": "Apollo, god of oracles, healing, archery, music and arts, sunlight, knowledge, herds and flocks, and protection of the young.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@deities/athena": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@nkzw/immutable-map": "^1.2.2", + "array-shuffle": "^3.0.0" + } +} diff --git a/apollo/push/Types.tsx b/apollo/push/Types.tsx new file mode 100644 index 00000000..64604466 --- /dev/null +++ b/apollo/push/Types.tsx @@ -0,0 +1,29 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; + +type BaseNotification = Readonly<{ + body: string; + timestamp: number; + title: string; +}>; + +export type TurnPushNotification = Readonly<{ + data: Readonly<{ + campaignStateId?: string; + game: string; + mapName: string; + player: PlayerID; + userId: string; + }>; + tag: 'turn'; +}>; + +export type DeviceInfo = Readonly<{ + browser: string; + os: { + name: string; + version: string; + }; + type: string; +}>; + +export type PushNotification = BaseNotification & TurnPushNotification; diff --git a/apollo/routes/getCampaignRoute.tsx b/apollo/routes/getCampaignRoute.tsx new file mode 100644 index 00000000..3be7650c --- /dev/null +++ b/apollo/routes/getCampaignRoute.tsx @@ -0,0 +1,8 @@ +import { Route, UserCampaignRoute } from '../Routes.tsx'; + +export default function getCampaignRoute( + slug: string, + route?: UserCampaignRoute, +): Route { + return `/${slug}${route ? '/' + route : ''}` as Route; +} diff --git a/apollo/routes/getMapRoute.tsx b/apollo/routes/getMapRoute.tsx new file mode 100644 index 00000000..0364cb47 --- /dev/null +++ b/apollo/routes/getMapRoute.tsx @@ -0,0 +1,5 @@ +import { Route, UserMapRoute } from '../Routes.tsx'; + +export default function getMapRoute(slug: string, route?: UserMapRoute): Route { + return `/${slug}${route ? '/' + route : ''}` as Route; +} diff --git a/apollo/routes/getUserRoute.tsx b/apollo/routes/getUserRoute.tsx new file mode 100644 index 00000000..74868f68 --- /dev/null +++ b/apollo/routes/getUserRoute.tsx @@ -0,0 +1,8 @@ +import { Route, UserRoute } from '../Routes.tsx'; + +export default function getUserRoute( + username: string, + route?: UserRoute, +): Route { + return `/${username}${route ? '/' + route : ''}` as Route; +} diff --git a/apollo/socket/Room.tsx b/apollo/socket/Room.tsx new file mode 100644 index 00000000..cb05f10d --- /dev/null +++ b/apollo/socket/Room.tsx @@ -0,0 +1,7 @@ +export function gameRoom(gameId: number) { + return `/game/${gameId}`; +} + +export function pendingGameRoom(pendingGameId: number) { + return `/pending-game/${pendingGameId}`; +} diff --git a/apollo/socket/Types.tsx b/apollo/socket/Types.tsx new file mode 100644 index 00000000..b6b05812 --- /dev/null +++ b/apollo/socket/Types.tsx @@ -0,0 +1,40 @@ +import { EncodedAction } from '../EncodedActions.tsx'; +import { EncodedGameActionResponseWithError } from '../Types.tsx'; + +export type ClientToServerEvents = { + '/campaign-state/reset': (campaignStateID: string) => void; + '/campaign-state/spectate': (campaignStateID: string) => void; + '/game/action': ( + currentGameID: string, + action: EncodedAction, + emit: (gameActionResponse: EncodedGameActionResponseWithError) => void, + ) => void; + '/game/spectate': ( + gameID: string, + spectatorCodes: ReadonlyArray, + ) => void; +}; + +export type ServerToClientEvents = { + '/campaign-state/update': (campaignStateID: string) => void; + '/game/action': ( + gameID: string, + response: EncodedGameActionResponseWithError, + ) => void; + '/pending-game/update': (pendingGameID: string) => void; +}; + +export type ServerEventName = keyof ServerToClientEvents; + +export function isValidServerEvent(action: string): action is ServerEventName { + const serverAction = action as ServerEventName; + switch (serverAction) { + case '/campaign-state/update': + case '/game/action': + case '/pending-game/update': + return true; + default: + serverAction satisfies never; + } + return false; +} diff --git a/ares/package.json b/ares/package.json new file mode 100644 index 00000000..3e0f508b --- /dev/null +++ b/ares/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deities/ares", + "version": "0.0.1", + "private": true, + "description": "Ares, god of war, represents the violent and untamed aspect of war.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "scripts": { + "fbt": "pnpm run fbt:manifest && pnpm run fbt:collect && pnpm run fbt:translate", + "fbt:collect": "../node_modules/.bin/fbt-collect --transform $(pwd)/scripts/collectionTransform.cjs --pretty --manifest < .src_manifest.json > ../source_strings.json", + "fbt:manifest": "../node_modules/.bin/fbt-manifest --src src ../hera ../ui", + "fbt:translate": "mkdir -p src/generated/ && node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/translations.js" + }, + "dependencies": { + "@deities/apollo": "workspace:*", + "@deities/art": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/fixtures": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@deities/hera": "workspace:*", + "@deities/hermes": "workspace:*", + "@deities/i18n": "workspace:*", + "@deities/ui": "workspace:*", + "@emotion/css": "^11.11.2", + "@iconify-icons/pixelarticons": "^1.2.5", + "@nkzw/use-relative-time": "^1.1.0", + "@sentry/browser": "^7.114.0", + "@stripe/react-stripe-js": "^2.7.1", + "@stripe/stripe-js": "^3.4.0", + "fbt": "^1.0.2", + "framer-motion": "^11.1.9", + "react": "19.0.0-canary-fd0da3eef-20240404", + "react-dom": "19.0.0-canary-fd0da3eef-20240404", + "react-error-boundary": "^4.0.13", + "react-relay": "^16.2.0", + "react-router-dom": "^6.23.1", + "relay-runtime": "^16.2.0", + "socket.io-client": "^4.7.5", + "workbox-core": "^7.1.0", + "workbox-window": "^7.1.0" + }, + "devDependencies": { + "@babel/core": "^7.24.5", + "@emotion/babel-plugin": "^11.11.0", + "@sentry/vite-plugin": "^2.16.1", + "@types/babel__core": "^7.20.5", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@types/react-relay": "^16.0.6", + "@types/react-router-dom": "^5.3.3", + "@types/relay-runtime": "^14.1.23", + "babel-plugin-relay": "^16.2.0", + "glob": "10.3.14", + "relay-compiler": "^16.2.0", + "vite-plugin-minify": "^1.5.2", + "vite-plugin-pwa": "^0.20.0", + "vite-plugin-restart": "^0.4.0" + } +} diff --git a/art/BiomeVariants.tsx b/art/BiomeVariants.tsx new file mode 100644 index 00000000..4119aad6 --- /dev/null +++ b/art/BiomeVariants.tsx @@ -0,0 +1,13 @@ +import getBiomeStyle from '@deities/athena/lib/getBiomeStyle.tsx'; +import { Biome, Biomes } from '@deities/athena/map/Biome.tsx'; +import { HEX } from '@nkzw/palette-swap'; + +export default new Map>( + Biomes.map((biome) => { + const style = getBiomeStyle(biome); + return [ + biome, + new Map([...(style.palette || []), ...(style.waterSwap || [])]), + ] as const; + }).filter((entry) => !!entry[1]), +); diff --git a/art/Sprites.tsx b/art/Sprites.tsx new file mode 100644 index 00000000..3aca82f1 --- /dev/null +++ b/art/Sprites.tsx @@ -0,0 +1,280 @@ +import { SpriteVariant } from '@deities/athena/info/SpriteVariants.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { injectGlobal } from '@emotion/css'; +import paletteSwap, { HEX } from '@nkzw/palette-swap'; +import Variants from 'athena-crisis:asset-variants'; +import BiomeVariants from './BiomeVariants.tsx'; +import VariantConfiguration, { + SpriteVariantConfiguration, +} from './VariantConfiguration.tsx'; + +type Resource = Readonly<[name: string, url: string]>; +type Resources = ReadonlyArray; +type PaletteSwapFn = typeof paletteSwap; +type PaletteSwapParameters = Parameters; +type DropFirstInTuple = T extends [unknown, ...infer Rest] + ? Rest + : never; +type MaybePaletteSwapParameters = [ + image: PaletteSwapParameters[0] | null, + ...DropFirstInTuple, +]; + +type Canvas = ReturnType extends ReadonlyMap + ? V + : never; + +type CanvasToURLFn = (canvas: Canvas, name: string) => Promise; + +const SHOULD_SWAP = + process.env.NODE_ENV !== 'production' || process.env.IS_DEMO; + +export const AssetDomain = 'https://art.athenacrisis.com'; +export const AssetVersion = 'v10'; + +// Keep remote images in memory forever. +const imageCache = []; + +const cacheImage = (path: string): [HTMLImageElement, Promise] => { + const image = new Image(); + imageCache.push(image); + image.src = path; + const promise = new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = (error) => reject(error); + }); + return [image, promise]; +}; + +const getFallbackURL = (name: string) => { + const path = `${AssetDomain}/${AssetVersion}/${name}.png`; + cacheImage(path); + return path; +}; + +const loadImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = (error) => reject(error); + image.src = url; + }); + +const _canvasToURL = (canvas: Canvas) => + new Promise((resolve, reject) => + (canvas as unknown as HTMLCanvasElement).toBlob((blob) => { + if (!blob) { + reject('Oops.'); + return; + } + resolve(URL.createObjectURL(blob)); + }, 'image/png'), + ); + +const imageIsDefined = ( + args: MaybePaletteSwapParameters, +): args is PaletteSwapParameters => args[0] !== null; + +const swap = (...args: MaybePaletteSwapParameters) => { + if (SHOULD_SWAP && imageIsDefined(args)) { + return [...paletteSwap(...args)]; + } + + return [...args[1].keys()].map((key) => [key, null] as const); +}; + +const emptySet = new Set(); +const emptyMap = new Map(); +const nullPromise = Promise.resolve(null); + +const imageMap = new Map]>(); +let sprites = new Map(); +let preparePromise: Promise> | null = null; +let portraitsPrepared = false; +let spritesPrepared = false; + +if (Variants.size !== VariantConfiguration.size) { + throw new Error( + `Sprites: 'Variant' and 'VariantMap' definitions are out of sync.`, + ); +} + +const _prepareSprites = async ( + spriteVariants: ReadonlyMap, + canvasToURL: CanvasToURLFn, + isBuild: boolean, +) => { + const promises: Array>> = []; + for (const [ + imageName, + { asImage, ignoreMissing = false, variantNames, waterSwap }, + ] of spriteVariants) { + if (!Variants.has(imageName)) { + throw new Error(`Sprites: Missing variant details for '${imageName}'.`); + } + + const variantDetails = Variants.get(imageName); + promises.push( + (SHOULD_SWAP && variantDetails + ? loadImage(variantDetails.source) + : nullPromise + ).then((image) => + Promise.all( + swap( + image, + variantDetails?.variants || + new Map([...variantNames].map((name) => [name, emptyMap])), + variantDetails ? variantDetails.staticColors : emptySet, + null, + { + ignoreMissing, + imageName, + }, + ).map(async ([variant, canvas]) => { + const name = `${imageName}-${variant}`; + const resource = canvas + ? await canvasToURL(canvas, name) + : getFallbackURL(name); + const item = [name, resource] as Resource; + if (asImage) { + imageMap.set(name, cacheImage(resource)); + } + + // Preload only the images that are most likely used on most maps. + if (!canvas && (variant === 0 || variant === 1 || variant === 2)) { + imageMap.set(name, cacheImage(resource)); + } + + if (!waterSwap) { + return [item]; + } + + return canvas + ? loadImage(resource) + .then((blobImage) => + Promise.all( + swap(blobImage, BiomeVariants, null, null, { + ignoreMissing: true, + }).map(async ([biome, waterSwapCanvas]) => { + const name = `${imageName}-${variant}-${biome}`; + const resource = waterSwapCanvas + ? await canvasToURL(waterSwapCanvas, name) + : getFallbackURL(name); + return [name, resource] as Resource; + }), + ), + ) + .then((waterSwapResources) => [item, ...waterSwapResources]) + : [ + item, + ...[...BiomeVariants.keys()].map((biome) => { + const name = `${imageName}-${variant}-${biome}`; + return [name, getFallbackURL(name)] as Resource; + }), + ]; + }), + ), + ), + ); + } + + const images = (await Promise.all(promises)).flatMap((list) => list.flat()); + + if (!isBuild) { + await Promise.all([...imageMap].map(([, [, promise]]) => promise)); + } + + injectGlobal( + images + .map( + ([name, url]) => `.Sprite-${name} { background-image: url('${url}'); }`, + ) + .join('\n'), + ); + portraitsPrepared = true; + spritesPrepared = true; + return (sprites = new Map(images)); +}; + +export async function preparePortraits() { + return ( + preparePromise || + _prepareSprites( + new Map([['Portraits', VariantConfiguration.get('Portraits')!]]), + _canvasToURL, + false, + ).then((sprites) => { + portraitsPrepared = true; + return sprites; + }) + ); +} + +export async function prepareSprites( + canvasToURL: CanvasToURLFn = _canvasToURL, + isBuild = false, +) { + return ( + preparePromise || + (preparePromise = _prepareSprites( + VariantConfiguration, + canvasToURL, + isBuild, + )) + ); +} + +export function hasPreparedPortraits() { + return portraitsPrepared; +} + +export function hasPreparedSprites() { + return spritesPrepared; +} + +export function hasSpriteURL( + sprite: SpriteVariant, + variant: number, + biome?: Biome, +) { + if (!sprites.size) { + throw new Error( + `Invalid \`hasSpriteURL('${sprite}', ${variant}, ${biome})\` invocation.`, + ); + } + + return sprites.has(`${sprite}-${variant}${biome ? `-${biome}` : ''}`); +} + +export function spriteURL(sprite: SpriteVariant, variant: number) { + if (!sprites.size) { + throw new Error( + `Invalid \`spriteURL('${sprite}', ${variant})\` invocation.`, + ); + } + + const image = sprites.get(`${sprite}-${variant}`); + if (!image) { + throw new Error(`spriteURL: Image not found for ${sprite}-${variant}.`); + } + + return image; +} + +export function spriteImage( + sprite: SpriteVariant, + variant: number, +): HTMLImageElement { + if (!sprites.size) { + throw new Error( + `Invalid \`spriteImage('${sprite}', ${variant})\` invocation.`, + ); + } + + const image = imageMap.get(`${sprite}-${variant}`); + if (!image) { + throw new Error(`spriteImage: Image not found for ${sprite}-${variant}.`); + } + + return image[0]; +} diff --git a/art/VariantConfiguration.tsx b/art/VariantConfiguration.tsx new file mode 100644 index 00000000..1b447cbb --- /dev/null +++ b/art/VariantConfiguration.tsx @@ -0,0 +1,421 @@ +import { SpriteVariant } from '@deities/athena/info/SpriteVariants.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { + PlainDynamicPlayerID, + PlayerIDs, +} from '@deities/athena/map/Player.tsx'; +import { HEX } from '@nkzw/palette-swap'; +import BiomeVariants from './BiomeVariants.tsx'; + +const variantNames = new Set(PlayerIDs); +const biomeVariantNames = new Set(BiomeVariants.keys()); + +export type Palette = number | Map; + +export type SpriteVariantConfiguration = Readonly<{ + asImage?: true; + ignoreMissing?: true; + variantNames: ReadonlySet; + waterSwap?: true; +}>; + +export default new Map([ + [ + 'Buildings', + { + variantNames, + }, + ], + [ + 'Building-Create', + { + variantNames, + }, + ], + [ + 'NavalExplosion', + { + variantNames: biomeVariantNames, + }, + ], + [ + 'Label', + { + variantNames, + }, + ], + [ + 'Rescue', + { + variantNames, + }, + ], + [ + 'Spawn', + { + variantNames, + }, + ], + [ + 'Units-AcidBomber', + { + variantNames, + }, + ], + [ + 'Portraits', + { + variantNames: new Set([ + ...variantNames, + -1, + -2, + -3, + ]), + }, + ], + [ + 'Units-AntiAir', + { + variantNames, + }, + ], + [ + 'Units-HeavyArtillery', + { + variantNames, + }, + ], + [ + 'Units-Dragon', + { + variantNames, + }, + ], + [ + 'Units-Bomber', + { + variantNames, + }, + ], + [ + 'Units-BattleShip', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Octopus', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'AttackOctopus', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Drone', + { + variantNames, + }, + ], + [ + 'Units-FighterJet', + { + variantNames, + }, + ], + [ + 'Units-Helicopter', + { + variantNames, + }, + ], + [ + 'Units-Pioneer', + { + variantNames, + }, + ], + [ + 'Units-Infantry', + { + variantNames, + }, + ], + [ + 'Units-RocketLauncher', + { + variantNames, + }, + ], + [ + 'Units-BazookaBear', + { + variantNames, + }, + ], + [ + 'Units-Alien', + { + variantNames, + }, + ], + [ + 'Units-Zombie', + { + variantNames, + }, + ], + [ + 'Units-Ogre', + { + variantNames, + }, + ], + [ + 'Units-Brute', + { + variantNames, + }, + ], + [ + 'Units-Commander', + { + variantNames, + }, + ], + [ + 'Units-Dinosaur', + { + variantNames, + }, + ], + [ + 'Units-Bear', + { + variantNames, + }, + ], + [ + 'Units-Flamethrower', + { + variantNames, + }, + ], + + [ + 'Units-AIU', + { + variantNames, + }, + ], + [ + 'Units-APU', + { + variantNames, + }, + ], + [ + 'Units-SuperAPU', + { + variantNames, + }, + ], + [ + 'Units-Saboteur', + { + variantNames, + }, + ], + [ + 'Units-Jetpack', + { + variantNames, + }, + ], + [ + 'Units-Sniper', + { + variantNames, + }, + ], + [ + 'Units-Jeep', + { + variantNames, + }, + ], + [ + 'Units-Truck', + { + variantNames, + }, + ], + [ + 'Units-Lander', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Amphibious', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Frigate', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Destroyer', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Hovercraft', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-SmallHovercraft', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-SupportShip', + { + variantNames, + waterSwap: true, + }, + ], + [ + 'Units-Corvette', + { + variantNames, + waterSwap: true, + }, + ], + ['Units-Mammoth', { variantNames }], + ['Units-TransportTrain', { variantNames }], + ['Units-SupplyTrain', { variantNames }], + [ + 'Units-MobileArtillery', + { + variantNames, + }, + ], + [ + 'Units-Cannon', + { + variantNames, + }, + ], + [ + 'Units-Medic', + { + variantNames, + }, + ], + [ + 'Units-ReconDrone', + { + variantNames, + }, + ], + [ + 'Units-Humvee', + { + variantNames, + }, + ], + [ + 'Units-HumveeAvenger', + { + variantNames, + }, + ], + [ + 'Units-ArtilleryHumvee', + { + variantNames, + }, + ], + [ + 'Units-SeaPatrol', + { + variantNames, + }, + ], + [ + 'Units-XFighter', + { + variantNames, + }, + ], + [ + 'Units-SmallTank', + { + variantNames, + }, + ], + [ + 'Units-HeavyTank', + { + variantNames, + }, + ], + [ + 'Units-SuperTank', + { + variantNames, + }, + ], + [ + 'Units-TransportHelicopter', + { + variantNames, + }, + ], + [ + 'BuildingsShadow', + { + asImage: true, + ignoreMissing: true, + variantNames: biomeVariantNames, + }, + ], + [ + 'StructuresShadow', + { + asImage: true, + ignoreMissing: true, + variantNames: biomeVariantNames, + }, + ], + [ + 'Decorators', + { + asImage: true, + ignoreMissing: true, + variantNames: biomeVariantNames, + }, + ], +] as const); diff --git a/art/Variants.tsx b/art/Variants.tsx new file mode 100644 index 00000000..04ea9082 --- /dev/null +++ b/art/Variants.tsx @@ -0,0 +1,79 @@ +import { SpriteVariant } from '@deities/athena/info/SpriteVariants.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { PlainDynamicPlayerID } from '@deities/athena/map/Player.tsx'; +import { HEX } from '@nkzw/palette-swap'; +import { Palette } from './VariantConfiguration.tsx'; + +export type SpriteVariantDetail = Readonly<{ + source: string; + staticColors?: Set; + variants: Map; +}>; + +export default new Map([ + ['Buildings', null], + ['Building-Create', null], + ['NavalExplosion', null], + ['Label', null], + ['Rescue', null], + ['Spawn', null], + ['Units-AcidBomber', null], + ['Portraits', null], + ['Units-AntiAir', null], + ['Units-HeavyArtillery', null], + ['Units-Dragon', null], + ['Units-Bomber', null], + ['Units-BattleShip', null], + ['Units-Octopus', null], + ['AttackOctopus', null], + ['Units-Drone', null], + ['Units-FighterJet', null], + ['Units-Helicopter', null], + ['Units-Pioneer', null], + ['Units-Infantry', null], + ['Units-RocketLauncher', null], + ['Units-BazookaBear', null], + ['Units-Alien', null], + ['Units-Zombie', null], + ['Units-Ogre', null], + ['Units-Brute', null], + ['Units-Commander', null], + ['Units-Dinosaur', null], + ['Units-Bear', null], + ['Units-Flamethrower', null], + ['Units-AIU', null], + ['Units-APU', null], + ['Units-SuperAPU', null], + ['Units-Saboteur', null], + ['Units-Jetpack', null], + ['Units-Sniper', null], + ['Units-Jeep', null], + ['Units-Truck', null], + ['Units-Lander', null], + ['Units-Amphibious', null], + ['Units-Frigate', null], + ['Units-Destroyer', null], + ['Units-Hovercraft', null], + ['Units-SmallHovercraft', null], + ['Units-SupportShip', null], + ['Units-Corvette', null], + ['Units-Mammoth', null], + ['Units-TransportTrain', null], + ['Units-SupplyTrain', null], + ['Units-MobileArtillery', null], + ['Units-Cannon', null], + ['Units-Medic', null], + ['Units-ReconDrone', null], + ['Units-Humvee', null], + ['Units-HumveeAvenger', null], + ['Units-ArtilleryHumvee', null], + ['Units-SeaPatrol', null], + ['Units-XFighter', null], + ['Units-SmallTank', null], + ['Units-HeavyTank', null], + ['Units-SuperTank', null], + ['Units-TransportHelicopter', null], + ['BuildingsShadow', null], + ['StructuresShadow', null], + ['Decorators', null], +] as const); diff --git a/art/package.json b/art/package.json new file mode 100644 index 00000000..0e1b2eaf --- /dev/null +++ b/art/package.json @@ -0,0 +1,18 @@ +{ + "name": "@deities/art", + "version": "0.0.1", + "private": true, + "description": "Athena Crisis Art.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@deities/athena": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@emotion/css": "^11.11.2", + "@nkzw/palette-swap": "^2.1.2" + } +} diff --git a/art/types/athena-crisis-asset-variants.d.ts b/art/types/athena-crisis-asset-variants.d.ts new file mode 100644 index 00000000..2cb5b78c --- /dev/null +++ b/art/types/athena-crisis-asset-variants.d.ts @@ -0,0 +1,12 @@ +declare module 'athena-crisis:asset-variants' { + import type { SpriteVariant } from '@deities/athena/info/SpriteVariants.tsx'; + + export default new Map< + SpriteVariant, + Readonly<{ + source: string; + staticColors?: Set; + variants: Map; + }> | null + >(); +} diff --git a/artemis/package.json b/artemis/package.json new file mode 100644 index 00000000..30f94bc1 --- /dev/null +++ b/artemis/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deities/artemis", + "version": "0.0.1", + "private": true, + "description": "Artemis, goddess of the hunt, wild animals and wilderness.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@aws-sdk/client-s3": "^3.574.0", + "@aws-sdk/s3-request-presigner": "^3.574.0", + "@deities/apollo": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/dionysus": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@deities/hermes": "workspace:*", + "@deities/i18n": "workspace:*", + "@graphql-tools/utils": "^10.2.0", + "@nkzw/immutable-map": "^1.2.2", + "@nkzw/profane": "^2.0.1", + "@nkzw/safe-word-list": "^2.1.0", + "@pothos/core": "^3.41.1", + "@pothos/plugin-complexity": "^3.13.0", + "@pothos/plugin-directives": "^3.10.2", + "@pothos/plugin-prisma": "^3.65.1", + "@pothos/plugin-relay": "^3.46.0", + "@prisma/client": "^5.13.0", + "@quixo3/prisma-session-store": "^3.1.13", + "@types/express-session": "^1.18.0", + "body-parser": "^1.20.2", + "bufferutil": "^4.0.8", + "chalk": "^5.3.0", + "cors": "^2.8.5", + "discord.js": "^14.15.2", + "express": "^4.19.2", + "express-session": "^1.18.0", + "graphql": "^16.8.1", + "graphql-helix": "^1.13.0", + "ioredis": "^5.4.1", + "json-stable-stringify": "^1.1.1", + "openskill": "^3.1.0", + "passport": "^0.7.0", + "passport-local": "^1.0.0", + "prettier": "4.0.0-alpha.8", + "resend": "^3.2.0", + "socket.io": "^4.7.5", + "stripe": "^15.6.0", + "ua-parser-js": "^1.0.37", + "utf-8-validate": "^6.0.4", + "web-push": "^3.6.7" + }, + "devDependencies": { + "@aws-sdk/client-sso-oidc": "^3.574.0", + "@aws-sdk/client-sts": "^3.574.0", + "@rollup/plugin-replace": "^5.0.5", + "@types/body-parser": "^1.19.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/glob": "^8.1.0", + "@types/json-stable-stringify": "^1.0.36", + "@types/passport": "^1.0.16", + "@types/passport-local": "^1.0.38", + "@types/ua-parser-js": "^0.7.39", + "@types/web-push": "^3.6.3", + "glob": "10.3.14", + "p-limit": "^5.0.0", + "prisma": "^5.13.0", + "prisma-json-types-generator": "^3.0.4", + "terminal-link": "^3.0.0" + }, + "prisma": { + "seed": "node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm prisma/seed.tsx" + } +} diff --git a/athena/MapData.tsx b/athena/MapData.tsx new file mode 100644 index 00000000..786723aa --- /dev/null +++ b/athena/MapData.tsx @@ -0,0 +1,649 @@ +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { Decorator, DecoratorInfo, getDecorator } from './info/Decorator.tsx'; +import { ActiveUnitTypes, getActiveUnitTypes, Skill } from './info/Skill.tsx'; +import { + getTile, + getTileInfo, + TileField, + TileInfo, + TileLayer, +} from './info/Tile.tsx'; +import getAllUnitsToRefill from './lib/getAllUnitsToRefill.tsx'; +import getUnitsByPositions from './lib/getUnitsByPositions.tsx'; +import indexToSpriteVector from './lib/indexToSpriteVector.tsx'; +import indexToVector from './lib/indexToVector.tsx'; +import isFuelConsumingUnit from './lib/isFuelConsumingUnit.tsx'; +import { Modifier } from './lib/Modifier.tsx'; +import refillUnits from './lib/refillUnits.tsx'; +import shouldRemoveUnit from './lib/shouldRemoveUnit.tsx'; +import { Biome } from './map/Biome.tsx'; +import Building from './map/Building.tsx'; +import { DecoratorsPerSide } from './map/Configuration.tsx'; +import Entity from './map/Entity.tsx'; +import type { PlainMap, PlainMapConfig } from './map/PlainMap.tsx'; +import Player, { + HumanPlayer, + PlayerID, + PlayerIDs, + toPlayerID, + toPlayerIDs, +} from './map/Player.tsx'; +import { + decodeBuildings, + decodeDecorators, + decodeTeams, + decodeUnits, + encodeDecorators, + encodeEntities, + encodeTeams, +} from './map/Serialization.tsx'; +import Team, { Teams } from './map/Team.tsx'; +import Unit from './map/Unit.tsx'; +import Vector from './map/Vector.tsx'; +import Vision, { Fog, VisionT } from './Vision.tsx'; +import { + decodeWinConditions, + encodeWinConditions, + WinConditions, + WinCriteria, +} from './WinConditions.tsx'; + +export type ID = number; + +export type PlayerOrPlayerID = Player | PlayerID; +type EntityOrPlayerID = + | Readonly<{ + player: PlayerID; + }> + | PlayerID; + +export type AnyEntity = PlayerOrPlayerID | Entity; + +export type TileMap = ReadonlyArray; +export type ModifierField = Modifier | [Modifier, Modifier]; +export type ModifierMap = ReadonlyArray; +export type DecoratorMap = ReadonlyArray; + +const nullPlayer = new HumanPlayer( + 0, + '-1', + 0, + 0, + new Set(), + new Set(), + 0, + null, + 0, +); +const nullTeam = new Team(0, 'null', ImmutableMap([[0, nullPlayer]])); + +export class MapConfig { + constructor( + public readonly multiplier: number, + public readonly seedCapital: number, + public readonly blocklistedBuildings: ReadonlySet, + public readonly blocklistedSkills: ReadonlySet, + public readonly blocklistedUnits: ReadonlySet, + public readonly fog: boolean, + public readonly biome: Biome, + public readonly winConditions: WinConditions, + ) {} + + copy({ + biome, + blocklistedBuildings, + blocklistedSkills, + blocklistedUnits, + fog, + multiplier, + seedCapital, + winConditions, + }: Partial) { + return new MapConfig( + multiplier ?? this.multiplier, + seedCapital ?? this.seedCapital, + blocklistedBuildings ?? this.blocklistedBuildings, + blocklistedSkills ?? this.blocklistedSkills, + blocklistedUnits ?? this.blocklistedUnits, + fog ?? this.fog, + biome ?? this.biome, + winConditions ?? this.winConditions, + ); + } + + toJSON() { + const { + biome, + blocklistedBuildings, + blocklistedSkills, + blocklistedUnits, + fog, + multiplier, + seedCapital, + winConditions, + } = this; + return { + biome, + blocklistedBuildings: [...blocklistedBuildings], + blocklistedSkills: [...blocklistedSkills], + blocklistedUnits: [...blocklistedUnits], + fog, + multiplier, + seedCapital, + winConditions: encodeWinConditions(winConditions), + }; + } +} + +export class SizeVector { + constructor( + public readonly width: number, + public readonly height: number, + ) {} + + equals({ height, width }: SizeVector) { + return this.height === height && this.width === width; + } + + contains({ x, y }: { x: number; y: number }) { + return x > 0 && y > 0 && this.width >= x && this.height >= y; + } + + toDecoratorSizeVector() { + return new SizeVector( + this.width * DecoratorsPerSide, + this.height * DecoratorsPerSide, + ); + } + + toJSON() { + const { height, width } = this; + return { height, width }; + } +} + +const toPlayer = (object: AnyEntity): PlayerID => + typeof object === 'number' + ? object + : object.type === 'entity' + ? object.player + : object.id; + +export default class MapData { + private players: Map; + private playerToTeam: Map; + private _firstPlayer: PlayerID = 0; + private _hasNeutralUnits: boolean | null = null; + private _activeUnitTypes: ReadonlyMap | null = + null; + + constructor( + public readonly map: TileMap, + public readonly modifiers: ModifierMap, + public readonly decorators: DecoratorMap, + public readonly config: MapConfig, + public readonly size: SizeVector, + public readonly currentPlayer: PlayerID, + public readonly round: number, + public readonly active: PlayerIDs, + public readonly teams: Teams, + public readonly buildings: ImmutableMap, + public readonly units: ImmutableMap, + ) { + this.players = new Map( + teams.flatMap((team) => team.players).sortBy(({ id }) => id), + ); + this.playerToTeam = new Map( + [...this.players].map(([id, player]) => { + if (!this._firstPlayer) { + this._firstPlayer = id; + } + + return [id, player.teamId]; + }), + ); + this.playerToTeam.set(0, -1); + } + + contains(size: { x: number; y: number }) { + return this.size.contains(size); + } + + matchesPlayer(objectA: AnyEntity, objectB: AnyEntity) { + return toPlayer(objectA) === toPlayer(objectB); + } + + matchesTeam(objectA: AnyEntity, objectB: AnyEntity) { + return ( + this.playerToTeam.get(toPlayer(objectA)) === + this.playerToTeam.get(toPlayer(objectB)) + ); + } + + isOpponent(objectA: AnyEntity, objectB: AnyEntity) { + return ( + this.playerToTeam.get(toPlayer(objectA)) !== + this.playerToTeam.get(toPlayer(objectB)) + ); + } + + isNeutral(entityA: Entity) { + return entityA.player === 0; + } + + isNonNeutralOpponent(objectA: AnyEntity, entityB: Entity) { + return ( + entityB.player !== 0 && + this.playerToTeam.get(toPlayer(objectA)) !== + this.playerToTeam.get(entityB.player) + ); + } + + hasNeutralUnits() { + if (this._hasNeutralUnits === null) { + this._hasNeutralUnits = this.units.some((unit) => unit.player === 0); + } + return this._hasNeutralUnits; + } + + getActiveUnitTypes() { + return ( + this._activeUnitTypes || + (this._activeUnitTypes = new Map( + this.getPlayers().map((player) => [ + player.id, + getActiveUnitTypes(this, player), + ]), + )) + ); + } + + getTileIndex(vector: Vector) { + return (vector.y - 1) * this.size.width + (vector.x - 1); + } + + getTile(vector: Vector, layer?: TileLayer) { + return this.contains(vector) + ? getTile(this.map[this.getTileIndex(vector)], layer) + : null; + } + + getTileInfo(vector: Vector, layer?: TileLayer) { + if (!this.contains(vector)) { + throw new Error( + `getTileInfo: Vector '${vector.x},${vector.y}' is not within the map limits of width '${this.size.width}' and height '${this.size.height}'.`, + ); + } + + return getTileInfo(this.map[this.getTileIndex(vector)], layer); + } + + maybeGetTileInfo(vector: Vector, layer?: TileLayer) { + if (this.contains(vector)) { + return getTileInfo(this.map[this.getTileIndex(vector)], layer); + } + } + + getModifier(vector: Vector, layer?: TileLayer) { + const modifier = this.modifiers[this.getTileIndex(vector)]; + const isNumber = typeof modifier === 'number'; + + if (layer != null) { + return (isNumber ? (layer === 0 ? modifier : 0) : modifier[layer]) || 0; + } + return (isNumber ? modifier : modifier[1] || modifier[0]) || 0; + } + + getNextPlayer(): Player { + return this.getPlayer( + this.active[this.active.indexOf(this.currentPlayer) + 1] || + this.active[0], + ); + } + + getPlayer(player: EntityOrPlayerID) { + const id = typeof player == 'number' ? player : player.player; + return id === 0 ? nullPlayer : this.players.get(id)!; + } + + maybeGetPlayer(player: EntityOrPlayerID) { + const id = typeof player == 'number' ? player : player.player; + return id === 0 ? nullPlayer : this.players.get(id); + } + + getFirstPlayerID() { + return this._firstPlayer; + } + + getPlayers(): ReadonlyArray { + return [...this.players.values()]; + } + + getPlayerByUserId(id: string): HumanPlayer | null { + for (const [, player] of this.players) { + if (player.isHumanPlayer() && player.userId === id) { + return player; + } + } + return null; + } + + getCurrentPlayer() { + return this.getPlayer(this.currentPlayer); + } + + isCurrentPlayer(object: AnyEntity) { + return this.matchesPlayer(this.currentPlayer, object); + } + + getTeam(player: PlayerOrPlayerID) { + if (typeof player === 'number') { + player = this.getPlayer(player); + } + return player.teamId === 0 ? nullTeam : this.teams.get(player.teamId)!; + } + + maybeGetTeam(maybePlayer: PlayerOrPlayerID) { + const player = + typeof maybePlayer === 'number' + ? this.getPlayer(maybePlayer) + : maybePlayer; + return player + ? player.teamId === 0 + ? nullTeam + : this.teams.get(player.teamId) || null + : null; + } + + isEndOfRound() { + return this.active[0] === this.getNextPlayer().id; + } + + recover(player: PlayerOrPlayerID) { + return this.copy({ + buildings: this.buildings.map((entity) => + this.matchesPlayer(player, entity) ? entity.recover() : entity, + ), + units: this.units.map((entity) => + this.matchesPlayer(player, entity) ? entity.recover() : entity, + ), + }); + } + + refill(player: Player, extraPositions?: ReadonlyArray) { + let map = this.subtractFuel(player); + const units = getAllUnitsToRefill(map, new Vision(player.id), player); + map = refillUnits( + map, + extraPositions + ? new Map([...units, ...getUnitsByPositions(map, extraPositions)]) + : units, + ); + + return map.copy({ + units: map.units.filter( + (unit, vector) => !shouldRemoveUnit(map, vector, unit, player.id), + ), + }); + } + + subtractFuel(player: PlayerOrPlayerID, amount = -1) { + return this.copy({ + units: this.units.map((unit, vector) => + this.matchesPlayer(player, unit) && + isFuelConsumingUnit(unit, this.getTileInfo(vector)) + ? unit.modifyFuel(amount) + : unit, + ), + }); + } + + mapFields(fn: (vector: Vector, index: number) => T): Array { + return this.reduceEachField>((array, vector, index) => { + array.push(fn(vector, index)); + return array; + }, []); + } + + forEachField(fn: (vector: Vector, index: number) => void) { + this.reduceEachField((_, vector, index) => fn(vector, index), void 0); + } + + reduceEachField( + fn: (value: T, vector: Vector, index: number) => T, + value: T, + ): T { + const { map, size } = this; + for (let i = 0; i < map.length; i++) { + value = fn.call(this, value, indexToVector(i, size.width), i); + } + return value; + } + + forEachTile( + fn: ( + vector: Vector, + tile: TileInfo, + layer: TileLayer, + modifier: number, + index: number, + ) => void, + ) { + this.reduceEachTile( + (_, vector, tile, layer, modifier, index) => + fn(vector, tile, layer, modifier, index), + void 0, + ); + } + + private reduceEachTile( + fn: ( + value: T, + vector: Vector, + tile: TileInfo, + layer: TileLayer, + modifier: number, + index: number, + ) => T, + value: T, + ): T { + const { map, modifiers, size } = this; + for (let i = 0; i < map.length; i++) { + const field = map[i]; + if (typeof field === 'number') { + value = fn.call( + this, + value, + indexToVector(i, size.width), + getTileInfo(field), + 0, + modifiers[i] as number, + i, + ); + } else { + const modifier = modifiers[i]; + const [modifier0, modifier1] = (typeof modifier === 'number' + ? [modifier, 0] + : modifier) || [0, 0]; + value = fn.call( + this, + value, + indexToVector(i, size.width), + getTileInfo(field[0]), + 0, + modifier0, + i, + ); + value = fn.call( + this, + value, + indexToVector(i, size.width), + getTileInfo(field[1]), + 1, + modifier1, + i, + ); + } + } + return value; + } + + forEachDecorator(fn: (decorator: DecoratorInfo, vector: Vector) => void) { + this.reduceEachDecorator( + (_, decorator, vector) => fn(decorator, vector), + void 0, + ); + } + + reduceEachDecorator( + fn: (value: T, decorator: DecoratorInfo, vector: Vector) => T, + value: T, + ): T { + const { decorators, size } = this; + for (let i = 0; i < decorators.length; i++) { + const decorator = getDecorator(decorators[i]); + if (decorator) { + value = fn.call( + this, + value, + decorator, + indexToSpriteVector(i, size.width * DecoratorsPerSide), + ); + } + } + return value; + } + + copy({ + active, + buildings, + config, + currentPlayer, + decorators, + map, + modifiers, + round, + size, + teams, + units, + }: { + active?: PlayerIDs; + buildings?: ImmutableMap; + config?: MapConfig; + currentPlayer?: PlayerID; + decorators?: DecoratorMap; + map?: TileMap; + modifiers?: ModifierMap; + round?: number; + size?: SizeVector; + teams?: Teams; + units?: ImmutableMap; + }) { + return new MapData( + map ?? this.map, + modifiers ?? this.modifiers, + decorators ?? this.decorators, + config ?? this.config, + size ?? this.size, + currentPlayer ?? this.currentPlayer, + round ?? this.round, + active ?? this.active, + teams ?? this.teams, + buildings ?? this.buildings, + units ?? this.units, + ); + } + + createVisionObject(player: PlayerOrPlayerID): VisionT { + const viewer = typeof player === 'number' ? player : player.id; + return this.config.fog ? new Fog(viewer) : new Vision(viewer); + } + + static fromObject(data: PlainMap) { + const size = new SizeVector(data.size.width, data.size.height); + return new MapData( + data.map, + data.modifiers, + decodeDecorators(size, data.decorators), + new MapConfig( + data.config.multiplier, + data.config.seedCapital, + new Set(data.config.blocklistedBuildings), + new Set(data.config.blocklistedSkills), + new Set(data.config.blocklistedUnits), + data.config.fog, + data.config.biome, + (data.config.winConditions + ? decodeWinConditions(data.config.winConditions) + : null) || [{ hidden: false, type: WinCriteria.Default }], + ), + size, + toPlayerID(data.currentPlayer), + data.round, + toPlayerIDs(data.active), + decodeTeams(data.teams), + decodeBuildings(data.buildings), + decodeUnits(data.units), + ); + } + + static fromJSON(json: string) { + return MapData.fromObject(JSON.parse(json)); + } + + static createMap({ + config, + ...data + }: Partial> & { + config?: Partial; + }) { + const active = data.teams + ? sortBy( + data.teams.flatMap(({ players }) => players.map(({ id }) => id)), + (id) => id, + ) + : [1, 2]; + return MapData.fromObject({ + active, + buildings: [], + config: { + biome: Biome.Grassland, + blocklistedBuildings: [], + blocklistedUnits: [], + fog: false, + multiplier: 1, + seedCapital: 0, + ...config, + }, + currentPlayer: active[0], + decorators: [], + map: [1], + modifiers: [0], + round: 1, + size: { height: 1, width: 1 }, + teams: [ + { id: 1, name: '', players: [{ funds: 0, id: 1, userId: '-1' }] }, + { id: 2, name: '', players: [{ funds: 0, id: 2, userId: '-2' }] }, + ], + units: [], + ...data, + })!; + } + + toJSON(): PlainMap { + return { + active: this.active, + buildings: encodeEntities(this.buildings), + config: this.config.toJSON(), + currentPlayer: this.currentPlayer, + decorators: encodeDecorators(this), + map: this.map, + modifiers: this.modifiers, + round: this.round, + size: this.size.toJSON(), + teams: encodeTeams(this.teams), + units: encodeEntities(this.units), + }; + } +} diff --git a/athena/Radius.tsx b/athena/Radius.tsx new file mode 100644 index 00000000..23917175 --- /dev/null +++ b/athena/Radius.tsx @@ -0,0 +1,382 @@ +import FastPriorityQueue from 'fastpriorityqueue'; +import { Skill } from './info/Skill.tsx'; +import { TileInfo, TileTypes } from './info/Tile.tsx'; +import { UnitInfo } from './info/Unit.tsx'; +import canLoad from './lib/canLoad.tsx'; +import getVectorRadius from './lib/getVectorRadius.tsx'; +import { EntityType } from './map/Entity.tsx'; +import Unit from './map/Unit.tsx'; +import vec from './map/vec.tsx'; +import Vector from './map/Vector.tsx'; +import MapData from './MapData.tsx'; + +type RadiusConfiguration = { + getCost(map: MapData, unit: Unit, vector: Vector): number; + getResourceValue(unit: Unit): number; + getTransitionCost( + info: UnitInfo, + current: TileInfo, + parent: TileInfo, + ): number; + isAccessible(map: MapData, unit: Unit, vector: Vector): boolean; +}; + +export type RadiusItem = Readonly<{ + cost: number; + parent: Vector | null; + vector: Vector; +}>; + +export const RadiusItem = ( + vector: Vector, + cost: number = 0, + parent?: Vector | null, +) => ({ + cost, + parent: parent && !vector.equals(parent) ? parent : null, + vector, +}); + +function isAccessibleBase(map: MapData, unit: Unit, vector: Vector) { + if (!map.contains(vector)) { + return false; + } + + const unitB = map.units.get(vector); + if (unitB && map.isOpponent(unitB, unit)) { + return false; + } + + const building = map.buildings.get(vector); + if (building && !building.info.isAccessibleBy(unit.info)) { + return false; + } + + return true; +} + +export const MoveConfiguration = { + getCost: (map: MapData, unit: Unit, vector: Vector) => + map.maybeGetTileInfo(vector)?.getMovementCost(unit.info) || -1, + getResourceValue: (unit: Unit) => unit.fuel, + getTransitionCost: (info: UnitInfo, current: TileInfo, parent: TileInfo) => + (current.group !== parent.group && + parent.getTransitionCost(info) + current.getTransitionCost(info)) || + 0, + isAccessible: isAccessibleBase, +} as const; + +const VisionConfiguration = { + getCost: (map: MapData, unit: Unit, vector: Vector) => + map.maybeGetTileInfo(vector)?.configuration.vision || -1, + getResourceValue: () => Number.POSITIVE_INFINITY, + getTransitionCost: () => 0, + isAccessible: (map: MapData, unit: Unit, vector: Vector) => + map.contains(vector), +} as const; + +function calculateRadius( + map: MapData, + unit: Unit, + start: Vector, + radius: number, + { + getCost, + getResourceValue, + getTransitionCost, + isAccessible, + }: RadiusConfiguration = MoveConfiguration, +): Map { + const { info } = unit; + const closed = new Array(map.size.width * map.size.height); + const paths = new Map(); + const queue = new FastPriorityQueue((a, b) => a.cost < b.cost); + queue.add(RadiusItem(start)); + + while (!queue.isEmpty()) { + const { cost: parentCost, vector } = queue.poll()!; + if (closed[map.getTileIndex(vector)]) { + continue; + } + const vectors = vector.adjacent(); + for (let i = 0; i < vectors.length; i++) { + const currentVector = vectors[i]; + if (!map.contains(currentVector)) { + continue; + } + const index = map.getTileIndex(currentVector); + if (closed[index] || currentVector.equals(start)) { + continue; + } + const cost = getCost(map, unit, currentVector); + if (cost < 0 || !isAccessible(map, unit, currentVector)) { + closed[index] = true; + continue; + } + const nextCost = + parentCost + + cost + + getTransitionCost( + info, + map.getTileInfo(vector), + map.getTileInfo(currentVector), + ); + const previousPath = paths.get(currentVector); + if ( + nextCost <= radius && + (!previousPath || nextCost < previousPath.cost) && + nextCost <= getResourceValue(unit) + ) { + const item = { + cost: nextCost, + parent: vector, + vector: currentVector, + }; + paths.set(currentVector, item); + queue.add(item); + } + } + } + return paths; +} + +export function moveable( + map: MapData, + unit: Unit, + start: Vector, + radius: number = unit.info.getRadiusFor(map.getPlayer(unit)), + configuration: RadiusConfiguration = MoveConfiguration, + withStart = false, +): ReadonlyMap { + const moveable = calculateRadius(map, unit, start, radius, configuration); + if (withStart) { + moveable.set(start, RadiusItem(start)); + } + return moveable; +} + +export function getPathCost( + map: MapData, + unit: Unit, + start: Vector, + path: ReadonlyArray, + radius: number = unit.info.getRadiusFor(map.getPlayer(unit)), + { + getCost, + getResourceValue, + getTransitionCost, + isAccessible, + }: RadiusConfiguration = MoveConfiguration, +) { + const { info } = unit; + const seen = new Set([start]); + let previousVector = start; + let totalCost = 0; + + for (const vector of path) { + if (seen.has(vector) || !map.contains(vector)) { + return -1; + } + + seen.add(vector); + if (previousVector.distance(vector) > 1) { + return -1; + } + + const cost = getCost(map, unit, vector); + if (cost < 0 || !isAccessible(map, unit, vector)) { + return -1; + } + + totalCost += + cost + + getTransitionCost( + info, + map.getTileInfo(vector), + map.getTileInfo(previousVector), + ); + + if (totalCost > radius || totalCost > getResourceValue(unit)) { + return -1; + } + + previousVector = vector; + } + + const unitB = map.units.get(previousVector); + return !unitB || canLoad(map, unitB, unit, previousVector) ? totalCost : -1; +} + +export function visible( + map: MapData, + unit: Unit, + start: Vector, + radius: number = unit.info.configuration.vision, +): ReadonlyMap { + const vision = + radius + + (unit.isUnfolded() + ? 2 + : unit.info.type === EntityType.Infantry && + map.getTileInfo(start).type & TileTypes.Mountain + ? 1 + : 0); + + const visible = calculateRadius( + map, + unit, + start, + vision, + VisionConfiguration, + ); + + const player = map.getPlayer(unit); + const canSeeHiddenFields = + player.activeSkills.size && + player.activeSkills.has(Skill.UnitInfantryForestDefenseIncrease); + + for (const [vector] of visible) { + if ( + !canSeeHiddenFields && + vector.distance(start) > 1 && + map.getTileInfo(vector).style.hidden + ) { + visible.delete(vector); + } + } + + for (const vector of start.expand()) { + if (map.contains(vector)) { + visible.set(vector, RadiusItem(vector)); + } + } + return visible; +} + +export function attackable( + map: MapData, + unit: Unit, + start: Vector, + optimize: 'cost' | 'cover', + radius?: number, +): ReadonlyMap { + const player = map.getPlayer(unit); + if (radius == null) { + radius = unit.info.getRadiusFor(player); + } + + const { info } = unit; + const attackable = new Map(); + if (!info.hasAttack()) { + return attackable; + } + + const range = info.getRangeFor(player); + if (info.isLongRange() && range) { + const [low, high] = range; + for (let x = 0; x <= high; x++) { + for (let y = 0; y <= high - x; y++) { + const v1 = vec(start.x + x, start.y + y); + if (start.distance(v1) >= low) { + const s2 = { x: start.x + x, y: start.y - y }; + const v2 = map.contains(s2) && vec(s2.x, s2.y); + const s3 = { x: start.x - x, y: start.y + y }; + const v3 = map.contains(s3) && vec(s3.x, s3.y); + const s4 = { x: start.x - x, y: start.y - y }; + const v4 = map.contains(s4) && vec(s4.x, s4.y); + if (map.contains(v1)) { + attackable.set(v1, RadiusItem(v1)); + } + if (v2) { + attackable.set(v2, RadiusItem(v2)); + } + if (v3) { + attackable.set(v3, RadiusItem(v3)); + } + if (v4) { + attackable.set(v4, RadiusItem(v4)); + } + } + } + } + } + + if (info.isShortRange() || info.canAttackAt(1, range)) { + for (const currentVector of start.adjacent()) { + if (map.contains(currentVector)) { + attackable.set( + currentVector, + RadiusItem( + currentVector, + map.getTileInfo(currentVector).configuration.cover, + start, + ), + ); + } + } + + if (unit.canMove()) { + const moveable = calculateRadius(map, unit, start, radius); + for (const [, parent] of moveable) { + const parentCost = + optimize === 'cover' + ? -map.getTileInfo(parent.vector).configuration.cover + : parent.cost; + const unitB = map.units.get(parent.vector); + // If there is a unit that you own, and it hasn't moved yet, + // you can move it out of the way to make the fields around it attackable. + if ( + unitB && + ((unitB.hasMoved() && map.matchesPlayer(unitB, unit)) || + !map.isOpponent(unitB, unit)) + ) { + continue; + } + + const vectors = parent.vector.adjacent(); + for (let i = 0; i < vectors.length; i++) { + const vector = vectors[i]; + if (map.contains(vector)) { + const itemB = attackable.get(vector); + if ( + !itemB || + (parentCost < itemB.cost && vector.distance(start) > 1) + ) { + attackable.set( + vector, + RadiusItem(vector, parentCost, parent.vector), + ); + } + } + } + } + + if (info.isLongRange() && info.canAttackAt(2, range)) { + const canAttackLargeArea = info.canAttackAt(3, range); + for (const [, item] of Array.from(moveable)) { + const list = canAttackLargeArea + ? getVectorRadius(map, item.vector, 3) + : item.vector.adjacentStar(); + + for (const vector of list) { + const currentAttackable = attackable.get(vector); + if ( + map.contains(vector) && + (!currentAttackable || + (currentAttackable.parent && + map.units.has(currentAttackable.parent) && + !map.units.has(item.vector))) + ) { + attackable.set( + vector, + RadiusItem(vector, item.cost, item.vector), + ); + } + } + } + } + } + } + + return attackable; +} diff --git a/athena/Vision.tsx b/athena/Vision.tsx new file mode 100644 index 00000000..198d5bda --- /dev/null +++ b/athena/Vision.tsx @@ -0,0 +1,91 @@ +import updatePlayers from './lib/updatePlayers.tsx'; +import { PlayerID } from './map/Player.tsx'; +import vec from './map/vec.tsx'; +import Vector from './map/Vector.tsx'; +import MapData from './MapData.tsx'; +import { visible } from './Radius.tsx'; + +export type VisionT = { + apply(map: MapData): MapData; + readonly currentViewer: PlayerID; + isVisible: (map: MapData, vector: Vector) => boolean; +}; + +export default class Vision { + constructor(public readonly currentViewer: PlayerID) {} + + isVisible() { + return true; + } + + apply(map: MapData) { + return map; + } +} + +export class Fog { + private vision: WeakMap>; + + constructor(public readonly currentViewer: PlayerID) { + this.vision = new WeakMap(); + } + + private calculateVision(map: MapData) { + const vision = Array(map.map.length).fill(0); + + map.units.forEach((unit, vector) => { + if (map.matchesTeam(unit, this.currentViewer)) { + visible(map, unit, vector).forEach((item) => { + vision[map.getTileIndex(item.vector)] = 1; + }); + } + }); + map.buildings.forEach((building, vector) => { + if (map.matchesTeam(building, this.currentViewer)) { + vector.expand().forEach((vector) => { + if (map.contains(vector)) { + vision[map.getTileIndex(vector)] = 1; + } + }); + } + }); + + this.vision.set(map, vision); + return vision; + } + + isVisible(map: MapData, vector: Vector) { + if (this.currentViewer === 0) { + return false; + } + + const vision = this.vision.get(map) || this.calculateVision(map); + return map.contains(vector) && vision[map.getTileIndex(vector)] === 1; + } + + apply(map: MapData) { + // Prime the cache. + this.isVisible(map, vec(1, 1)); + // Fall back to hide everything if the viewer is not a player or spectator. + const vision = this.vision.get(map) || []; + const team = map.maybeGetTeam(this.currentViewer); + return map.copy({ + buildings: map.buildings.map((building, vector) => + vision[map.getTileIndex(vector)] === 1 + ? building + : building.hide(map.config.biome), + ), + teams: updatePlayers( + map.teams, + map + .getPlayers() + .map((player) => + team?.players.has(player.id) ? player : player.resetStatistics(), + ), + ), + units: map.units.filter( + (_, vector) => vision[map.getTileIndex(vector)] === 1, + ), + }); + } +} diff --git a/athena/WinConditions.tsx b/athena/WinConditions.tsx new file mode 100644 index 00000000..c5ee99cd --- /dev/null +++ b/athena/WinConditions.tsx @@ -0,0 +1,797 @@ +import isPositiveInteger from '@deities/hephaestus/isPositiveInteger.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { + PlayerID, + PlayerIDs, + PlayerIDSet, + toPlayerIDs, +} from './map/Player.tsx'; +import { + EncodedReward, + maybeDecodeReward, + maybeEncodeReward, + Reward, + validateReward, +} from './map/Reward.tsx'; +import Vector, { decodeVectorArray, encodeVectorArray } from './map/Vector.tsx'; +import MapData from './MapData.tsx'; + +export enum WinCriteria { + Default = 0, + CaptureLabel = 1, + CaptureAmount = 2, + DefeatLabel = 3, + EscortLabel = 4, + Survival = 5, + EscortAmount = 6, + RescueLabel = 8, + DefeatAmount = 9, + DefeatOneLabel = 10, + DestroyLabel = 11, + DestroyAmount = 12, +} + +export const WinCriteriaList = [ + WinCriteria.Default, + WinCriteria.CaptureLabel, + WinCriteria.CaptureAmount, + WinCriteria.DefeatLabel, + WinCriteria.DefeatOneLabel, + WinCriteria.DefeatAmount, + WinCriteria.EscortLabel, + WinCriteria.EscortAmount, + WinCriteria.Survival, + WinCriteria.RescueLabel, + WinCriteria.DestroyLabel, + WinCriteria.DestroyAmount, +] as const; + +export const WinCriteriaListWithoutDefault = [ + WinCriteria.CaptureLabel, + WinCriteria.CaptureAmount, + WinCriteria.DefeatLabel, + WinCriteria.DefeatAmount, + WinCriteria.EscortLabel, + WinCriteria.EscortAmount, + WinCriteria.Survival, + WinCriteria.RescueLabel, + WinCriteria.DefeatOneLabel, + WinCriteria.DestroyLabel, + WinCriteria.DestroyAmount, +] as const; + +export const MIN_AMOUNT = 1; +export const MAX_AMOUNT = 128; +export const MIN_ROUNDS = 1; +export const MAX_ROUNDS = 1024; + +type CaptureLabelWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.CaptureLabel; +}>; + +type CaptureAmountWinCondition = Readonly<{ + amount: number; + hidden: boolean; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.CaptureAmount; +}>; + +type DefeatWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.DefeatLabel; +}>; + +type SurvivalWinCondition = Readonly<{ + hidden: boolean; + players: PlayerIDs; + reward?: Reward | null; + rounds: number; + type: WinCriteria.Survival; +}>; + +type EscortLabelWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.EscortLabel; + vectors: ReadonlySet; +}>; + +type EscortAmountWinCondition = Readonly<{ + amount: number; + hidden: boolean; + label?: PlayerIDSet; + players: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.EscortAmount; + vectors: ReadonlySet; +}>; + +type RescueLabelWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.RescueLabel; +}>; + +type DefeatAmountWinCondition = Readonly<{ + amount: number; + hidden: boolean; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.DefeatAmount; +}>; + +type DefeatOneLabelWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.DefeatOneLabel; +}>; + +type DestroyLabelWinCondition = Readonly<{ + hidden: boolean; + label: PlayerIDSet; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.DestroyLabel; +}>; + +type DestroyAmountWinCondition = Readonly<{ + amount: number; + hidden: boolean; + players?: PlayerIDs; + reward?: Reward | null; + type: WinCriteria.DestroyAmount; +}>; + +export type WinConditionsWithVectors = + | EscortLabelWinCondition + | EscortAmountWinCondition; + +export type WinCondition = + | Readonly<{ + hidden: boolean; + reward?: Reward | null; + type: WinCriteria.Default; + }> + | CaptureAmountWinCondition + | CaptureLabelWinCondition + | DefeatAmountWinCondition + | DefeatOneLabelWinCondition + | DefeatWinCondition + | DestroyAmountWinCondition + | DestroyLabelWinCondition + | EscortAmountWinCondition + | EscortLabelWinCondition + | RescueLabelWinCondition + | SurvivalWinCondition; + +export type PlainWinCondition = + | [type: WinCriteria.Default, hidden: 0 | 1, reward?: EncodedReward | null] + | [ + type: WinCriteria.CaptureLabel, + hidden: 0 | 1, + label: ReadonlyArray, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.CaptureAmount, + hidden: 0 | 1, + amount: number, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.DefeatLabel, + hidden: 0 | 1, + label: ReadonlyArray, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.EscortLabel, + hidden: 0 | 1, + label: ReadonlyArray, + players: ReadonlyArray, + vectors: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.Survival, + hidden: 0 | 1, + rounds: number, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.EscortAmount, + hidden: 0 | 1, + amount: number, + players: ReadonlyArray, + vectors: ReadonlyArray, + label: null | ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.RescueLabel, + hidden: 0 | 1, + label: ReadonlyArray, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.DefeatAmount, + hidden: 0 | 1, + amount: number, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.DefeatOneLabel, + hidden: 0 | 1, + label: null | ReadonlyArray, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.DestroyLabel, + hidden: 0 | 1, + label: ReadonlyArray, + players: ReadonlyArray, + reward?: EncodedReward | null, + ] + | [ + type: WinCriteria.DestroyAmount, + hidden: 0 | 1, + amount: number, + players: ReadonlyArray, + reward?: EncodedReward | null, + ]; + +export type WinConditions = ReadonlyArray; +export type PlainWinConditions = ReadonlyArray; + +export function encodeWinCondition(condition: WinCondition): PlainWinCondition { + const { hidden, type } = condition; + switch (type) { + case WinCriteria.Default: + return [type, hidden ? 1 : 0, maybeEncodeReward(condition.reward)]; + case WinCriteria.CaptureLabel: + case WinCriteria.DestroyLabel: + return [ + type, + hidden ? 1 : 0, + Array.from(condition.label), + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.CaptureAmount: + case WinCriteria.DestroyAmount: + return [ + type, + hidden ? 1 : 0, + condition.amount, + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.DefeatLabel: + return [ + type, + hidden ? 1 : 0, + Array.from(condition.label), + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.EscortLabel: + return [ + type, + hidden ? 1 : 0, + Array.from(condition.label), + condition.players || [], + encodeVectorArray([...condition.vectors]), + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.Survival: + return [ + type, + hidden ? 1 : 0, + condition.rounds, + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.EscortAmount: + return [ + type, + hidden ? 1 : 0, + condition.amount, + condition.players, + encodeVectorArray([...condition.vectors]), + condition.label ? Array.from(condition.label) : [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.RescueLabel: + return [ + type, + hidden ? 1 : 0, + Array.from(condition.label), + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.DefeatAmount: + return [ + type, + hidden ? 1 : 0, + condition.amount, + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + case WinCriteria.DefeatOneLabel: + return [ + type, + hidden ? 1 : 0, + condition.label ? Array.from(condition.label) : [], + condition.players || [], + maybeEncodeReward(condition.reward), + ]; + default: { + condition satisfies never; + throw new UnknownTypeError('encodeWinCondition', type); + } + } +} + +export function decodeWinCondition(condition: PlainWinCondition): WinCondition { + const type = condition[0]; + switch (type) { + case WinCriteria.Default: { + return { + hidden: !!condition[1], + reward: maybeDecodeReward(condition[2]), + type, + }; + } + case WinCriteria.CaptureLabel: + case WinCriteria.DestroyLabel: + return { + hidden: !!condition[1], + label: new Set(toPlayerIDs(condition[2])), + players: condition[3] ? toPlayerIDs(condition[3]) : undefined, + reward: maybeDecodeReward(condition[4]), + type, + }; + case WinCriteria.CaptureAmount: + case WinCriteria.DestroyAmount: + return { + amount: condition[2]!, + hidden: !!condition[1], + players: condition[3] ? toPlayerIDs(condition[3]) : undefined, + reward: maybeDecodeReward(condition[4]), + type, + }; + case WinCriteria.DefeatLabel: + return { + hidden: !!condition[1], + label: new Set(toPlayerIDs(condition[2])), + players: condition[3] ? toPlayerIDs(condition[3]) : undefined, + reward: maybeDecodeReward(condition[4]), + type, + }; + case WinCriteria.EscortLabel: + return { + hidden: !!condition[1], + label: new Set(toPlayerIDs(condition[2])), + players: toPlayerIDs(condition[3]), + reward: maybeDecodeReward(condition[5]), + type, + vectors: new Set(decodeVectorArray(condition[4])), + }; + case WinCriteria.Survival: + return { + hidden: !!condition[1], + players: toPlayerIDs(condition[3]), + reward: maybeDecodeReward(condition[4]), + rounds: condition[2]!, + type, + }; + case WinCriteria.EscortAmount: + return { + amount: condition[2], + hidden: !!condition[1], + label: condition[5] ? new Set(toPlayerIDs(condition[5])) : undefined, + players: toPlayerIDs(condition[3]), + reward: maybeDecodeReward(condition[6]), + type, + vectors: new Set(decodeVectorArray(condition[4])), + }; + case WinCriteria.RescueLabel: + return { + hidden: !!condition[1], + label: new Set(toPlayerIDs(condition[2])), + players: condition[3] ? toPlayerIDs(condition[3]) : undefined, + reward: maybeDecodeReward(condition[4]), + type, + }; + case WinCriteria.DefeatAmount: + return { + amount: condition[2], + hidden: !!condition[1], + players: toPlayerIDs(condition[3]), + reward: maybeDecodeReward(condition[4]), + type, + }; + case WinCriteria.DefeatOneLabel: + return { + hidden: !!condition[1], + label: condition[2] ? new Set(toPlayerIDs(condition[2])) : new Set(), + players: condition[3] ? toPlayerIDs(condition[3]) : undefined, + reward: maybeDecodeReward(condition[4]), + type, + }; + default: { + condition satisfies never; + throw new UnknownTypeError('decodeWinCondition', type); + } + } +} + +export function encodeWinConditions(conditions: WinConditions) { + return conditions.map(encodeWinCondition); +} + +export function decodeWinConditions(conditions: PlainWinConditions) { + return conditions.map(decodeWinCondition); +} + +export function formatWinCondition(condition: WinCondition) { + const newCondition: Record = { ...condition }; + if ('label' in condition && condition.label) { + newCondition.label = Array.from(condition.label || []); + } + if ('players' in condition && condition.players) { + newCondition.players = Array.from(condition.players || []); + } + if ('vectors' in condition) { + newCondition.vectors = Array.from(condition.vectors).map(String); + } + return newCondition; +} + +export function winConditionHasVectors( + condition: WinCondition, +): condition is EscortLabelWinCondition | EscortAmountWinCondition { + const { type } = condition; + return type === WinCriteria.EscortLabel || type === WinCriteria.EscortAmount; +} + +export function winConditionHasLabel( + condition: WinCondition, +): condition is + | CaptureLabelWinCondition + | DefeatOneLabelWinCondition + | DefeatWinCondition + | DestroyLabelWinCondition + | EscortAmountWinCondition + | EscortLabelWinCondition + | RescueLabelWinCondition { + const { type } = condition; + return ( + type === WinCriteria.CaptureLabel || + type === WinCriteria.DefeatLabel || + type === WinCriteria.DefeatOneLabel || + type === WinCriteria.DestroyLabel || + type === WinCriteria.EscortLabel || + type === WinCriteria.EscortAmount || + type === WinCriteria.RescueLabel + ); +} + +export function winConditionHasAmounts( + condition: WinCondition, +): condition is + | CaptureAmountWinCondition + | DefeatAmountWinCondition + | DestroyAmountWinCondition + | EscortAmountWinCondition { + const { type } = condition; + return ( + type === WinCriteria.CaptureAmount || + type === WinCriteria.DestroyAmount || + type === WinCriteria.DefeatAmount || + type === WinCriteria.EscortAmount + ); +} + +export function winConditionHasRounds( + condition: WinCondition, +): condition is SurvivalWinCondition { + const { type } = condition; + return type === WinCriteria.Survival; +} + +export function getOpponentPriorityLabels( + conditions: WinConditions, + player: PlayerID, +) { + return new Set( + conditions.flatMap((condition) => + winConditionHasLabel(condition) && + condition.label && + (((condition.type === WinCriteria.DefeatLabel || + condition.type === WinCriteria.DestroyLabel || + condition.type === WinCriteria.DefeatOneLabel) && + (!condition.players || condition.players.includes(player))) || + ((condition.type === WinCriteria.EscortAmount || + condition.type === WinCriteria.EscortLabel || + condition.type === WinCriteria.RescueLabel) && + condition.players && + !condition.players.includes(player))) + ? [...condition.label] + : [], + ), + ); +} + +const validateLabel = (label: PlayerIDSet) => { + if (!label.size) { + return false; + } + + toPlayerIDs([...label]); + return true; +}; +const validatePlayers = (map: MapData, players: PlayerIDs) => { + if (players.length > 0) { + const playerIDSet = new Set(toPlayerIDs(players)); + if (playerIDSet.size !== players.length) { + return false; + } + for (const player of playerIDSet) { + if (!map.maybeGetPlayer(player)) { + return false; + } + } + return true; + } + return false; +}; + +const validateAmount = (amount: number) => + isPositiveInteger(amount) && amount >= MIN_AMOUNT && amount <= MAX_AMOUNT; + +export function validateWinCondition(map: MapData, condition: WinCondition) { + const { hidden, type } = condition; + if ( + (hidden !== false && hidden !== true) || + (condition.reward && !validateReward(condition.reward)) + ) { + return false; + } + + const validateVector = (vector: Vector) => map.contains(vector); + + switch (type) { + case WinCriteria.Default: + return true; + case WinCriteria.CaptureLabel: + case WinCriteria.DefeatLabel: + case WinCriteria.DefeatOneLabel: + case WinCriteria.DestroyLabel: + case WinCriteria.RescueLabel: + return ( + validateLabel(condition.label) && + (condition.players?.length + ? validatePlayers(map, condition.players) + : true) + ); + case WinCriteria.CaptureAmount: + case WinCriteria.DefeatAmount: + case WinCriteria.DestroyAmount: + if (!validateAmount(condition.amount)) { + return false; + } + return condition.players?.length + ? validatePlayers(map, condition.players) + : true; + case WinCriteria.EscortLabel: + if (![...condition.vectors].every(validateVector)) { + return false; + } + return ( + validateLabel(condition.label) && + validatePlayers(map, condition.players) + ); + case WinCriteria.Survival: + if ( + !isPositiveInteger(condition.rounds) || + condition.rounds < MIN_ROUNDS || + condition.rounds > MAX_ROUNDS + ) { + return false; + } + + if (!validatePlayers(map, condition.players)) { + return false; + } + + return condition.players.includes(map.active[0]) + ? condition.rounds > 1 + : true; + case WinCriteria.EscortAmount: + if (condition.label?.size && !validateLabel(condition.label)) { + return false; + } + + if ( + !isPositiveInteger(condition.amount) || + condition.amount > MAX_AMOUNT + ) { + return false; + } + + return ( + validatePlayers(map, toPlayerIDs(condition.players)) && + [...condition.vectors].every(validateVector) + ); + default: { + condition satisfies never; + return false; + } + } +} + +export function validateWinConditions(map: MapData) { + const { winConditions } = map.config; + if (Array.isArray(winConditions) && winConditions.length <= 32) { + if ( + winConditions.filter(({ type }) => type === WinCriteria.Default).length > + 1 + ) { + return false; + } + return winConditions.every(validateWinCondition.bind(null, map)); + } + + return false; +} + +export function dropInactivePlayersFromWinConditions( + conditions: WinConditions, + active: PlayerIDSet, +): WinConditions { + return conditions.map((condition) => + condition.type === WinCriteria.Default || !condition.players + ? condition + : ({ + ...condition, + players: condition.players.filter((player) => active.has(player)), + } as const), + ); +} + +export function onlyHasDefaultWinCondition(winConditions: WinConditions) { + return ( + winConditions.length === 0 || + (winConditions.length === 1 && + winConditions[0].type === WinCriteria.Default) + ); +} + +export function getHiddenLabels(conditions: WinConditions): PlayerIDSet | null { + if (onlyHasDefaultWinCondition(conditions)) { + return null; + } + + let labels: Set | null = null; + for (const condition of conditions) { + if ( + condition.hidden && + winConditionHasLabel(condition) && + condition.label + ) { + for (const label of condition.label) { + if (!labels) { + labels = new Set(); + } + + labels.add(label); + } + } + } + return labels; +} + +export function getInitialWinCondition( + map: MapData, + criteria: WinCriteria, +): WinCondition { + const hidden = false; + const currentPlayer = map.getCurrentPlayer().id; + const players = [currentPlayer > 0 ? currentPlayer : map.active[0]]; + const label = new Set(players); + switch (criteria) { + case WinCriteria.Default: + return { + hidden, + type: criteria, + }; + case WinCriteria.CaptureLabel: + case WinCriteria.DestroyLabel: + return { + hidden, + label, + type: criteria, + }; + case WinCriteria.DefeatLabel: + return { + hidden, + label, + type: criteria, + }; + case WinCriteria.CaptureAmount: + case WinCriteria.DestroyAmount: + return { + amount: 10, + hidden, + type: criteria, + }; + case WinCriteria.EscortLabel: + return { + hidden, + label, + players, + type: criteria, + vectors: new Set(), + }; + case WinCriteria.Survival: + return { + hidden, + players, + rounds: MIN_ROUNDS + 4, + type: criteria, + }; + case WinCriteria.EscortAmount: + return { + amount: 1, + hidden, + players, + type: criteria, + vectors: new Set(), + }; + case WinCriteria.RescueLabel: + return { + hidden, + label, + type: criteria, + }; + case WinCriteria.DefeatAmount: + return { + amount: 5, + hidden, + players, + type: criteria, + }; + case WinCriteria.DefeatOneLabel: + return { + hidden, + label, + type: criteria, + }; + default: { + criteria satisfies never; + throw new UnknownTypeError('getInitialWinCondition', criteria); + } + } +} diff --git a/athena/__tests__/MapData.test.tsx b/athena/__tests__/MapData.test.tsx new file mode 100644 index 00000000..a41f7e1e --- /dev/null +++ b/athena/__tests__/MapData.test.tsx @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest'; +import map from '../../hermes/map-fixtures/they-are-close-to-home.tsx'; +import { Pioneer } from '../info/Unit.tsx'; +import canDeploy from '../lib/canDeploy.tsx'; +import MapData from '../MapData.tsx'; +import vec from './../map/vec.tsx'; + +const player1 = map.getPlayer(1); +const player2 = map.getPlayer(2); + +test('serializing to JSON', () => { + const json = JSON.stringify(map); + const newMap = MapData.fromJSON(json); + + expect(JSON.parse(json)).toEqual(map.toJSON()); + expect(newMap).toEqual(map); +}); + +test('allow entity lookups through vectors', () => { + const unit = map.units.get(vec(7, 5))!; + + expect(unit.id).toBe(4); + expect(unit.health).toBe(100); + expect(unit.player).toBe(2); + expect(unit.fuel).toBe(40); +}); + +test('support for basic queries', () => { + const unitA = map.units.get(vec(5, 6))!; + const unitB = map.units.get(vec(7, 5))!; + + expect(map.contains(vec(-1, -1))).toBe(false); + expect(map.contains(vec(5, 4))).toBe(true); + expect(map.contains(vec(15, 10))).toBe(true); + expect(map.contains(vec(16, 10))).toBe(false); + expect(map.contains(vec(15, 11))).toBe(false); + + expect(canDeploy(map, Pioneer, vec(7, 5), false)).toBe(false); + expect(canDeploy(map, Pioneer, vec(7, 7), false)).toBe(true); + expect(canDeploy(map, Pioneer, vec(11, 17), false)).toBe(false); + expect(canDeploy(map, Pioneer, vec(1, 1), false)).toBe(false); + + expect(map.matchesPlayer(1, 1)).toBe(true); + expect(map.matchesPlayer(map.getPlayer(1), 1)).toBe(true); + expect(map.matchesPlayer(unitB, 1)).toBe(false); + + expect(map.isOpponent(1, 1)).toBe(false); + expect(map.isOpponent(unitB, 1)).toBe(true); + + expect(map.getNextPlayer()).toBe(player2); + expect(map.isEndOfRound()).toBe(false); + + expect(map.getCurrentPlayer()).toBe(player1); + + expect(map.isCurrentPlayer(player1)).toBe(true); + expect(map.isCurrentPlayer(player2)).toBe(false); + expect(map.isCurrentPlayer(unitA)).toBe(true); + expect(map.isCurrentPlayer(unitB)).toBe(false); + + expect(map.getTeam(1)).toBe(map.teams.get(1)); + expect(map.getTeam(player2)).toBe(map.teams.get(2)); +}); diff --git a/athena/__tests__/Player.test.tsx b/athena/__tests__/Player.test.tsx new file mode 100644 index 00000000..06c7543f --- /dev/null +++ b/athena/__tests__/Player.test.tsx @@ -0,0 +1,57 @@ +import { expect, test } from 'vitest'; +import { resolveDynamicPlayerID } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; + +test('`resolveDynamicPlayerID` resolves to the correct players', () => { + const map = MapData.createMap({ + map: Array(3 * 3).fill(1), + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }); + + expect(resolveDynamicPlayerID(map, 'self')).toBe(1); + expect(resolveDynamicPlayerID(map, 'team')).toBe(1); + expect(resolveDynamicPlayerID(map, 'opponent')).toBe(2); +}); + +test('`resolveDynamicPlayerID` always prefers human players', () => { + const map = MapData.createMap({ + currentPlayer: 2, + map: Array(3 * 3).fill(1), + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [ + { funds: 0, id: 1, name: 'AI-1' }, + { funds: 0, id: 3, userId: '1' }, + ], + }, + { + id: 2, + name: '', + players: [ + { funds: 0, id: 2, name: 'AI-2' }, + { funds: 0, id: 4, name: 'AI-3' }, + { funds: 0, id: 5, userId: '3' }, + ], + }, + ], + }); + + expect(resolveDynamicPlayerID(map, 'self')).toBe(2); + expect(resolveDynamicPlayerID(map, 'team')).toBe(5); + expect(resolveDynamicPlayerID(map, 'opponent')).toBe(3); +}); diff --git a/athena/__tests__/Radius.test.tsx b/athena/__tests__/Radius.test.tsx new file mode 100644 index 00000000..51fef775 --- /dev/null +++ b/athena/__tests__/Radius.test.tsx @@ -0,0 +1,1224 @@ +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import { expect, test } from 'vitest'; +import startMap from '../../hermes/map-fixtures/they-are-close-to-home.tsx'; +import { XFighter } from '../info/Unit.tsx'; +import MapData, { SizeVector } from '../MapData.tsx'; +import { attackable, moveable } from '../Radius.tsx'; +import vec from './../map/vec.tsx'; + +const radiusTestMap = MapData.createMap({ + buildings: [ + [ + 1, + 1, + { + h: 100, + i: 1, + p: 1, + }, + ], + [ + 2, + 1, + { + h: 100, + i: 1, + p: 2, + }, + ], + ], + map: [ + 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, + ], + size: { + height: 5, + width: 5, + }, + teams: [ + { + id: 1, + name: '', + players: [ + { + funds: 500, + id: 1, + }, + ], + }, + { + id: 2, + name: '', + players: [ + { + funds: 500, + id: 2, + }, + ], + }, + ], + units: [ + [ + 1, + 1, + { + a: [[1, 7]], + g: 30, + h: 100, + i: 5, + p: 2, + }, + ], + [ + 1, + 2, + { + g: 50, + h: 100, + i: 2, + p: 2, + }, + ], + [ + 3, + 1, + { + a: [[1, 7]], + g: 30, + h: 100, + i: 5, + p: 1, + }, + ], + [ + 1, + 3, + { + a: [[1, 4]], + g: 15, + h: 100, + i: 12, + p: 2, + }, + ], + [ + 2, + 3, + { + a: [[1, 4]], + g: 40, + h: 100, + i: 3, + p: 2, + }, + ], + [ + 4, + 2, + { + a: [[1, 7]], + g: 30, + h: 100, + i: 5, + p: 1, + }, + ], + [ + 3, + 3, + { + a: [[1, 4]], + g: 40, + h: 100, + i: 3, + p: 1, + }, + ], + [ + 2, + 4, + { + a: [[1, 4]], + g: 15, + h: 100, + i: 12, + p: 2, + }, + ], + [ + 4, + 3, + { + g: 50, + h: 100, + i: 2, + p: 1, + }, + ], + [ + 5, + 3, + { + a: [[1, 7]], + g: 30, + h: 100, + i: 5, + p: 1, + }, + ], + [ + 4, + 4, + { + g: 50, + h: 100, + i: 2, + p: 1, + }, + ], + [ + 3, + 5, + { + g: 40, + h: 100, + i: 1, + p: 1, + }, + ], + ], +}); + +test('calculate the moveable radius', () => { + const testMap = startMap.copy({ + units: startMap.units.delete(vec(11, 8)), + }); + const vecA = vec(11, 7); + const unitA = testMap.units.get(vecA); + + if (!unitA) { + throw new Error(`Radius.test: 'unitA' not found at position ${vecA}.`); + } + + expect( + [...moveable(testMap, unitA, vecA, 1).values()].map(({ vector }) => vector), + ).toEqual([vec(11, 6), vec(11, 8), vec(10, 7)]); + + expect( + [...moveable(testMap, unitA, vecA, 2).values()] + .map(({ vector }) => vector) + .sort(), + ).toEqual( + [ + vec(9, 7), + vec(11, 5), + vec(10, 6), + vec(11, 6), + vec(10, 7), + vec(10, 8), + vec(11, 8), + vec(11, 9), + ].sort(), + ); + + expect(Array.from(attackable(testMap, unitA, vecA, 'cost').keys()).sort()) + .toMatchInlineSnapshot(` + [ + [ + 10, + 10, + ], + [ + 10, + 4, + ], + [ + 10, + 5, + ], + [ + 10, + 6, + ], + [ + 10, + 7, + ], + [ + 10, + 8, + ], + [ + 10, + 9, + ], + [ + 11, + 10, + ], + [ + 11, + 3, + ], + [ + 11, + 4, + ], + [ + 11, + 5, + ], + [ + 11, + 6, + ], + [ + 11, + 7, + ], + [ + 11, + 8, + ], + [ + 11, + 9, + ], + [ + 12, + 10, + ], + [ + 12, + 4, + ], + [ + 12, + 5, + ], + [ + 12, + 6, + ], + [ + 12, + 7, + ], + [ + 12, + 8, + ], + [ + 12, + 9, + ], + [ + 13, + 5, + ], + [ + 13, + 6, + ], + [ + 13, + 9, + ], + [ + 8, + 8, + ], + [ + 9, + 5, + ], + [ + 9, + 6, + ], + [ + 9, + 7, + ], + [ + 9, + 8, + ], + [ + 9, + 9, + ], + ] + `); +}); + +test('verifies that the attackable radius is always correct', () => { + const map = radiusTestMap; + const player1 = map.getPlayer(1); + const vectors = [vec(3, 3), vec(4, 2), vec(4, 3), vec(4, 4)]; + const units = vectors + .map((vector) => map.units.get(vector)) + .filter(isPresent); + + const getAttackable = (id: number) => + Array.from(attackable(map, units[id], vectors[id], 'cost').values()) + .filter(({ vector }) => { + const unit = map.units.get(vector); + return unit && map.isOpponent(unit, player1); + }) + .map(({ parent, vector }) => ({ parent, vector })) + .sort(({ vector: vecA }, { vector: vecB }) => + String(vecA).localeCompare(String(vecB)), + ); + + expect(getAttackable(0)).toMatchInlineSnapshot(` + [ + { + "parent": [ + 2, + 1, + ], + "vector": [ + 1, + 1, + ], + }, + { + "parent": [ + 2, + 2, + ], + "vector": [ + 1, + 2, + ], + }, + { + "parent": [ + 3, + 3, + ], + "vector": [ + 2, + 3, + ], + }, + { + "parent": [ + 3, + 4, + ], + "vector": [ + 2, + 4, + ], + }, + ] + `); + + expect(getAttackable(1)).toMatchInlineSnapshot(` + [ + { + "parent": [ + 2, + 1, + ], + "vector": [ + 1, + 1, + ], + }, + { + "parent": [ + 2, + 2, + ], + "vector": [ + 1, + 2, + ], + }, + { + "parent": [ + 2, + 2, + ], + "vector": [ + 2, + 3, + ], + }, + { + "parent": [ + 3, + 4, + ], + "vector": [ + 2, + 4, + ], + }, + ] + `); + + expect(getAttackable(2)).toMatchInlineSnapshot(` + [ + { + "parent": [ + 2, + 2, + ], + "vector": [ + 1, + 2, + ], + }, + { + "parent": [ + 2, + 2, + ], + "vector": [ + 2, + 3, + ], + }, + { + "parent": [ + 3, + 4, + ], + "vector": [ + 2, + 4, + ], + }, + ] + `); + + expect(getAttackable(3)).toMatchInlineSnapshot(` + [ + { + "parent": [ + 3, + 4, + ], + "vector": [ + 2, + 4, + ], + }, + ] + `); +}); + +test('Trenches give a movement bonus to infantry units', () => { + const vecA = vec(2, 3); + const vecB = vec(8, 3); + const map = MapData.createMap({ + map: [ + 19, 19, 19, 19, 1, 1, 1, 1, 6, 6, 1, 19, 1, 1, 1, 1, 6, 19, 19, 19, 19, + 19, 19, 1, 6, 1, 1, 19, 1, 1, 1, 1, 6, 19, 19, 19, 1, 1, 1, 1, + ], + size: { + height: 5, + width: 8, + }, + teams: [ + { + id: 1, + name: '', + players: [ + { + funds: 500, + id: 1, + }, + ], + }, + { + id: 2, + name: '', + players: [ + { + funds: 500, + id: 2, + }, + ], + }, + ], + units: [ + [ + 8, + 3, + { + a: [[1, 4]], + g: 30, + h: 100, + i: 15, + p: 2, + }, + ], + [ + 2, + 3, + { + g: 40, + h: 100, + i: 1, + p: 1, + }, + ], + ], + }); + + const unitA = map.units.get(vecA)!; + const unitB = map.units.get(vecB)!; + const moveableA = moveable(map, unitA, vecA); + const moveableB = moveable(map, unitB, vecB); + expect(Array.from(moveableA.keys()).sort()).toMatchInlineSnapshot(` + [ + [ + 2, + 1, + ], + [ + 2, + 4, + ], + [ + 2, + 5, + ], + [ + 3, + 1, + ], + [ + 3, + 2, + ], + [ + 3, + 3, + ], + [ + 3, + 4, + ], + [ + 3, + 5, + ], + [ + 4, + 1, + ], + [ + 4, + 2, + ], + [ + 4, + 3, + ], + [ + 4, + 4, + ], + [ + 4, + 5, + ], + [ + 5, + 2, + ], + [ + 5, + 3, + ], + [ + 5, + 4, + ], + [ + 6, + 3, + ], + [ + 7, + 3, + ], + ] + `); + + expect(Array.from(moveableB.keys()).sort()).toMatchInlineSnapshot(` + [ + [ + 3, + 1, + ], + [ + 3, + 3, + ], + [ + 3, + 5, + ], + [ + 4, + 1, + ], + [ + 4, + 2, + ], + [ + 4, + 3, + ], + [ + 4, + 4, + ], + [ + 4, + 5, + ], + [ + 5, + 2, + ], + [ + 5, + 3, + ], + [ + 5, + 4, + ], + [ + 6, + 1, + ], + [ + 6, + 2, + ], + [ + 6, + 3, + ], + [ + 6, + 4, + ], + [ + 6, + 5, + ], + [ + 7, + 1, + ], + [ + 7, + 2, + ], + [ + 7, + 3, + ], + [ + 7, + 4, + ], + [ + 7, + 5, + ], + [ + 8, + 1, + ], + [ + 8, + 2, + ], + [ + 8, + 4, + ], + [ + 8, + 5, + ], + ] + `); + + expect(moveableA.get(vec(2, 1))!.cost).toBe(3); + expect(moveableA.get(vec(3, 1))!.cost).toBe(2.5); + expect(moveableA.get(vec(3, 2))!.cost).toBe(2); + expect(moveableB.get(vec(6, 2))!.cost).toBe(3); + expect(moveableB.get(vec(6, 3))!.cost).toBe(1.5); +}); + +test('verifies that the attackable radius is always correct', () => { + const vecA = vec(7, 7); + const unitA = XFighter.create(1); + const map = radiusTestMap.copy({ + map: Array(14 * 14).fill(1), + size: new SizeVector(14, 14), + units: radiusTestMap.units.set(vecA, unitA), + }); + + expect(Array.from(attackable(map, unitA, vecA, 'cost').keys()).sort()) + .toMatchInlineSnapshot(` + [ + [ + 1, + 6, + ], + [ + 1, + 7, + ], + [ + 1, + 8, + ], + [ + 10, + 10, + ], + [ + 10, + 11, + ], + [ + 10, + 3, + ], + [ + 10, + 4, + ], + [ + 10, + 5, + ], + [ + 10, + 6, + ], + [ + 10, + 7, + ], + [ + 10, + 8, + ], + [ + 10, + 9, + ], + [ + 11, + 10, + ], + [ + 11, + 4, + ], + [ + 11, + 5, + ], + [ + 11, + 6, + ], + [ + 11, + 7, + ], + [ + 11, + 8, + ], + [ + 11, + 9, + ], + [ + 12, + 5, + ], + [ + 12, + 6, + ], + [ + 12, + 7, + ], + [ + 12, + 8, + ], + [ + 12, + 9, + ], + [ + 13, + 6, + ], + [ + 13, + 7, + ], + [ + 13, + 8, + ], + [ + 14, + 7, + ], + [ + 2, + 5, + ], + [ + 2, + 6, + ], + [ + 2, + 7, + ], + [ + 2, + 8, + ], + [ + 2, + 9, + ], + [ + 3, + 10, + ], + [ + 3, + 4, + ], + [ + 3, + 5, + ], + [ + 3, + 6, + ], + [ + 3, + 7, + ], + [ + 3, + 8, + ], + [ + 3, + 9, + ], + [ + 4, + 10, + ], + [ + 4, + 11, + ], + [ + 4, + 3, + ], + [ + 4, + 4, + ], + [ + 4, + 5, + ], + [ + 4, + 6, + ], + [ + 4, + 7, + ], + [ + 4, + 8, + ], + [ + 4, + 9, + ], + [ + 5, + 10, + ], + [ + 5, + 11, + ], + [ + 5, + 12, + ], + [ + 5, + 2, + ], + [ + 5, + 3, + ], + [ + 5, + 4, + ], + [ + 5, + 5, + ], + [ + 5, + 6, + ], + [ + 5, + 7, + ], + [ + 5, + 8, + ], + [ + 5, + 9, + ], + [ + 6, + 1, + ], + [ + 6, + 10, + ], + [ + 6, + 11, + ], + [ + 6, + 12, + ], + [ + 6, + 13, + ], + [ + 6, + 2, + ], + [ + 6, + 3, + ], + [ + 6, + 4, + ], + [ + 6, + 5, + ], + [ + 6, + 6, + ], + [ + 6, + 7, + ], + [ + 6, + 8, + ], + [ + 6, + 9, + ], + [ + 7, + 1, + ], + [ + 7, + 10, + ], + [ + 7, + 11, + ], + [ + 7, + 12, + ], + [ + 7, + 13, + ], + [ + 7, + 14, + ], + [ + 7, + 2, + ], + [ + 7, + 3, + ], + [ + 7, + 4, + ], + [ + 7, + 5, + ], + [ + 7, + 6, + ], + [ + 7, + 7, + ], + [ + 7, + 8, + ], + [ + 7, + 9, + ], + [ + 8, + 1, + ], + [ + 8, + 10, + ], + [ + 8, + 11, + ], + [ + 8, + 12, + ], + [ + 8, + 13, + ], + [ + 8, + 2, + ], + [ + 8, + 3, + ], + [ + 8, + 4, + ], + [ + 8, + 5, + ], + [ + 8, + 6, + ], + [ + 8, + 7, + ], + [ + 8, + 8, + ], + [ + 8, + 9, + ], + [ + 9, + 10, + ], + [ + 9, + 11, + ], + [ + 9, + 12, + ], + [ + 9, + 2, + ], + [ + 9, + 3, + ], + [ + 9, + 4, + ], + [ + 9, + 5, + ], + [ + 9, + 6, + ], + [ + 9, + 7, + ], + [ + 9, + 8, + ], + [ + 9, + 9, + ], + ] + `); +}); diff --git a/athena/generator/MapGenerator.tsx b/athena/generator/MapGenerator.tsx new file mode 100644 index 00000000..3b343192 --- /dev/null +++ b/athena/generator/MapGenerator.tsx @@ -0,0 +1,483 @@ +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import minBy from '@deities/hephaestus/minBy.tsx'; +import random from '@deities/hephaestus/random.tsx'; +import randomEntry from '@deities/hephaestus/randomEntry.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import arrayShuffle from 'array-shuffle'; +import { + Barracks, + Factory, + HorizontalBarrier, + House, + HQ, + VerticalBarrier, +} from '../info/Building.tsx'; +import { + Beach, + Bridge, + ConstructionSite, + Forest, + Forest2, + getTile, + getTileInfo, + Mountain, + Plain, + Reef, + River, + Ruins, + Sea, + Street, + TileInfo, + TileTypes, +} from '../info/Tile.tsx'; +import { Pioneer } from '../info/Unit.tsx'; +import calculateClusters from '../lib/calculateClusters.tsx'; +import canPlaceTile from '../lib/canPlaceTile.tsx'; +import convertBiome from '../lib/convertBiome.tsx'; +import getBiomeStyle from '../lib/getBiomeStyle.tsx'; +import getMovementPath from '../lib/getMovementPath.tsx'; +import indexToVector from '../lib/indexToVector.tsx'; +import withModifiers from '../lib/withModifiers.tsx'; +import { Biome, Biomes } from '../map/Biome.tsx'; +import Building from '../map/Building.tsx'; +import { toPlayerID } from '../map/Player.tsx'; +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData, { SizeVector, TileMap } from '../MapData.tsx'; +import { moveable, MoveConfiguration } from '../Radius.tsx'; +import vec from './../map/vec.tsx'; + +const minmax = (min: number, max: number, value: number) => + Math.max(min, Math.min(max, value)); + +const isEdge = (vector: Vector, size: SizeVector) => + vector.x === 1 || + vector.x === size.width || + vector.y === 1 || + vector.y === size.height; + +export function generateBuildings( + mapData: MapData, + biomes: ReadonlyArray = Biomes, +): MapData { + let buildings = ImmutableMap(); + const map = mapData.map.slice(); + const { size } = mapData; + const length = size.width * size.height; + const seed = random(0, length / 2.5); + for (let j = seed; j < length; j++) { + const vector = indexToVector(j, size.width); + const hq2Vector = vec( + size.width - vector.x + 1 + random(-2, 2), + size.height - vector.y + 1 + random(-2, 2), + ); + if ( + size.contains(hq2Vector) && + !hq2Vector.equals(vector) && + !isEdge(vector, size) && + !isEdge(hq2Vector, size) + ) { + buildings = buildings + .set(vector, HQ.create(1)) + .set(hq2Vector, HQ.create(2)); + map[mapData.getTileIndex(vector)] = Plain.id; + map[mapData.getTileIndex(hq2Vector)] = Plain.id; + break; + } + } + + const maxDistance = vec(1, 1).distance(vec(size.width, size.height)) * 2; + + if (buildings.size === 2) { + const [start, end] = [...buildings.keys()]; + const unit = Pioneer.create(1); + let currentMap = mapData.copy({ buildings }); + const streetPath = getMovementPath( + currentMap, + end, + moveable(currentMap, unit, start, maxDistance), + null, + ).path.slice(0, -1); + + streetPath.forEach((vector) => { + map[currentMap.getTileIndex(vector)] = Street.id; + }); + + const center = + streetPath[Math.round(streetPath.length) / 2 - 1 + random(0, 1)]; + if (center && streetPath.length >= 3) { + const [up, right, down, left] = center.adjacent(); + currentMap = mapData.copy({ map }); + const possibleRivers = arrayShuffle([ + currentMap.getTile(up) !== Street.id ? up : null, + currentMap.getTile(right) !== Street.id ? right : null, + currentMap.getTile(down) !== Street.id ? down : null, + currentMap.getTile(left) !== Street.id ? left : null, + ]).filter(isPresent); + if (possibleRivers.length >= 2) { + const [firstVector, secondVector] = possibleRivers; + const left = vec( + minmax(1, size.width, firstVector.x + random(-3, 3)), + 1, + ); + const right = vec( + minmax(1, size.width, firstVector.x + random(-3, 3)), + size.height, + ); + const top = vec( + 1, + minmax(1, size.height, firstVector.y + random(-3, 3)), + ); + const bottom = vec( + size.width, + minmax(1, size.height, firstVector.y + random(-3, 3)), + ); + const [, first, second] = minBy( + [ + [vec(firstVector.x, 1), left, right], + [vec(firstVector.x, size.height), right, left], + [vec(1, firstVector.y), top, bottom], + [vec(size.width, firstVector.y), bottom, top], + ], + ([edge]) => firstVector.distance(edge), + )!; + + const isAccessible = (map: MapData, unit: Unit, vector: Vector) => + !!( + MoveConfiguration.isAccessible(map, unit, vector) && + map.getTile(vector) !== Street.id && + !buildings.has(vector) + ); + + const { path: firstPath } = getMovementPath( + currentMap, + first, + moveable(currentMap, unit, firstVector, maxDistance, { + getCost: () => 1, + getResourceValue: () => Number.POSITIVE_INFINITY, + getTransitionCost: () => 0, + isAccessible, + }), + null, + ); + const { path: secondPath } = getMovementPath( + currentMap, + second, + moveable(currentMap, unit, secondVector, maxDistance, { + getCost: () => 1, + getResourceValue: () => Number.POSITIVE_INFINITY, + getTransitionCost: () => 0, + isAccessible, + }), + null, + ); + + if (firstPath.length && secondPath.length) { + [ + firstVector, + secondVector, + ...firstPath, + ...secondPath, + ...(Math.abs(firstVector.x - secondVector.x) === 1 && + Math.abs(firstVector.y - secondVector.y) === 1 + ? [ + vec(firstVector.x, secondVector.y), + vec(secondVector.x, firstVector.y), + ].filter( + (vector) => + isAccessible(currentMap, unit, vector) && + !buildings.has(vector) && + currentMap.getTile(vector) !== Street.id, + ) + : []), + ].forEach((vector) => { + map[currentMap.getTileIndex(vector)] = River.id; + }); + } + } + + arrayShuffle( + [up.left(), up.right(), down.left(), down.right()].filter((vector) => { + const index = currentMap.getTileIndex(vector); + return ( + currentMap.contains(vector) && + map[index] !== Street.id && + map[index] !== River.id + ); + }), + ) + .slice(2) + .forEach((vector) => { + map[currentMap.getTileIndex(vector)] = ConstructionSite.id; + }); + + streetPath.forEach((vector) => { + const index = currentMap.getTileIndex(vector); + const [up, right, down, left] = vector + .adjacent() + .map( + (vector) => + currentMap.contains(vector) && + getTile(map[currentMap.getTileIndex(vector)], 0), + ); + + const shouldPlaceHorizontalBridge = + up === River.id && + down === River.id && + left === Street.id && + right === Street.id; + if ( + shouldPlaceHorizontalBridge || + (up === Street.id && + down === Street.id && + left === River.id && + right === River.id) + ) { + map[index] = [River.id, Bridge.id]; + if (random(0, 1)) { + buildings = buildings.set( + vector, + (shouldPlaceHorizontalBridge + ? HorizontalBarrier + : VerticalBarrier + ).create(0), + ); + } + } + }); + currentMap = currentMap.copy({ map }); + } + + const maxFactories = 2; + for (let playerId = 1; playerId <= 2; playerId++) { + let factories = 0; + const possibleBuildingTiles = arrayShuffle( + [ + ...new Set( + (playerId === 1 ? start : end) + .adjacent() + .flatMap((vector: Vector) => vector.expandStar().slice(1)), + ), + ].filter( + (vector) => + currentMap.contains(vector) && + !vector.equals(start) && + !vector.equals(end), + ), + ); + + possibleBuildingTiles.slice(0, 8).forEach((vector, arrayIndex) => { + const index = currentMap.getTileIndex(vector); + if ( + !isEdge(vector, size) && + map[index] !== Street.id && + map[index] !== River.id && + !buildings.has(vector) + ) { + map[currentMap.getTileIndex(vector)] = ConstructionSite.id; + const buildingToBuild = random(0, 1); + if (buildingToBuild === 0) { + factories++; + } + const building = + buildingToBuild === 0 && factories <= maxFactories + ? factories == 0 || random(0, 1) + ? Barracks + : Factory + : House; + + if (arrayIndex <= 5) { + buildings = buildings.set( + vector, + building.create(toPlayerID(playerId)), + ); + } + } + }); + } + } + return convertBiome( + copyMap(mapData.copy({ buildings }), map), + arrayShuffle(biomes)[0], + ); +} + +export function generateSea(mapData: MapData): MapData { + if (!random(0, 3)) { + return mapData; + } + + const seaTile = + getBiomeStyle(mapData.config.biome).tileConversions?.get(Sea)?.id || Sea.id; + const map = mapData.map.slice(); + if (random(0, 1)) { + const queue: Array = []; + mapData.forEachField((vector, index) => { + if (isEdge(vector, mapData.size) && !mapData.buildings.has(vector)) { + map[index] = seaTile; + queue.push( + ...vector + .adjacent() + .slice(1) + .filter((vector) => { + const tile = + mapData.contains(vector) && + getTile(map[mapData.getTileIndex(vector)]); + return ( + tile && + tile !== River.id && + tile !== Street.id && + tile !== Bridge.id + ); + }), + ); + } + }); + + const seen = new Set(); + while (queue.length) { + const vector = queue.shift()!; + if (seen.has(vector)) { + continue; + } + seen.add(vector); + + if (random(0, 1) && !mapData.buildings.has(vector)) { + map[mapData.getTileIndex(vector)] = seaTile; + } + } + } else { + const clusters = calculateClusters(mapData.size, [ + ...mapData.reduceEachField((set, vector) => { + const tile = mapData.getTileInfo(vector); + if ( + tile && + tile.type & TileTypes.Plain && + !mapData.buildings.has(vector) + ) { + return set.add(vector); + } + return set; + }, new Set()), + ]); + + const initialVector = clusters[0]; + const queue = [initialVector]; + const seen = new Set(); + let iterations = 45; + while (queue.length && iterations > 0) { + const vector = queue.shift()!; + if (seen.has(vector)) { + continue; + } + seen.add(vector); + if (!mapData.contains(vector)) { + continue; + } + + const index = mapData.getTileIndex(vector); + const tile = getTileInfo(map[index]); + if ( + (tile.type & TileTypes.Plain || tile.type & TileTypes.Forest) && + !mapData.buildings.has(vector) && + (vector.equals(initialVector) || + vector + .adjacent() + .some((vector) => map[mapData.getTileIndex(vector)] === seaTile)) + ) { + map[index] = seaTile; + } + + queue.push( + ...vector + .expandWithDiagonals() + .slice(1) + .filter((vector) => mapData.contains(vector)), + ); + iterations--; + } + } + + mapData = copyMap(mapData, map); + mapData.forEachField((vector) => { + const index = mapData.getTileIndex(vector); + if ( + !random(0, 4) && + !mapData.buildings.has(vector) && + canPlaceTile(mapData, vector, Reef) + ) { + map[index] = [getTileInfo(map[index], 0).id, Reef.id]; + mapData = copyMap(mapData, map); + } + }); + + // These should be separate loops. + mapData.forEachField((vector) => { + const index = mapData.getTileIndex(vector); + if ( + !random(0, 2) && + !mapData.buildings.has(vector) && + canPlaceTile(mapData, vector, Beach) + ) { + map[index] = Beach.id; + mapData = copyMap(mapData, map); + } + }); + + return mapData; +} + +const copyMap = (mapData: MapData, map: TileMap) => + withModifiers(mapData.copy({ map: map.slice() })); + +export function generateRandomMap( + size: SizeVector, + fill?: ReadonlyArray | null, +): MapData { + const map: Array = []; + const tiles: ReadonlyArray = fill || [ + ...Array(28).fill(Plain), + ...Array(6).fill(Forest), + ...Array(3).fill(Forest2), + ...Array(4).fill(Mountain), + ...Array(1).fill(Ruins), + ]; + const length = size.width * size.height; + for (let i = 0; i < length; i++) { + map.push(randomEntry(tiles).id); + } + + return MapData.createMap({ + config: { + blocklistedBuildings: [], + fog: false, + multiplier: 1, + seedCapital: 500, + }, + map, + size: size.toJSON(), + }); +} + +export function generatePlainMap( + size: SizeVector, + biome?: Biome, + tile?: TileInfo, +): MapData { + const map: Array = []; + const length = size.width * size.height; + for (let i = 0; i < length; i++) { + map.push((tile || Plain).id); + } + return MapData.createMap({ + config: { + biome: biome || Biome.Grassland, + blocklistedBuildings: [], + fog: false, + multiplier: 1, + seedCapital: 500, + }, + map, + size: size.toJSON(), + }); +} diff --git a/athena/info/AttackSprite.tsx b/athena/info/AttackSprite.tsx new file mode 100644 index 00000000..f07de02e --- /dev/null +++ b/athena/info/AttackSprite.tsx @@ -0,0 +1,30 @@ +export type AttackSprite = + | 'Amphibious' + | 'AntiAir' + | 'Artillery' + | 'Bite' + | 'Bomb' + | 'Cannon' + | 'Empty' + | 'ExplosionImpact' + | 'Flamethrower' + | 'HeavyArtillery' + | 'LightGun' + | 'MG' + | 'Minigun' + | 'Pistol' + | 'Pow' + | 'Punch' + | 'Railgun' + | 'RailgunImpact' + | 'Rocket' + | 'RocketLauncher' + | 'Rockets' + | 'SAM' + | 'SAMImpact' + | 'Shotgun' + | 'Smoke' + | 'SniperRifle' + | 'Spark' + | 'SparkMini' + | 'Torpedo'; diff --git a/athena/info/Building.tsx b/athena/info/Building.tsx new file mode 100644 index 00000000..ded24c50 --- /dev/null +++ b/athena/info/Building.tsx @@ -0,0 +1,600 @@ +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { AIBehavior } from '../map/AIBehavior.tsx'; +import { Biome } from '../map/Biome.tsx'; +import Building from '../map/Building.tsx'; +import { MaxHealth } from '../map/Configuration.tsx'; +import { EntityType } from '../map/Entity.tsx'; +import Player, { PlayerID } from '../map/Player.tsx'; +import SpriteVector from '../map/SpriteVector.tsx'; +import type { ID } from '../MapData.tsx'; +import { BarID } from './BuildingIDs.tsx'; +import { hasUnlockedBuilding, Skill } from './Skill.tsx'; +import { SpriteVariant } from './SpriteVariants.tsx'; +import { + Airfield, + Bridge, + Campsite, + ConstructionSite, + Path, + Pier, + Plain, + RailBridge, + RailTrack, + ShipyardConstructionSite, + SpaceBridge, + Street, + TileInfo, +} from './Tile.tsx'; +import { Ability, filterUnits, SpecialUnits, UnitInfo } from './Unit.tsx'; + +let _buildingClass: typeof Building; + +export const MinFunds = 100; +export const MaxSkills = 4; + +export enum Behavior { + Heal, + Radar, + SellSkills, +} + +class BuildingBehaviors { + private readonly heal: boolean; + private readonly radar: boolean; + private readonly sellSkills: boolean; + + constructor({ + heal, + radar, + sellSkills, + }: { + heal?: boolean; + radar?: boolean; + sellSkills?: boolean; + } = {}) { + this.heal = heal ?? false; + this.radar = radar ?? false; + this.sellSkills = sellSkills ?? false; + } + + has(ability: Behavior): boolean { + switch (ability) { + case Behavior.Heal: + return this.heal; + case Behavior.Radar: + return this.radar; + case Behavior.SellSkills: + return this.sellSkills; + default: { + ability satisfies never; + throw new UnknownTypeError('BuildingBehaviors.has', ability); + } + } + } +} + +export type BuildingHeight = 'small' | 'medium' | 'tall'; + +const defaultBehavior = new BuildingBehaviors(); + +export class BuildingInfo { + private readonly buildableUnits: ReadonlySet; + public readonly configuration: { + attackStatusEffect: number; + behaviors: BuildingBehaviors; + canBeCreated: boolean; + cost: number; + editorPlaceOn: ReadonlySet; + funds: number; + healTypes?: ReadonlySet; + isAccessible: boolean; + limit: number; + placeOn?: ReadonlySet; + requiresUnlock: boolean; + restrictedUnits?: ReadonlySet; + sort: number; + unitTypes?: ReadonlySet; + units?: ReadonlySet; + }; + public readonly defense: number; + public readonly sprite: { + biomeStyle?: Map; + name: SpriteVariant | 'Structures'; + position: SpriteVector; + size: BuildingHeight; + }; + public readonly type: EntityType; + + constructor( + public readonly id: ID, + private readonly internalName: string, + private readonly internalDescription: string, + configuration: { + attackStatusEffect?: number; + behaviors?: BuildingBehaviors; + canBeCreated?: boolean; + cost?: number; + defense: number; + editorPlaceOn?: ReadonlySet; + funds?: number; + healTypes?: ReadonlySet; + isAccessible?: boolean; + limit?: number; + placeOn?: ReadonlySet; + requiresUnlock?: boolean; + restrictedUnits?: ReadonlySet; + sort: number; + type?: number; + unitTypes?: ReadonlySet; + units?: ReadonlySet; + }, + sprite: { + biomeStyle?: Map; + name: SpriteVariant | 'Structures'; + position: SpriteVector; + size?: BuildingHeight; + }, + ) { + const { defense, type, ...rest } = configuration; + this.defense = defense || 0; + this.type = type || EntityType.Building; + this.configuration = { + attackStatusEffect: 0, + behaviors: defaultBehavior, + canBeCreated: true, + cost: 0, + editorPlaceOn: new Set(), + funds: 0, + isAccessible: true, + limit: 0, + requiresUnlock: false, + ...rest, + }; + this.sprite = { + size: 'tall', + ...sprite, + }; + const { unitTypes, units } = this.configuration; + this.buildableUnits = new Set( + [ + ...(unitTypes ? filterUnits(({ type }) => unitTypes.has(type)) : []), + ...(units || []), + ].filter((unitInfo) => !configuration.restrictedUnits?.has(unitInfo)), + ); + } + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } + + get description() { + Object.defineProperty(this, 'description', { + value: this.internalDescription, + }); + return this.internalDescription; + } + + canBeCreatedOn(tileInfo: TileInfo) { + return !!this.configuration.placeOn?.has(tileInfo); + } + + editorCanBeCreatedOn(tileInfo: TileInfo) { + return this.configuration.editorPlaceOn.has(tileInfo); + } + + canHeal(unitInfo: UnitInfo) { + return ( + this.configuration.behaviors.has(Behavior.Heal) && + this.configuration.healTypes?.has(unitInfo.type) + ); + } + + getAllBuildableUnits(): Iterable { + return this.buildableUnits; + } + + hasBehavior(behavior: Behavior) { + return this.configuration.behaviors.has(behavior); + } + + isStructure() { + return this.type === EntityType.Structure; + } + + isAccessibleBy(unitInfo: UnitInfo) { + return ( + this.configuration.isAccessible && + unitInfo.hasAbility(Ability.AccessBuildings) + ); + } + + isHQ() { + return this.id === HQ.id; + } + + create( + player: Player | PlayerID, + config?: { behaviors?: Set; label?: PlayerID | null }, + ) { + return new _buildingClass( + this.id, + MaxHealth, + typeof player === 'number' ? player : player.id, + null, + config?.label != null ? config.label : null, + config?.behaviors != null ? config.behaviors : null, + ); + } + + static setConstructor(buildingClass: typeof Building) { + _buildingClass = buildingClass; + } +} + +export const HQ = new BuildingInfo( + 1, + 'HQ', + 'The HQ is the most important building. If it is captured, the player loses the game.', + { + canBeCreated: false, + defense: 40, + editorPlaceOn: new Set([Plain, ConstructionSite]), + limit: 1, + restrictedUnits: SpecialUnits, + sort: 1, + type: EntityType.Invincible, + unitTypes: new Set([EntityType.Infantry]), + }, + { + biomeStyle: new Map([[Biome.Spaceship, new SpriteVector(0, 2)]]), + name: 'Buildings', + position: new SpriteVector(0, 0), + }, +); + +export const House = new BuildingInfo( + 2, + 'House', + `Houses generate funds for the occupier. Capturing or building more houses will increase income that can be used to hire units at each turn.`, + { + cost: 100, + defense: 10, + funds: MinFunds, + placeOn: new Set([ConstructionSite]), + sort: 2, + }, + { name: 'Buildings', position: new SpriteVector(5, 0), size: 'medium' }, +); + +export const Factory = new BuildingInfo( + 3, + 'Factory', + `Factories serve as production hubs on the battlefield, enabling the assembly and deployment of ground units such as light vehicles, tanks, and artillery.`, + { + cost: 250, + defense: 10, + placeOn: new Set([ConstructionSite]), + restrictedUnits: SpecialUnits, + sort: 3, + unitTypes: new Set([ + EntityType.Ground, + EntityType.Artillery, + EntityType.Rail, + ]), + }, + { name: 'Buildings', position: new SpriteVector(6, 0) }, +); + +const AirUnitTypes = new Set([ + EntityType.LowAltitude, + EntityType.Airplane, + EntityType.AirInfantry, +]); + +export const Airbase = new BuildingInfo( + 4, + 'Airbase', + `This building is used to build air units like helicopters and airplanes. They also automatically repair damaged air units at the beginning of each turn.`, + { + behaviors: new BuildingBehaviors({ heal: true }), + cost: 200, + defense: 20, + healTypes: AirUnitTypes, + placeOn: new Set([Airfield]), + restrictedUnits: SpecialUnits, + sort: 3, + unitTypes: AirUnitTypes, + }, + { name: 'Buildings', position: new SpriteVector(8, 0) }, +); + +export const Shipyard = new BuildingInfo( + 5, + 'Shipyard', + `Shipyards are built on piers and are used to build ships and amphibious units.`, + { + cost: 300, + defense: 20, + placeOn: new Set([ShipyardConstructionSite]), + restrictedUnits: SpecialUnits, + sort: 5, + unitTypes: new Set([EntityType.Ship, EntityType.Amphibious]), + }, + { name: 'Buildings', position: new SpriteVector(9, 0) }, +); + +const barrierTiles = new Set([ + Plain, + Street, + Bridge, + RailTrack, + RailBridge, + Path, + Pier, + SpaceBridge, +]); +export const VerticalBarrier = new BuildingInfo( + 6, + 'Barrier', + `This structure is an impassable obstacle on the battlefield that needs to be destroyed in order to clear the path for advancing units and securing strategic positions.`, + { + canBeCreated: false, + defense: 60, + editorPlaceOn: barrierTiles, + isAccessible: false, + sort: 10, + type: EntityType.Structure, + }, + { + biomeStyle: new Map([[Biome.Spaceship, new SpriteVector(0, 2)]]), + name: 'Structures', + position: new SpriteVector(0, 0), + size: 'small', + }, +); + +export const HorizontalBarrier = new BuildingInfo( + 7, + 'Barrier', + `This structure is an impassable obstacle on the battlefield that needs to be destroyed in order to clear the path for advancing units and securing strategic positions.`, + { + canBeCreated: false, + defense: 60, + editorPlaceOn: barrierTiles, + isAccessible: false, + sort: 10, + type: EntityType.Structure, + }, + { + biomeStyle: new Map([[Biome.Spaceship, new SpriteVector(0, 2)]]), + name: 'Structures', + position: new SpriteVector(1, 0), + size: 'small', + }, +); + +export const CrashedAirplane = new BuildingInfo( + 8, + 'Crashed Airplane', + `This structure is an impassable obstacle on the battlefield that needs to be destroyed in order to clear the path for advancing units and securing strategic positions.`, + { + canBeCreated: false, + defense: 80, + editorPlaceOn: new Set([Plain, Street]), + isAccessible: false, + sort: 10, + type: EntityType.Structure, + }, + { name: 'Structures', position: new SpriteVector(2, 0), size: 'small' }, +); + +export const DestroyedHouse = new BuildingInfo( + 14, + 'Destroyed House', + `This structure is an impassable obstacle on the battlefield that needs to be destroyed in order to clear the path for advancing units and securing strategic positions.`, + { + canBeCreated: false, + defense: 70, + editorPlaceOn: new Set([Plain, ConstructionSite]), + isAccessible: false, + sort: 10, + type: EntityType.Structure, + }, + { name: 'Structures', position: new SpriteVector(3, 0), size: 'medium' }, +); + +export const ResearchLab = new BuildingInfo( + 9, + 'Research Lab', + `This building increases the attack strength of the owner's units by 10%. At some locations you may be able to acquire skills for the duration of a game.`, + { + attackStatusEffect: 0.1, + behaviors: new BuildingBehaviors({ sellSkills: true }), + canBeCreated: false, + cost: 300, + defense: 60, + placeOn: new Set([ConstructionSite]), + sort: 4, + }, + { name: 'Buildings', position: new SpriteVector(10, 0) }, +); + +export const RadarStation = new BuildingInfo( + 10, + 'Radar Station', + `Radar Stations are used to determine navigational patterns through lightning strikes.`, + { + behaviors: new BuildingBehaviors({ radar: true }), + cost: 500, + defense: 30, + placeOn: new Set([ConstructionSite]), + sort: 4, + }, + { name: 'Buildings', position: new SpriteVector(11, 0) }, +); + +export const PowerStation = new BuildingInfo( + 11, + 'Power Station', + `Unknown`, + { + canBeCreated: false, + cost: Number.POSITIVE_INFINITY, + defense: 30, + placeOn: new Set([ConstructionSite]), + sort: 4, + }, + { name: 'Buildings', position: new SpriteVector(12, 0) }, +); + +export const Barracks = new BuildingInfo( + 12, + 'Barracks', + `Barracks function as training centers, facilitating the recruitment and deployment of various types of infantry units.`, + { + cost: 150, + defense: 20, + placeOn: new Set([ConstructionSite]), + restrictedUnits: SpecialUnits, + sort: 2, + unitTypes: new Set([EntityType.Infantry]), + }, + { + name: 'Buildings', + position: new SpriteVector(7, 0), + size: 'medium', + }, +); + +export const Shelter = new BuildingInfo( + 13, + 'Shelter', + `Shelters can be used to automatically heal infantry units at the beginning of each turn.`, + { + behaviors: new BuildingBehaviors({ heal: true }), + cost: 200, + defense: 40, + funds: MinFunds / 2, + healTypes: new Set([EntityType.Infantry, EntityType.AirInfantry]), + placeOn: new Set([Campsite]), + sort: 4, + }, + { name: 'Buildings', position: new SpriteVector(14, 0), size: 'small' }, +); + +export const Bar = new BuildingInfo( + BarID, + 'Bar', + `A bar, on the battlefield? Why?`, + { + cost: Number.POSITIVE_INFINITY, + defense: 40, + funds: MinFunds * 3, + placeOn: new Set([ConstructionSite]), + requiresUnlock: true, + sort: 3, + units: SpecialUnits, + }, + { name: 'Buildings', position: new SpriteVector(17, 0) }, +); + +export const OilRig = new BuildingInfo( + 16, + 'Oil Rig', + `Oil Rigs in the sea are used to extract oil from the ground. They generate twice the funds compared to a House.`, + { + cost: 200, + defense: 20, + funds: MinFunds * 2, + placeOn: new Set([ShipyardConstructionSite]), + sort: 5, + }, + { name: 'Buildings', position: new SpriteVector(20, 0) }, +); + +export const RepairShop = new BuildingInfo( + 17, + 'Repair Shop', + `This building automatically repairs damaged ground units and amphibious units at the beginning of each turn.`, + { + behaviors: new BuildingBehaviors({ heal: true }), + cost: 300, + defense: 30, + funds: MinFunds * 1.5, + healTypes: new Set([ + EntityType.Ground, + EntityType.Artillery, + EntityType.Amphibious, + ]), + placeOn: new Set([ConstructionSite]), + sort: 4, + }, + { name: 'Buildings', position: new SpriteVector(18, 0) }, +); + +// The order of buildings must not be changed. +const Buildings = [ + HQ, + House, + Factory, + Airbase, + Shipyard, + VerticalBarrier, + HorizontalBarrier, + CrashedAirplane, + ResearchLab, + RadarStation, + PowerStation, + Barracks, + Shelter, + DestroyedHouse, + Bar, + OilRig, + RepairShop, +]; + +export function getBuildingInfo(id: number): BuildingInfo | null { + return Buildings[id - 1] || null; +} + +const buildings = Buildings.slice().sort((infoA, infoB) => { + const sortA = infoA.configuration.sort; + const sortB = infoB.configuration.sort; + return sortA === sortB + ? infoA.id > infoB.id + ? 1 + : -1 + : sortA > sortB + ? 1 + : -1; +}); + +export function getAllBuildings(): ReadonlyArray { + return buildings; +} + +export function filterBuildings( + fn: (buildingInfo: BuildingInfo) => boolean, +): Array { + return buildings.filter(fn); +} + +export function mapBuildings( + fn: (buildingInfo: BuildingInfo) => T, +): Array { + return buildings.map(fn); +} + +export function mapBuildingsWithContentRestriction( + fn: (buildingInfo: BuildingInfo, index: number) => T, + skills: ReadonlySet, +): Array { + return buildings + .filter( + (building) => + building.configuration.cost < Number.POSITIVE_INFINITY || + hasUnlockedBuilding(building, skills), + ) + .map(fn); +} diff --git a/athena/info/BuildingIDs.tsx b/athena/info/BuildingIDs.tsx new file mode 100644 index 00000000..152ee7eb --- /dev/null +++ b/athena/info/BuildingIDs.tsx @@ -0,0 +1 @@ +export const BarID = 15; diff --git a/athena/info/Decorator.tsx b/athena/info/Decorator.tsx new file mode 100644 index 00000000..42dc2722 --- /dev/null +++ b/athena/info/Decorator.tsx @@ -0,0 +1,459 @@ +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import { Biome } from '../map/Biome.tsx'; +import SpriteVector from '../map/SpriteVector.tsx'; +import { ID } from '../MapData.tsx'; +import { + PlainTileGroup, + SeaTileGroup, + TileAnimation, + TileInfo, +} from './Tile.tsx'; + +const sprite = (x: number, y: number) => new SpriteVector(x, y); + +enum DecoratorGroup { + Bush, + TreeTrunk, + Log, + Barrel, + Box, + Barrier, + StreetSign, + Rock, + Mushroom, + PotHole, + ElectricityPole, + Gate, + Statue, + Arch, + Flower, + Lantern, + PowerTransformer, + Gravestone, + Bones, + Vegetation, + Shovel, + Mask, + Bucket, + Ladder, + Plank, + Shipwreck, + Sandbag, +} + +export type Decorator = number; + +export class DecoratorInfo { + constructor( + private readonly internalName: string, + public readonly group: DecoratorGroup, + public readonly position: SpriteVector, + public readonly placeOn: Set, + public readonly animation: TileAnimation | null = null, + public readonly biomeStyle?: Map, + public readonly id: ID = 0, + ) {} + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } + + copy({ + animation, + biomeStyle, + group, + id, + name, + placeOn, + position, + }: { + animation?: TileAnimation | null; + biomeStyle?: Map; + group?: DecoratorGroup; + id?: ID; + name?: string; + placeOn?: Set; + position?: SpriteVector; + }): DecoratorInfo { + return new DecoratorInfo( + name ?? this.internalName, + group ?? this.group, + position ?? this.position, + placeOn ?? this.placeOn, + animation ?? this.animation, + biomeStyle ?? this.biomeStyle, + id ?? this.id, + ); + } + + right(n = 1) { + return this.copy({ position: this.position.right(n) }); + } + + down(n = 1) { + return this.copy({ position: this.position.down(n) }); + } + + up(n = 1) { + return this.copy({ position: this.position.up(n) }); + } +} + +const seaAnimation = { + frames: 4, + offset: 1, + ticks: 6, +}; + +const _Bush = new DecoratorInfo( + 'Bush', + DecoratorGroup.Bush, + sprite(0, 0), + PlainTileGroup, +); + +const TreeTrunk = new DecoratorInfo( + 'Tree Trunk', + DecoratorGroup.TreeTrunk, + sprite(4, 0), + PlainTileGroup, + null, + new Map([[Biome.Volcano, sprite(0, 9)]]), +); + +const Log = new DecoratorInfo( + 'Log', + DecoratorGroup.Log, + sprite(8, 0), + PlainTileGroup, + null, + new Map([[Biome.Volcano, sprite(0, 9)]]), +); + +const Barrel = new DecoratorInfo( + 'Barrel', + DecoratorGroup.Barrel, + sprite(12, 0), + PlainTileGroup, +); + +const Box = new DecoratorInfo( + 'Box', + DecoratorGroup.Box, + sprite(13, 0), + PlainTileGroup, +); + +const Barrier = new DecoratorInfo( + 'Barrier', + DecoratorGroup.Barrier, + sprite(15, 0), + PlainTileGroup, +); + +const Ladder = new DecoratorInfo( + 'Ladder', + DecoratorGroup.Ladder, + sprite(0, 1), + SeaTileGroup, + seaAnimation, +); + +const Plank = new DecoratorInfo( + 'Plank', + DecoratorGroup.Plank, + sprite(0, 2), + SeaTileGroup, + seaAnimation, +); + +const StreetSign = new DecoratorInfo( + 'Street Sign', + DecoratorGroup.StreetSign, + sprite(4, 1), + PlainTileGroup, +); + +const StreetSigns = [ + StreetSign, + StreetSign.right(), + StreetSign.right(2), + StreetSign.right(3), + StreetSign.right(4), + StreetSign.right(5), + StreetSign.right(6), + StreetSign.right(7), +]; + +const Rock = new DecoratorInfo( + 'Rock', + DecoratorGroup.Rock, + sprite(12, 9), + PlainTileGroup, +); + +const Mushroom = new DecoratorInfo( + 'Mushroom', + DecoratorGroup.Mushroom, + sprite(12, 1), + PlainTileGroup, +); + +const Mushrooms = [ + Mushroom, + Mushroom.right(), + Mushroom.right(2), + Mushroom.right(3), +]; + +const Pylon = new DecoratorInfo( + 'Pylon', + DecoratorGroup.Barrier, + sprite(16, 2), + PlainTileGroup, +); + +const Pothole = new DecoratorInfo( + 'Pothole', + DecoratorGroup.PotHole, + sprite(0, 6), + PlainTileGroup, + seaAnimation, +); + +const ElectricityPole = new DecoratorInfo( + 'Electricity Pole', + DecoratorGroup.ElectricityPole, + sprite(4, 5), + PlainTileGroup, +); + +const Gate = new DecoratorInfo( + 'Gate', + DecoratorGroup.Gate, + sprite(4, 6), + PlainTileGroup, +); + +const StreetLamp = new DecoratorInfo( + 'Street Lamp', + DecoratorGroup.StreetSign, + sprite(4, 7), + PlainTileGroup, + seaAnimation, +); + +const Statue = new DecoratorInfo( + 'Statue', + DecoratorGroup.Statue, + sprite(6, 6), + PlainTileGroup, +); + +const Arch = new DecoratorInfo( + 'Arch', + DecoratorGroup.Arch, + sprite(10, 5), + PlainTileGroup, +); + +const Flower = new DecoratorInfo( + 'Flower', + DecoratorGroup.Flower, + sprite(11, 5), + PlainTileGroup, +); + +const Flowers = [Flower, Flower.right(), Flower.right(2), Flower.right(3)]; + +const Lantern = new DecoratorInfo( + 'Lantern', + DecoratorGroup.Lantern, + sprite(8, 8), + PlainTileGroup, +); + +const PowerTransformer = new DecoratorInfo( + 'Power Transformer', + DecoratorGroup.PowerTransformer, + sprite(8, 7), + PlainTileGroup, +); + +const Gravestone = new DecoratorInfo( + 'Gravestone', + DecoratorGroup.Gravestone, + sprite(17, 2), + PlainTileGroup, +); + +const Bones = new DecoratorInfo( + 'Bones', + DecoratorGroup.Bones, + sprite(15, 6), + PlainTileGroup, +); + +const Vegetation = new DecoratorInfo( + 'Vegetation', + DecoratorGroup.Vegetation, + sprite(10, 8), + PlainTileGroup, +); + +const Shovel = new DecoratorInfo( + 'Shovel', + DecoratorGroup.Shovel, + sprite(0, 9), + PlainTileGroup, +); + +const Mask = new DecoratorInfo( + 'Mask', + DecoratorGroup.Mask, + sprite(17, 8), + PlainTileGroup, +); + +const Bucket = new DecoratorInfo( + 'Bucket', + DecoratorGroup.Bucket, + sprite(16, 8), + PlainTileGroup, +); + +const Shipwreck = new DecoratorInfo( + 'Shipwreck', + DecoratorGroup.Shipwreck, + sprite(0, 10), + SeaTileGroup, + seaAnimation, +); + +const Sandbag = new DecoratorInfo( + 'Sandbag', + DecoratorGroup.Sandbag, + sprite(18, 0), + PlainTileGroup, +); + +// The order of decorators must not be changed. +const Decorators = [ + _Bush, + _Bush.right(), + _Bush.right(2), + _Bush.right(3), + TreeTrunk, + TreeTrunk.right(), + TreeTrunk.right(2), + TreeTrunk.right(3), + Log, + Log.right(), + Log.right(2), + Log.right(3), + Barrel, + Box, + Box.right(), + Barrier, + Barrier.right(), + Barrier.right(2), + Ladder, + Plank, + Plank.down(), + Plank.down(2), + Plank.down(3), + ...StreetSigns, + ...StreetSigns.map((decorator) => decorator.down()), + ...StreetSigns.map((decorator) => decorator.down(2)), + ...StreetSigns.map((decorator) => decorator.down(3)), + Rock, + Rock.right(), + Rock.right(2), + Rock.right(3), + Rock.right(4), + ...Mushrooms, + ...Mushrooms.map((decorator) => decorator.down()), + ...Mushrooms.map((decorator) => decorator.down(2)), + Pylon, + Pothole, + Pothole.down(), + Pothole.down(2), + ElectricityPole, + ElectricityPole.right(), + ElectricityPole.right(2), + ElectricityPole.right(3), + ElectricityPole.right(4), + ElectricityPole.right(5), + Gate, + Gate.right(), + StreetLamp, + StreetLamp.down(), + Statue, + Statue.right(), + Statue.right(2), + Statue.right(3), + Statue.right(4), + Arch, + ...Flowers, + ...Flowers.map((decorator) => decorator.down()), + ...Flowers.map((decorator) => decorator.down(2)), + Lantern, + Lantern.right(), + PowerTransformer, + PowerTransformer.right(), + PowerTransformer.right(2), + Barrel.right(4).down(3), + Barrel.right(4).down(4), + Barrel.right(4).down(5), + Barrel.right(4).down(6), + Gravestone, + Gravestone.down(), + Gravestone.down(2), + Gravestone.down(3), + Gravestone.down(4), + Bones, + Bones.down(), + Bones.down().right(), + Bones.down().right(2), + Vegetation, + Vegetation.right(), + Vegetation.right(2), + Vegetation.right(3), + Vegetation.right(4), + Vegetation.right(5), + Shovel, + Shovel.right(), + Shovel.right(2), + Shovel.right(3), + Mask, + Bucket, + ...Mushrooms.map((decorator) => decorator.down(3)), + Statue.right(10).up(5), + Shipwreck, + Shipwreck.right(4), + Shipwreck.right(8), + Shipwreck.right(12), + Shipwreck.right(16), + Sandbag, + Sandbag.down(1), + Sandbag.down(2), +].map((decorator, index) => decorator.copy({ id: index + 1 })); + +export const Bush = Decorators[0]; + +export function getDecorator(index: Decorator): DecoratorInfo | null { + return Decorators[index - 1] || null; +} + +// Defensive copy: Prevent mutations of the internal array. +const decorators = sortBy(Decorators.slice(), ({ group }) => group); +export function getAllDecorators(): ReadonlyArray { + return decorators; +} + +export function mapDecorators( + fn: (decorator: DecoratorInfo) => T, +): Array { + return Decorators.map((decorator) => decorator && fn(decorator)); +} diff --git a/athena/info/FactionNames.tsx b/athena/info/FactionNames.tsx new file mode 100644 index 00000000..e70e1c4f --- /dev/null +++ b/athena/info/FactionNames.tsx @@ -0,0 +1,30 @@ +import randomEntry from '@deities/hephaestus/randomEntry.tsx'; + +const FactionNames = [ + 'Apollo', + 'Ares', + 'Artemis', + 'Athena', + 'Atlas', + 'Ceres', + 'Dionysus', + 'Eros', + 'Hephaestus', + 'Hera', + 'Hermes', + 'Hyperion', + 'Mars', + 'Nebula', + 'Nemesis', + 'Nero', + 'Orion', + 'Phoenix', + 'Prometheus', + 'Terra', +] as const; + +export default FactionNames; + +export function generateFactionName() { + return `${randomEntry(FactionNames)}-${Math.floor(Math.random() * 1000)}`; +} diff --git a/athena/info/MovementType.tsx b/athena/info/MovementType.tsx new file mode 100644 index 00000000..416e6351 --- /dev/null +++ b/athena/info/MovementType.tsx @@ -0,0 +1,95 @@ +import { SoundName } from './Music.tsx'; + +export class MovementType { + constructor( + public readonly id: number, + private readonly internalName: string, + public readonly sound: SoundName, + public readonly endSound: SoundName | null, + public readonly endDelay: 'quarter' | 'none', + public readonly sortOrder: number, + public readonly alternative?: MovementType, + ) {} + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } +} +const Tread = new MovementType( + 4, + 'Tread', + 'Movement/Tread', + 'Movement/TreadEnd', + 'none', + 3, +); +export const MovementTypes = { + Air: new MovementType( + 5, + 'Air', + 'Movement/Air', + 'Movement/AirEnd', + 'quarter', + 5, + ), + AirInfantry: new MovementType( + 7, + 'Air Infantry', + 'Movement/AirInfantry', + 'Movement/AirInfantryEnd', + 'quarter', + 1, + ), + Amphibious: new MovementType( + 8, + 'Amphibious', + 'Movement/Amphibious', + null, + 'none', + 6, + Tread, + ), + HeavySoldier: new MovementType( + 2, + 'Heavy Soldier', + 'Movement/HeavySoldier', + 'Movement/HeavySoldierEnd', + 'none', + 1, + ), + LowAltitude: new MovementType( + 10, + 'Low Altitude', + 'Movement/LowAltitude', + null, + 'none', + 4, + ), + Rail: new MovementType( + 9, + 'Rail', + 'Movement/Rail', + 'Movement/RailEnd', + 'quarter', + 8, + ), + Ship: new MovementType(6, 'Ship', 'Movement/Ship', null, 'none', 7), + Soldier: new MovementType( + 1, + 'Soldier', + 'Movement/Soldier', + 'Movement/SoldierEnd', + 'quarter', + 1, + ), + Tires: new MovementType( + 3, + 'Tires', + 'Movement/Tires', + 'Movement/TiresEnd', + 'quarter', + 2, + ), + Tread, +}; diff --git a/athena/info/Music.tsx b/athena/info/Music.tsx new file mode 100644 index 00000000..2aa2f759 --- /dev/null +++ b/athena/info/Music.tsx @@ -0,0 +1,97 @@ +export type SoundName = + | 'Attack/AirToAirMissile' + | 'Attack/AntiAirGun' + | 'Attack/Artillery' + | 'Attack/ArtilleryBattery' + | 'Attack/Bite' + | 'Attack/Bomb' + | 'Attack/Cannon' + | 'Attack/Club' + | 'Attack/Flamethrower' + | 'Attack/HeavyArtillery' + | 'Attack/HeavyGun' + | 'Attack/LightGun' + | 'Attack/MG' + | 'Attack/MiniGun' + | 'Attack/Pistol' + | 'Attack/Pow' + | 'Attack/Railgun' + | 'Attack/RailgunImpact' + | 'Attack/Rocket' + | 'Attack/RocketLauncher' + | 'Attack/Rockets' + | 'Attack/SAM' + | 'Attack/SAMImpact' + | 'Attack/Shotgun' + | 'Attack/SniperRifle' + | 'Attack/TentacleWhip' + | 'Attack/Torpedo' + | 'Attack/TorpedoImpact' + | 'Attack/ZombieBite' + | 'Explosion/Air' + | 'Explosion/Building' + | 'Explosion/Ground' + | 'Explosion/Infantry' + | 'Explosion/Naval' + | 'ExplosionImpact' + | 'Movement/Air' + | 'Movement/AirEnd' + | 'Movement/AirInfantry' + | 'Movement/AirInfantryEnd' + | 'Movement/Amphibious' + | 'Movement/HeavySoldier' + | 'Movement/HeavySoldierEnd' + | 'Movement/LowAltitude' + | 'Movement/Rail' + | 'Movement/RailEnd' + | 'Movement/Ship' + | 'Movement/Soldier' + | 'Movement/SoldierEnd' + | 'Movement/Tires' + | 'Movement/TiresEnd' + | 'Movement/Tread' + | 'Movement/TreadEnd' + | 'Talking/High' + | 'Talking/Low' + | 'Talking/Mid' + | 'UI/Accept' + | 'UI/Cancel' + | 'UI/LongPress' + | 'UI/Next' + | 'UI/Previous' + | 'UI/Put' + | 'UI/SelectPosition' + | 'UI/Start' + | 'Unit/ArtilleryFold' + | 'Unit/ArtilleryUnfold' + | 'Unit/CannonFold' + | 'Unit/CannonUnfold' + | 'Unit/Capture' + | 'Unit/CreateBuilding' + | 'Unit/Drop' + | 'Unit/Heal' + | 'Unit/Load' + | 'Unit/Sabotage' + | 'Unit/SniperFold' + | 'Unit/SniperUnfold' + | 'Unit/Spawn' + | 'Unit/Supply'; + +export type SongName = + | 'apollos-ascend' + | 'apollos-gleam' + | 'ares-chaos' + | 'ares-skirmish' + | 'artemis-glade' + | 'artemis-hunt' + | 'astraeus-expanse' + | 'astraeus-wings' + | 'chiones-cloud' + | 'chiones-leap' + | 'eos-dawn' + | 'gaias-rise' + | 'hestias-serenade' + | 'poseidons-tide' + | 'poseidons-wrath' + | 'selenes-tranquility' + | 'selenes-voyage'; diff --git a/athena/info/Skill.tsx b/athena/info/Skill.tsx new file mode 100644 index 00000000..ed57558b --- /dev/null +++ b/athena/info/Skill.tsx @@ -0,0 +1,849 @@ +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import matchesActiveType from '../lib/matchesActiveType.tsx'; +import { HealAmount } from '../map/Configuration.tsx'; +import { EntityType, isUnitInfo } from '../map/Entity.tsx'; +import Player from '../map/Player.tsx'; +import Vector from '../map/Vector.tsx'; +import type MapData from '../MapData.tsx'; +import { BuildingInfo } from './Building.tsx'; +import { BarID } from './BuildingIDs.tsx'; +import { MovementType, MovementTypes } from './MovementType.tsx'; +import { TileInfo, TileType, TileTypes } from './Tile.tsx'; +import type { UnitInfo } from './Unit.tsx'; +import UnitID from './UnitID.tsx'; + +export enum Skill { + AttackIncreaseMinor = 1, + DefenseIncreaseMinor = 2, + AttackIncreaseMajorDefenseDecreaseMinor = 3, + BuyUnitCannon = 4, + DecreaseUnitCostAttackAndDefenseDecreaseMinor = 5, + UnitAbilitySniperImmediateAction = 6, + MovementIncreaseGroundUnitDefenseDecrease = 7, + UnitBattleShipMoveAndAct = 8, + BuyUnitBrute = 9, + UnitAPUAttackIncreaseMajorPower = 10, + BuyUnitZombieDefenseDecreaseMajor = 11, + BuyUnitBazookaBear = 12, + AttackAndDefenseIncreaseHard = 13, + HealVehiclesAttackDecrease = 14, + ArtilleryRangeIncrease = 15, + HealInfantryMedicPower = 16, + NoUnitRestrictions = 17, + CounterAttackPower = 18, + AttackAndDefenseDecreaseEasy = 19, + UnitInfantryForestDefenseIncrease = 20, + UnitRailDefenseIncreasePowerAttackIncrease = 21, + BuyUnitAIU = 22, +} + +export const Skills = new Set([ + Skill.AttackIncreaseMinor, + Skill.DefenseIncreaseMinor, + Skill.AttackIncreaseMajorDefenseDecreaseMinor, + Skill.BuyUnitCannon, + Skill.BuyUnitBrute, + Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, + Skill.UnitAbilitySniperImmediateAction, + Skill.MovementIncreaseGroundUnitDefenseDecrease, + Skill.UnitBattleShipMoveAndAct, + Skill.UnitAPUAttackIncreaseMajorPower, + Skill.BuyUnitZombieDefenseDecreaseMajor, + Skill.BuyUnitBazookaBear, + Skill.AttackAndDefenseIncreaseHard, + Skill.HealVehiclesAttackDecrease, + Skill.ArtilleryRangeIncrease, + Skill.HealInfantryMedicPower, + Skill.NoUnitRestrictions, + Skill.CounterAttackPower, + Skill.AttackAndDefenseDecreaseEasy, + Skill.UnitInfantryForestDefenseIncrease, + Skill.UnitRailDefenseIncreasePowerAttackIncrease, + Skill.BuyUnitAIU, +]); + +const skillConfig: Record< + Skill, + Readonly<{ charges?: number; cost: number | null }> +> = { + [Skill.AttackIncreaseMinor]: { charges: 3, cost: 300 }, + [Skill.DefenseIncreaseMinor]: { cost: 300 }, + [Skill.AttackIncreaseMajorDefenseDecreaseMinor]: { charges: 5, cost: 800 }, + [Skill.BuyUnitCannon]: { cost: 1000 }, + [Skill.BuyUnitBrute]: { charges: 3, cost: 1000 }, + [Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor]: { + charges: 5, + cost: 600, + }, + [Skill.UnitAbilitySniperImmediateAction]: { cost: 2000 }, + [Skill.MovementIncreaseGroundUnitDefenseDecrease]: { charges: 2, cost: 2500 }, + [Skill.UnitBattleShipMoveAndAct]: { charges: 5, cost: 2000 }, + [Skill.UnitAPUAttackIncreaseMajorPower]: { charges: 3, cost: 3000 }, + [Skill.BuyUnitZombieDefenseDecreaseMajor]: { cost: 1500 }, + [Skill.BuyUnitBazookaBear]: { charges: 3, cost: 2000 }, + [Skill.AttackAndDefenseIncreaseHard]: { cost: 1500 }, + [Skill.HealVehiclesAttackDecrease]: { charges: 3, cost: 1000 }, + [Skill.ArtilleryRangeIncrease]: { charges: 3, cost: 1500 }, + [Skill.HealInfantryMedicPower]: { charges: 4, cost: 1000 }, + [Skill.NoUnitRestrictions]: { cost: null }, + [Skill.CounterAttackPower]: { charges: 3, cost: 1500 }, + [Skill.AttackAndDefenseDecreaseEasy]: { cost: null }, + [Skill.UnitInfantryForestDefenseIncrease]: { charges: 3, cost: 2000 }, + [Skill.UnitRailDefenseIncreasePowerAttackIncrease]: { + charges: 4, + cost: 1500, + }, + [Skill.BuyUnitAIU]: { cost: 1500 }, +}; + +export const AIOnlySkills: ReadonlySet = new Set( + [...Skills].filter((skill) => skillConfig[skill].cost === null), +); + +type ID = number; +type Modifier = number; + +type SkillMap = ReadonlyMap; +type UnitSkillMap = ReadonlyMap>; +type MovementSkillMap = ReadonlyMap>; + +type TileMovementSkillMap = ReadonlyMap< + Skill, + ReadonlyMap> +>; +type RangeSkillMap = ReadonlyMap; +type RangeMap = ReadonlyMap; + +export type SkillActivationType = 'regular' | 'power'; +export type ActiveUnitTypes = ReadonlySet | 'all'; + +type SkillStatusType = + | 'attack' + | 'defense' + | 'attack-power' + | 'defense-power' + | 'unit-cost' + | 'unit-cost-power'; + +const attackStatusEffects: SkillMap = new Map([ + [Skill.AttackIncreaseMinor, 0.05], + [Skill.AttackIncreaseMajorDefenseDecreaseMinor, 0.15], + [Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, -0.07], + [Skill.AttackAndDefenseIncreaseHard, 0.1], + [Skill.HealVehiclesAttackDecrease, -0.15], + [Skill.AttackAndDefenseDecreaseEasy, -0.1], +]); + +const attackPowerStatusEffects: SkillMap = new Map([ + [Skill.AttackIncreaseMinor, 0.2], + [Skill.AttackIncreaseMajorDefenseDecreaseMinor, 0.35], + [Skill.HealVehiclesAttackDecrease, 0.3], +]); + +const attackUnitPowerStatusEffects: UnitSkillMap = new Map([ + [Skill.BuyUnitBazookaBear, new Map([[UnitID.BazookaBear, 0.5]])], + [ + Skill.ArtilleryRangeIncrease, + new Map([ + [UnitID.Artillery, 0.2], + [UnitID.HeavyArtillery, 0.2], + [UnitID.Cannon, 0.2], + ]), + ], + [Skill.HealInfantryMedicPower, new Map([[UnitID.Medic, 2.5]])], +]); + +const attackMovementTypePowerStatusEffects: MovementSkillMap = new Map([ + [Skill.UnitBattleShipMoveAndAct, new Map([[MovementTypes.Ship, 0.5]])], + [Skill.BuyUnitBrute, new Map([[MovementTypes.Soldier, 0.5]])], + [ + Skill.UnitAPUAttackIncreaseMajorPower, + new Map([[MovementTypes.HeavySoldier, 3]]), + ], + [ + Skill.HealInfantryMedicPower, + new Map([ + [MovementTypes.AirInfantry, 0.2], + [MovementTypes.Soldier, 0.2], + ]), + ], +]); + +const attackTilePowerStatusEffects: TileMovementSkillMap = new Map([ + [ + Skill.UnitRailDefenseIncreasePowerAttackIncrease, + new Map([ + [ + TileTypes.RailTrack, + new Map([ + [MovementTypes.Soldier, 0.3], + [MovementTypes.HeavySoldier, 0.3], + [MovementTypes.Rail, 0.1], + ]), + ], + ]), + ], +]); + +const defenseStatusEffects: SkillMap = new Map([ + [Skill.AttackIncreaseMajorDefenseDecreaseMinor, -0.12], + [Skill.DefenseIncreaseMinor, 0.05], + [Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, -0.07], + [Skill.BuyUnitZombieDefenseDecreaseMajor, -0.5], + [Skill.AttackAndDefenseIncreaseHard, 0.1], + [Skill.AttackAndDefenseDecreaseEasy, -0.1], +]); + +const defensePowerStatusEffects: SkillMap = new Map([ + [Skill.AttackIncreaseMajorDefenseDecreaseMinor, -0.3], +]); + +const unitCostEffects: SkillMap = new Map([ + [Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, -0.1], +]); + +const unitCostPowerEffects: SkillMap = new Map([ + [Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor, -0.3], +]); + +const defenseMovementTypeStatusEffects: MovementSkillMap = new Map([ + [ + Skill.MovementIncreaseGroundUnitDefenseDecrease, + new Map([ + [MovementTypes.Tires, -0.15], + [MovementTypes.Tread, -0.15], + ]), + ], + [ + Skill.ArtilleryRangeIncrease, + new Map([ + [MovementTypes.Tires, -0.2], + [MovementTypes.Tread, -0.2], + ]), + ], + [ + Skill.UnitRailDefenseIncreasePowerAttackIncrease, + new Map([[MovementTypes.Rail, 0.2]]), + ], +]); + +const forestDefense = new Map([ + [MovementTypes.Soldier, 0.1], + [MovementTypes.HeavySoldier, 0.1], +]); +const defenseTileStatusEffects: TileMovementSkillMap = new Map([ + [ + Skill.UnitInfantryForestDefenseIncrease, + new Map([ + [TileTypes.Forest, forestDefense], + [TileTypes.ForestVariant2, forestDefense], + [TileTypes.ForestVariant3, forestDefense], + [TileTypes.ForestVariant4, forestDefense], + ]), + ], +]); + +const skillRangeEffects: RangeMap = new Map([]); + +const skillRangePowerEffects = new Map([ + [UnitID.BazookaBear, new Map([[Skill.BuyUnitBazookaBear, [1, 3]]])], + [UnitID.Cannon, new Map([[Skill.ArtilleryRangeIncrease, [2, 8]]])], + [UnitID.HeavyArtillery, new Map([[Skill.ArtilleryRangeIncrease, [3, 7]]])], + [UnitID.Artillery, new Map([[Skill.ArtilleryRangeIncrease, [2, 6]]])], +]); + +const skillMovementTypeRadiusEffects = new Map< + MovementType, + Map +>([ + [ + MovementTypes.Tires, + new Map([[Skill.MovementIncreaseGroundUnitDefenseDecrease, 1]]), + ], + [ + MovementTypes.Tread, + new Map([[Skill.MovementIncreaseGroundUnitDefenseDecrease, 1]]), + ], + [ + MovementTypes.HeavySoldier, + new Map([[Skill.UnitAPUAttackIncreaseMajorPower, 1]]), + ], +]); + +const skillMovementTypeRadiusPowerEffects = new Map< + MovementType, + Map +>([ + [ + MovementTypes.Tires, + new Map([[Skill.MovementIncreaseGroundUnitDefenseDecrease, 1]]), + ], + [ + MovementTypes.Tread, + new Map([[Skill.MovementIncreaseGroundUnitDefenseDecrease, 1]]), + ], +]); + +const unitCosts = new Map>([ + [UnitID.Cannon, new Map([[Skill.BuyUnitCannon, 450]])], + [UnitID.BazookaBear, new Map([[Skill.BuyUnitBazookaBear, 800]])], + [UnitID.Brute, new Map([[Skill.BuyUnitBrute, 600]])], + [UnitID.Zombie, new Map([[Skill.BuyUnitZombieDefenseDecreaseMajor, 250]])], + [UnitID.AIU, new Map([[Skill.BuyUnitAIU, 300]])], +]); + +const buildingUnlocks = new Map>([ + [BarID, new Set([Skill.BuyUnitBazookaBear])], +]); + +const blockedUnits = new Map>([ + [ + Skill.BuyUnitZombieDefenseDecreaseMajor, + new Set([UnitID.Pioneer, UnitID.Infantry]), + ], + [Skill.BuyUnitAIU, new Set([UnitID.Infantry])], +]); + +const healPower = new Map([ + [ + Skill.HealVehiclesAttackDecrease, + new Set([MovementTypes.Tires, MovementTypes.Tread, MovementTypes.Rail]), + ], + [ + Skill.HealInfantryMedicPower, + new Set([MovementTypes.AirInfantry, MovementTypes.Soldier]), + ], +]); + +export function getSkillConfig(skill: Skill) { + const config = skillConfig[skill]; + if (!config) { + throw new UnknownTypeError('getSkillConfig', String(skill)); + } + return config; +} + +const getUnitStatusEffect = ( + unitStatusEffects: UnitSkillMap | null, + skill: Skill, + entity: Readonly<{ type: EntityType }> | UnitInfo, +) => + unitStatusEffects && isUnitInfo(entity) + ? unitStatusEffects.get(skill)?.get(entity.id) ?? 0 + : 0; + +const getMovementStatusEffect = ( + movementStatusEffects: MovementSkillMap | null, + skill: Skill, + entity: Readonly<{ type: EntityType }> | UnitInfo, +) => + movementStatusEffects && isUnitInfo(entity) + ? movementStatusEffects.get(skill)?.get(entity.movementType) ?? 0 + : 0; + +const getTileStatusEffect = ( + tileStatusEffects: TileMovementSkillMap | null, + skill: Skill, + entity: Readonly<{ type: EntityType }> | UnitInfo, + tile: TileInfo | null, +) => + tile && tileStatusEffects && isUnitInfo(entity) + ? tileStatusEffects.get(skill)?.get(tile.group)?.get(entity.movementType) ?? + 0 + : 0; + +const someOneSkill = ( + statusEffects: SkillMap, + unitStatusEffects: UnitSkillMap | null, + movementStatusEffects: MovementSkillMap | null, + tileStatusEffects: TileMovementSkillMap | null, + entity: Readonly<{ type: EntityType }> | UnitInfo, + tile: TileInfo | null, + skill: Skill | undefined, +) => + skill + ? (statusEffects.get(skill) ?? 0) + + getUnitStatusEffect(unitStatusEffects, skill, entity) + + getMovementStatusEffect(movementStatusEffects, skill, entity) + + getTileStatusEffect(tileStatusEffects, skill, entity, tile) + : 0; + +const sum = ( + statusEffects: SkillMap, + unitStatusEffects: UnitSkillMap | null, + movementStatusEffects: MovementSkillMap | null, + tileStatusEffects: TileMovementSkillMap | null, + entity: Readonly<{ type: EntityType }> | UnitInfo, + tile: TileInfo | null, + skills: ReadonlySet, +) => { + if (!skills.size) { + return 0; + } + + const sumOne = someOneSkill.bind( + null, + statusEffects, + unitStatusEffects, + movementStatusEffects, + tileStatusEffects, + entity, + tile, + ); + + if (skills.size === 1) { + return sumOne(skills.values().next().value); + } + + if (skills.size === 2) { + const [skillA, skillB] = skills; + return sumOne(skillA) + sumOne(skillB); + } + + let statusEffect = 0; + for (const skill of skills) { + statusEffect += sumOne(skill); + } + return statusEffect; +}; + +const sumAll = ( + statusEffects: SkillMap, + unitStatusEffects: UnitSkillMap | null, + movementStatusEffects: MovementSkillMap | null, + activeStatusEffects: SkillMap, + activeUnitStatusEffects: UnitSkillMap | null, + activeMovementStatusEffects: MovementSkillMap | null, + tileStatusEffects: TileMovementSkillMap | null, + activeTileStatusEffects: TileMovementSkillMap | null, + entity: Readonly<{ type: EntityType }> | UnitInfo, + tile: TileInfo | null, + skills: ReadonlySet, + activeSkills: ReadonlySet, +) => + sum( + statusEffects, + unitStatusEffects, + movementStatusEffects, + tileStatusEffects, + entity, + tile, + skills, + ) + + sum( + activeStatusEffects, + activeUnitStatusEffects, + activeMovementStatusEffects, + activeTileStatusEffects, + entity, + tile, + activeSkills, + ); + +export const getSkillAttackStatusEffects = sumAll.bind( + null, + attackStatusEffects, + null, + null, + attackPowerStatusEffects, + attackUnitPowerStatusEffects, + attackMovementTypePowerStatusEffects, + null, + attackTilePowerStatusEffects, +); + +export const getSkillDefenseStatusEffects = sumAll.bind( + null, + defenseStatusEffects, + null, + defenseMovementTypeStatusEffects, + defensePowerStatusEffects, + null, + null, + defenseTileStatusEffects, + null, +); + +export const getSkillUnitCostEffects = sumAll.bind( + null, + unitCostEffects, + null, + null, + unitCostPowerEffects, + null, + null, + null, + null, +); + +export function getBlockedUnits(skill: Skill) { + return blockedUnits.get(skill); +} + +const unitIsBlocked = (unit: UnitInfo, skill: Skill) => + blockedUnits.get(skill)?.has(unit.id); + +export function getUnitCost( + unit: UnitInfo, + cost: number, + skills: ReadonlySet, + activeSkills: ReadonlySet, +) { + if (skills.size === 0) { + return cost; + } + + const modifier = + 1 + getSkillUnitCostEffects(unit, null, skills, activeSkills); + const costs = unitCosts.get(unit.id); + let min = cost; + if (skills.size === 1) { + const skill: Skill = skills.values().next().value; + return unitIsBlocked(unit, skill) + ? Number.POSITIVE_INFINITY + : Math.floor((costs?.get(skill) || min) * modifier); + } + + for (const skill of skills) { + if (unitIsBlocked(unit, skill)) { + return Number.POSITIVE_INFINITY; + } + + const cost = costs?.get(skill); + if (cost && (!min || cost < min)) { + min = cost; + } + } + + return Math.floor(min * modifier); +} + +export function hasUnlockedBuilding( + building: BuildingInfo, + skills: ReadonlySet, +) { + if (!building.configuration.requiresUnlock) { + return building.configuration.cost < Number.POSITIVE_INFINITY; + } + + if (skills.size === 0) { + return false; + } + + const unlocks = buildingUnlocks.get(building.id); + if (unlocks) { + for (const skill of unlocks) { + if (skills.has(skill)) { + return true; + } + } + } + + return false; +} + +export function hasUnlockedUnit(unit: UnitInfo, skills: ReadonlySet) { + if (skills.size === 0) { + return false; + } + + const unlocks = unitCosts.get(unit.id); + if (unlocks) { + for (const [skill] of unlocks) { + if (skills.has(skill)) { + return true; + } + } + } + + return false; +} + +const getSkillUnitRadius = ( + unit: UnitInfo, + skillRadiusEffects: ReadonlyMap>, + skills: ReadonlySet, +) => { + let radius = 0; + if (skills.size === 0) { + return radius; + } + + const movement = skillRadiusEffects.get(unit.movementType); + if (movement) { + if (skills.size === 1) { + return radius + (movement.get(skills.values().next().value) || 0); + } + + for (const skill of skills) { + radius = radius + (movement.get(skill) || 0); + } + } + + return radius; +}; + +export function getUnitRadius( + unit: UnitInfo, + radius: number, + skills: ReadonlySet, + activeSkills: ReadonlySet, +) { + return ( + radius + + getSkillUnitRadius(unit, skillMovementTypeRadiusEffects, skills) + + getSkillUnitRadius(unit, skillMovementTypeRadiusPowerEffects, activeSkills) + ); +} + +const getSkillUnitRange = ( + unit: UnitInfo, + effects: RangeMap, + skills: ReadonlySet, +) => { + if (skills.size === 0) { + return null; + } + + const rangeMap = effects.get(unit.id); + if (rangeMap) { + if (skills.size === 1) { + return rangeMap.get(skills.values().next().value); + } + + for (const skill of skills) { + const newRange = rangeMap.get(skill); + if (newRange) { + return newRange; + } + } + } + + return null; +}; + +export function getUnitRange( + unit: UnitInfo, + range: [number, number], + skills: ReadonlySet, + activeSkills: ReadonlySet, +): [number, number] { + return ( + getSkillUnitRange(unit, skillRangePowerEffects, activeSkills) || + getSkillUnitRange(unit, skillRangeEffects, skills) || + range + ); +} + +export function getSkillEffect(skillType: SkillStatusType, skill: Skill) { + switch (skillType) { + case 'attack': + return attackStatusEffects.get(skill) ?? 0; + case 'attack-power': + return attackPowerStatusEffects.get(skill) ?? 0; + case 'defense': + return defenseStatusEffects.get(skill) ?? 0; + case 'defense-power': + return defensePowerStatusEffects.get(skill) ?? 0; + case 'unit-cost': + return unitCostEffects.get(skill) ?? 0; + case 'unit-cost-power': + return unitCostPowerEffects.get(skill) ?? 0; + default: { + skillType satisfies never; + throw new UnknownTypeError('getSkillEffect', skillType); + } + } +} + +export function getSkillDefenseMovementTypeStatusEffect( + skill: Skill, + type: SkillActivationType, +) { + return ( + (type === 'regular' && defenseMovementTypeStatusEffects.get(skill)) || null + ); +} + +export function getSkillTileDefenseStatusEffect( + skill: Skill, + type: SkillActivationType, +) { + return (type === 'regular' && defenseTileStatusEffects.get(skill)) || null; +} + +export function getSkillAttackUnitStatusEffect( + skill: Skill, + type: SkillActivationType, +) { + return type === 'regular' + ? null + : attackUnitPowerStatusEffects.get(skill) || null; +} + +export function getSkillAttackMovementTypeStatusEffect( + skill: Skill, + type: SkillActivationType, +) { + return type === 'regular' + ? null + : attackMovementTypePowerStatusEffects.get(skill) || null; +} + +export function getSkillTileAttackStatusEffect( + skill: Skill, + type: SkillActivationType, +) { + return ( + (type === 'regular' ? null : attackTilePowerStatusEffects.get(skill)) || + null + ); +} + +export function getSkillUnitCosts(skill: Skill, type: SkillActivationType) { + const skillUnitCosts = new Map(); + if (type === 'regular') { + for (const [unitID, costs] of unitCosts) { + const cost = costs.get(skill); + if (cost) { + skillUnitCosts.set(unitID, cost); + } + } + } + return skillUnitCosts; +} + +export function getSkillUnitMovement(skill: Skill, type: SkillActivationType) { + const movementTypes = new Map(); + const effects = + type === 'regular' + ? skillMovementTypeRadiusEffects + : skillMovementTypeRadiusPowerEffects; + for (const [unit, movement] of effects) { + const radius = movement.get(skill); + if (radius) { + movementTypes.set(unit, (movementTypes.get(unit) || 0) + radius); + } + } + return movementTypes; +} + +export function getUnitRangeForSkill(skill: Skill, type: SkillActivationType) { + const unitTypes = new Map(); + const effects = + type === 'regular' ? skillRangeEffects : skillRangePowerEffects; + for (const [unit, rangeMap] of effects) { + const range = rangeMap.get(skill); + if (range) { + unitTypes.set(unit, range); + } + } + return unitTypes; +} + +const getSkillActiveUnitTypes = ( + map: MapData, + player: Player, + skill: Skill, +): ReadonlyArray | 'all' => { + if ( + skill === Skill.CounterAttackPower || + attackPowerStatusEffects.has(skill) || + defensePowerStatusEffects.has(skill) + ) { + return 'all'; + } + + const list = []; + + const units = attackUnitPowerStatusEffects.get(skill); + if (units) { + list.push(...units.keys()); + } + + const movementType = attackMovementTypePowerStatusEffects.get(skill); + if (movementType) { + list.push(...movementType.keys()); + } + + for (const [movementType, skills] of skillMovementTypeRadiusPowerEffects) { + if (skills.has(skill)) { + list.push(movementType); + } + } + + const attackTileMap = attackTilePowerStatusEffects.get(skill); + if (attackTileMap) { + list.push( + ...map.units + .filter( + (unit, vector) => + map.matchesPlayer(player, unit) && + attackTileMap + .get(map.getTileInfo(vector).group) + ?.get(unit.info.movementType), + ) + .keys(), + ); + } + + return list; +}; + +export function getActiveUnitTypes( + map: MapData, + player: Player, +): ActiveUnitTypes { + const skills = player.activeSkills; + if (!skills.size) { + return new Set(); + } + + if (skills.size === 1) { + const value = getSkillActiveUnitTypes( + map, + player, + skills.values().next().value, + ); + return value === 'all' ? value : new Set(value); + } + + const activeUnitTypes = new Set(); + for (const skill of skills) { + const active = getSkillActiveUnitTypes(map, player, skill); + if (active === 'all') { + return active; + } + for (const value of active) { + activeUnitTypes.add(value); + } + } + + return activeUnitTypes; +} + +export function getHealUnitTypes(skill: Skill) { + return healPower.get(skill); +} + +export function applyPower(skill: Skill, map: MapData) { + const healTypes = healPower.get(skill); + + if (healTypes) { + const player = map.getCurrentPlayer(); + return map.copy({ + units: map.units.map((unit) => + map.matchesPlayer(player, unit) && + matchesActiveType(healTypes, unit, null) + ? unit.modifyHealth(HealAmount) + : unit, + ), + }); + } + + return map; +} + +export function hasCounterAttackSkill(skills: ReadonlySet) { + return skills.has(Skill.CounterAttackPower); +} diff --git a/athena/info/SpriteVariants.tsx b/athena/info/SpriteVariants.tsx new file mode 100644 index 00000000..fef86ca7 --- /dev/null +++ b/athena/info/SpriteVariants.tsx @@ -0,0 +1,66 @@ +export type SpriteVariant = + | 'AttackOctopus' + | 'Building-Create' + | 'Buildings' + | 'BuildingsShadow' + | 'Decorators' + | 'Label' + | 'NavalExplosion' + | 'Portraits' + | 'Rescue' + | 'Spawn' + | 'StructuresShadow' + | 'Units-AIU' + | 'Units-APU' + | 'Units-AcidBomber' + | 'Units-Alien' + | 'Units-Amphibious' + | 'Units-AntiAir' + | 'Units-ArtilleryHumvee' + | 'Units-BattleShip' + | 'Units-BazookaBear' + | 'Units-Bear' + | 'Units-Bomber' + | 'Units-Brute' + | 'Units-Cannon' + | 'Units-Commander' + | 'Units-Corvette' + | 'Units-Destroyer' + | 'Units-Dinosaur' + | 'Units-Dragon' + | 'Units-Drone' + | 'Units-FighterJet' + | 'Units-Flamethrower' + | 'Units-Frigate' + | 'Units-HeavyArtillery' + | 'Units-HeavyTank' + | 'Units-Helicopter' + | 'Units-Hovercraft' + | 'Units-Humvee' + | 'Units-HumveeAvenger' + | 'Units-Infantry' + | 'Units-Jeep' + | 'Units-Jetpack' + | 'Units-Lander' + | 'Units-Mammoth' + | 'Units-Medic' + | 'Units-MobileArtillery' + | 'Units-Octopus' + | 'Units-Ogre' + | 'Units-Pioneer' + | 'Units-ReconDrone' + | 'Units-RocketLauncher' + | 'Units-Saboteur' + | 'Units-SeaPatrol' + | 'Units-SmallHovercraft' + | 'Units-SmallTank' + | 'Units-Sniper' + | 'Units-SuperAPU' + | 'Units-SuperTank' + | 'Units-SupplyTrain' + | 'Units-SupportShip' + | 'Units-TransportHelicopter' + | 'Units-TransportTrain' + | 'Units-Truck' + | 'Units-XFighter' + | 'Units-Zombie'; diff --git a/athena/info/Tile.tsx b/athena/info/Tile.tsx new file mode 100644 index 00000000..15012162 --- /dev/null +++ b/athena/info/Tile.tsx @@ -0,0 +1,1703 @@ +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import { Modifier } from '../lib/Modifier.tsx'; +import { Biome } from '../map/Biome.tsx'; +import SpriteVector from '../map/SpriteVector.tsx'; +import { isVector } from '../map/Vector.tsx'; +import { ID, TileMap } from '../MapData.tsx'; +import { MovementType, MovementTypes } from './MovementType.tsx'; + +export enum RenderType { + Composite = 0, + Horizontal = 1, + Vertical = 2, + Quarter = 4, +} + +const { Composite, Horizontal, Quarter, Vertical } = RenderType; + +type CompositeModifierValue = [ + RenderType.Composite, + SpriteVector, + SpriteVector, +]; + +type HorizontalModifierValue = [ + RenderType.Horizontal, + SpriteVector, + SpriteVector, +]; +type VerticalModifierValue = [RenderType.Vertical, SpriteVector, SpriteVector]; + +type QuarterModifierValue = [ + RenderType.Quarter, + SpriteVector, + SpriteVector, + SpriteVector, + SpriteVector, +]; + +type ModifierValue = + | SpriteVector + | CompositeModifierValue + | HorizontalModifierValue + | VerticalModifierValue + | QuarterModifierValue; + +export type TileField = number | [number, number]; +export type TileLayer = 0 | 1; + +export type TileAnimation = Readonly<{ + frames: number; + horizontal?: true; + modifiers?: Set; + offset: number; + ticks: number; +}>; + +const sprite = (x: number, y: number) => new SpriteVector(x, y); + +const composite = ( + tile: TileInfo, + modifier: Modifier, + sprite: SpriteVector, +): [Modifier, CompositeModifierValue] => { + const modifierValue = tile.sprite.modifiers.get(modifier); + if (!modifierValue || Array.isArray(modifierValue)) { + throw new Error('Oops'); + } + return [modifier, [Composite, modifierValue, sprite]]; +}; + +const horizontal = ( + l: SpriteVector, + r: SpriteVector, +): HorizontalModifierValue => [Horizontal, l, r.down(0.5)]; +const vertical = (u: SpriteVector, d: SpriteVector): VerticalModifierValue => [ + Vertical, + u, + d.right(0.5), +]; + +const quarter = ( + lu: SpriteVector, + ru: SpriteVector, + ld: SpriteVector, + rd: SpriteVector, +): QuarterModifierValue => [ + Quarter, + lu, + ru.right(0.5), + ld.down(0.5), + rd.right(0.5).down(0.5), +]; + +export class TileDecoratorInfo { + // @ts-expect-error Add an invisible private prop to tell + // TypeScript that this class does not conform to TileInfo. + private readonly _TileDecoratorInfo = undefined; + constructor( + public readonly position: SpriteVector, + public readonly animation?: TileAnimation | null, + private readonly biomes?: ReadonlySet, + ) {} + + public isVisible(biome: Biome) { + return !this.biomes || this.biomes.has(biome); + } +} + +export class TileInfo { + public readonly configuration: { + cover: number; + movement: ReadonlyMap; + transitionCost?: ReadonlyMap; + vision: number; + }; + public readonly group: TileType; + public readonly sprite: { + alternate: boolean; + animation?: TileAnimation | null; + modifiers: ReadonlyMap; + noClip?: boolean | Biome; + position: SpriteVector; + }; + public readonly type: number; + public readonly style: { + connectsWith?: TileInfo; + crossesWith?: number; + decorator?: TileDecoratorInfo; + fallback?: TileInfo; + hidden: boolean; + isolated: boolean; + layer: TileLayer; + }; + + constructor( + public readonly id: ID, + private readonly internalName: string, + private readonly internalDescription: string, + group: TileType | { group: TileType; type: number }, + configuration: { + cover: number; + movement: ReadonlyMap; + transitionCost?: ReadonlyMap; + vision?: number; + }, + sprite: + | { + alternate?: boolean; + animation?: TileAnimation | null; + modifiers?: ReadonlyMap; + noClip?: boolean | Biome; + position: SpriteVector; + } + | SpriteVector, + style?: { + connectsWith?: TileInfo; + crossesWith?: number; + decorator?: TileDecoratorInfo; + fallback?: TileInfo; + hidden?: boolean; + isolated?: boolean; + layer?: TileLayer; + }, + ) { + this.group = typeof group === 'number' ? group : group.group; + this.type = this.group | (typeof group === 'number' ? 0 : group.type); + this.configuration = { + vision: 1, + ...configuration, + }; + this.sprite = { + alternate: false, + modifiers: new Map(), + ...(isVector(sprite) ? { position: sprite } : sprite), + }; + this.style = { + ...style, + hidden: style?.hidden || false, + isolated: style?.isolated || false, + layer: style?.layer || 0, + }; + } + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } + + get description() { + Object.defineProperty(this, 'description', { + value: this.internalDescription, + }); + return this.internalDescription; + } + + getMovementCost({ movementType }: { movementType: MovementType }): number { + return this.configuration.movement.get(movementType) || -1; + } + + getTransitionCost({ movementType }: { movementType: MovementType }): number { + return this.configuration.transitionCost?.get(movementType) || 0; + } + + isInaccessible() { + return this.type & TileTypes.Inaccessible; + } +} + +export const SeaAnimation = { + frames: 4, + offset: 3, + ticks: 6, +} as const; + +const HorizontalSeaAnimation = { + ...SeaAnimation, + horizontal: true, + offset: 1, +} as const; + +/* eslint-disable sort-keys-fix/sort-keys-fix */ +export const TileTypes = { + Plain: 2, + Forest: 4, + ForestVariant2: 8, + ForestVariant3: 16, + ForestVariant4: 32, + Mountain: 64, + Street: 128, + Trench: 256, + River: 512, + ConstructionSite: 1024, + Pier: 2048, + Airfield: 4096, + Sea: 8192, + DeepSea: 16_384, + Bridge: 32_768, + ConnectWithEdge: 65_536, + RailTrack: 131_072, + AreaDecorator: 262_144, + Campsite: 524_288, + StormCloud: 1_048_576, + AreaMatchesAll: 2_097_152, + Pipe: 4_194_304, + Teleporter: 8_388_608, + Joinable: 16_777_216, + Area: 33_554_432, + Inaccessible: 67_108_864, +} as const; +/* eslint-enable sort-keys-fix/sort-keys-fix */ + +type TileTypeT = typeof TileTypes; +export type TileType = TileTypeT[keyof TileTypeT]; + +const PlainMovementCosts = new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, 1], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, 1], + [MovementTypes.Tread, 1], +]); + +const InaccessibleMovementCosts = new Map([ + [MovementTypes.Air, -1], + [MovementTypes.AirInfantry, -1], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, -1], + [MovementTypes.HeavySoldier, -1], + [MovementTypes.LowAltitude, -1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], +]); + +const JoinableModifiers = new Map([ + [Modifier.Vertical, sprite(1, 0)], + [Modifier.TopLeftCorner, sprite(-3, 0)], + [Modifier.TRight, sprite(-3, 1)], + [Modifier.BottomLeftCorner, sprite(-3, 2)], + [Modifier.TBottom, sprite(-2, 0)], + [Modifier.JoinableCenter, sprite(-2, 1)], + [Modifier.TTop, sprite(-2, 2)], + [Modifier.TopRightCorner, sprite(-1, 0)], + [Modifier.TLeft, sprite(-1, 1)], + [Modifier.Single, vertical(sprite(0, 2), sprite(0, 1))], + [Modifier.BottomRightCorner, sprite(-1, 2)], + + [Modifier.TailUp, sprite(1, 2)], + [Modifier.TailDown, sprite(1, 1)], + [Modifier.TailLeft, sprite(0, 2)], + [Modifier.TailRight, sprite(0, 1)], +]); + +const RiverModifiers = (() => { + const lt = sprite(-1, 0); + const rt = sprite(1, 0); + const lb = sprite(-1, 2); + const rb = sprite(1, 2); + return new Map([ + [Modifier.Single, quarter(lt, rt, lb, rb)], + [Modifier.Vertical, sprite(-1, 1)], + + [Modifier.BottomLeftCorner, lb], + [Modifier.BottomRightCorner, rb], + [Modifier.JoinableCenter, sprite(2, 2)], + [Modifier.TBottom, sprite(3, 0)], + [Modifier.TLeft, sprite(3, 1)], + [Modifier.TopLeftCorner, lt], + [Modifier.TopRightCorner, rt], + [Modifier.TRight, sprite(2, 0)], + [Modifier.TTop, sprite(2, 1)], + + [Modifier.TailUp, vertical(lt, rt)], + [Modifier.TailDown, vertical(lb, rb)], + [Modifier.TailLeft, horizontal(lt, lb)], + [Modifier.TailRight, horizontal(rt, rb)], + + [Modifier.ConnectingTailDown, sprite(-1, 1)], + [Modifier.ConnectingTailLeft, sprite(0, 0)], + [Modifier.ConnectingTailRight, sprite(0, 0)], + [Modifier.ConnectingTailUp, sprite(-1, 1)], + ]); +})(); + +const AreaModifiers = (() => { + /* + * b = bottom + * t = top + * l = left + * r = right + * e = edge + * w = wall + */ + const b = sprite(0, 1); + const lb = sprite(-1, 1); + const lt = sprite(-1, -1); + const rb = sprite(1, 1); + const rt = sprite(1, -1); + const t = sprite(0, -1); + const l = sprite(-1, 0); + const r = sprite(1, 0); + const bre = sprite(2, -1); + const tre = sprite(2, 0); + const ble = sprite(3, -1); + const tle = sprite(3, 0); + const lw = sprite(-1, 0); + const tw = sprite(0, -1); + const bw = sprite(0, 1); + const rw = sprite(1, 0); + return new Map([ + [Modifier.Single, quarter(lt, rt, lb, rb)], + [Modifier.Horizontal, horizontal(t, b)], + [Modifier.Vertical, vertical(l, r)], + [Modifier.TopLeftCorner, quarter(lt, lt, lt, bre)], + [Modifier.TRight, quarter(lw, tre, lw, bre)], + [Modifier.BottomLeftCorner, quarter(lb, tre, lb, lb)], + [Modifier.TBottom, quarter(tw, tw, ble, bre)], + [Modifier.JoinableCenter, quarter(tle, tre, ble, bre)], + [Modifier.TTop, quarter(tle, tre, bw, bw)], + [Modifier.TopRightCorner, quarter(rt, rt, ble, rt)], + [Modifier.TLeft, quarter(tle, rw, ble, rw)], + [Modifier.BottomRightCorner, quarter(tle, rb, rb, rb)], + + [Modifier.TailUp, vertical(lt, rt)], + [Modifier.TailDown, vertical(lb, rb)], + [Modifier.TailLeft, horizontal(lt, lb)], + [Modifier.TailRight, horizontal(rt, rb)], + + [Modifier.Center, sprite(0, 0)], + [Modifier.TopLeftAreaCorner, lt], + [Modifier.TopRightAreaCorner, rt], + [Modifier.BottomLeftAreaCorner, lb], + [Modifier.BottomRightAreaCorner, rb], + + [Modifier.LeftWall, lw], + [Modifier.TopWall, tw], + [Modifier.BottomWall, bw], + [Modifier.RightWall, rw], + + [Modifier.BottomRightEdge, bre], + [Modifier.TopRightEdge, tre], + [Modifier.BottomLeftEdge, ble], + [Modifier.TopLeftEdge, tle], + + [Modifier.TopRightBottomLeftEdge, horizontal(tre, ble)], + [Modifier.TopLeftBottomRightEdge, horizontal(tle, bre)], + [Modifier.BottomLeftAndRightEdge, vertical(ble, bre)], + [Modifier.TopLeftAndRightEdge, vertical(tle, tre)], + [Modifier.TopRightBottomRightEdge, horizontal(tre, bre)], + [Modifier.TopLeftBottomLeftEdge, horizontal(tle, ble)], + + [Modifier.TopRightIsArea, quarter(tle, tle, ble, bre)], + [Modifier.TopLeftIsArea, quarter(tre, tre, ble, bre)], + [Modifier.BottomRightIsArea, quarter(tle, tre, ble, ble)], + [Modifier.BottomLeftIsArea, quarter(tle, tre, bre, bre)], + + [Modifier.RightWallBottomLeftEdge, vertical(ble, rw)], + [Modifier.LeftWallBottomRightEdge, vertical(lw, bre)], + [Modifier.BottomWallRightTopEdge, horizontal(tre, bw)], + [Modifier.TopWallBottomLeftEdge, horizontal(tw, ble)], + [Modifier.BottomWallLeftTopEdge, horizontal(tle, bw)], + [Modifier.LeftWallTopRightEdge, vertical(lw, tre)], + [Modifier.RightWallTopLeftEdge, vertical(tle, rw)], + [Modifier.TopWallRightBottomEdge, horizontal(tw, bre)], + ]); +})(); + +export const Plain = new TileInfo( + 1, + 'Plain', + `A flat terrain with minimal cover, allowing most units to move freely.`, + TileTypes.Plain, + { cover: 5, movement: PlainMovementCosts }, + { + modifiers: new Map([ + [Modifier.Variant2, sprite(1, 0)], + [Modifier.Variant3, sprite(2, 0)], + ]), + position: sprite(0, 0), + }, + { isolated: true }, +); + +export const Forest = new TileInfo( + 2, + 'Forest', + `Dense woodland providing moderate cover but slowing down light vehicles and tanks. When it is foggy, units hiding in forests are only visible to adjacent units.`, + { + group: TileTypes.Forest, + type: TileTypes.Joinable, + }, + { + cover: 20, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, 1], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, 2], + [MovementTypes.Tread, 2], + ]), + }, + { + modifiers: new Map([ + ...JoinableModifiers, + [Modifier.Single, sprite(-3, -1)], + ]), + position: sprite(3, 19), + }, + { + hidden: true, + isolated: true, + }, +); + +export const Forest2 = new TileInfo( + 12, + 'Forest', + `Dense woodland providing moderate cover but slowing down light vehicles and tanks. When it is foggy, units hiding in forests are only visible to adjacent units.`, + { + group: TileTypes.ForestVariant2, + type: TileTypes.Joinable, + }, + Forest.configuration, + { + ...Forest.sprite, + position: Forest.sprite.position.down(4), + }, + Forest.style, +); + +export const Forest3 = new TileInfo( + 23, + 'Forest', + `Dense woodland providing moderate cover but slowing down light vehicles and tanks. When it is foggy, units hiding in forests are only visible to adjacent units.`, + TileTypes.ForestVariant3, + Forest.configuration, + { + ...Forest.sprite, + modifiers: new Map([ + [Modifier.Variant2, sprite(1, 0)], + [Modifier.Variant3, sprite(2, 0)], + [Modifier.Variant4, sprite(3, 0)], + [Modifier.Variant5, sprite(4, 0)], + [Modifier.Variant6, sprite(4, 4)], + [Modifier.Variant7, sprite(4, 5)], + ]), + position: sprite(0, 22), + }, + Forest.style, +); + +export const Mountain = new TileInfo( + 3, + 'Mountain', + `Rugged terrain offering high cover for infantry units, but impassable for tread or tire based vehicles.`, + { + group: TileTypes.Mountain, + type: TileTypes.Joinable, + }, + { + cover: 30, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 2], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, 2], + [MovementTypes.HeavySoldier, 3], + [MovementTypes.LowAltitude, 2], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + vision: 2, + }, + { + alternate: true, + modifiers: new Map([ + ...JoinableModifiers, + [Modifier.Single, sprite(-3, -1)], + ]), + position: sprite(3, 7), + }, + { isolated: true }, +); + +export const Street = new TileInfo( + 4, + 'Street', + `Paved roads allowing swift movement for most units but offering no cover.`, + { + group: TileTypes.Street, + type: TileTypes.Joinable, + }, + { + cover: 0, + movement: PlainMovementCosts, + }, + { + modifiers: JoinableModifiers, + position: sprite(3, 3), + }, +); + +export const River = new TileInfo( + 5, + 'River', + `The fast flowing water slows down movement for most infantry units, and is inaccessible to light vehicles and tanks.`, + { + group: TileTypes.River, + type: TileTypes.Joinable | TileTypes.ConnectWithEdge, + }, + { + cover: 0, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, 2], + [MovementTypes.HeavySoldier, 2], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + }, + { + animation: { + frames: 24, + offset: 3, + ticks: 1, + }, + modifiers: RiverModifiers, + position: sprite(1, 73), + }, + { isolated: true }, +); + +export const Sea = new TileInfo( + 6, + 'Sea', + `Expansive water bodies only navigable by ships and aircraft.`, + { group: TileTypes.Sea, type: TileTypes.Joinable | TileTypes.Area }, + { + cover: 10, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 1], + [MovementTypes.Soldier, -1], + [MovementTypes.HeavySoldier, -1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, 1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + transitionCost: new Map([[MovementTypes.Amphibious, 1]]), + }, + { + animation: SeaAnimation, + modifiers: new Map([ + ...AreaModifiers, + + [Modifier.RiverFlowsFromTop, sprite(-2, 38)], + [Modifier.RiverFlowsFromBottom, sprite(-2, 40)], + [Modifier.RiverFlowsFromLeft, sprite(-3, 39)], + [Modifier.RiverFlowsFromRight, sprite(-1, 39)], + ]), + position: sprite(8, 35), + }, + { isolated: true }, +); + +export const DeepSea = new TileInfo( + 21, + 'Deep Sea', + `Treacherous waters navigable only by advanced ships and aircraft; low altitude and amphibious units risk getting lost at sea.`, + { + group: TileTypes.DeepSea, + type: TileTypes.Joinable | TileTypes.Area | TileTypes.Sea, + }, + { + cover: 10, + movement: new Map([ + ...Sea.configuration.movement, + [MovementTypes.AirInfantry, -1], + [MovementTypes.Amphibious, -1], + [MovementTypes.LowAltitude, -1], + ]), + }, + { + animation: SeaAnimation, + modifiers: AreaModifiers, + position: sprite(8, 47), + }, + { fallback: Sea, isolated: true }, +); + +export const WaterfallModifiers = new Set([ + Modifier.RiverFlowsFromTop, + Modifier.RiverFlowsFromBottom, + Modifier.RiverFlowsFromLeft, + Modifier.RiverFlowsFromRight, +]); +export const WaterfallAnimation = { + ...River.sprite.animation!, + offset: 3, +} as const; + +export const Ruins = new TileInfo( + 7, + 'Ruins', + `Crumbled structures providing some cover. They are an active area of research where scientists learn more about the history of the current conflict.`, + TileTypes.Plain, + { cover: 20, movement: PlainMovementCosts }, + { + noClip: Biome.Volcano, + position: sprite(2, 2), + }, + { + decorator: new TileDecoratorInfo( + sprite(9, 0), + null, + new Set([Biome.Volcano]), + ), + isolated: true, + }, +); + +export const ConstructionSite = new TileInfo( + 8, + 'Construction Site', + `An area designated for development, offering moderate cover and serving as a potential location for Factories, Barracks, and other buildings.`, + TileTypes.ConstructionSite, + { cover: 15, movement: PlainMovementCosts }, + { + modifiers: new Map([ + [Modifier.Variant2, sprite(1, 0)], + [Modifier.Variant3, sprite(2, 0)], + [Modifier.Variant4, sprite(3, 0)], + ]), + noClip: true, + position: sprite(0, 1), + }, + { decorator: new TileDecoratorInfo(sprite(0, 0)), isolated: true }, +); + +export const Reef = new TileInfo( + 9, + 'Reef', + `Shallow sea areas providing some cover to naval units. When it is foggy, units hiding in reefs are only visible to adjacent units.`, + TileTypes.Sea, + { + cover: 25, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, -1], + [MovementTypes.HeavySoldier, -1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, 2], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + }, + { + animation: HorizontalSeaAnimation, + modifiers: new Map([ + [Modifier.Variant2, sprite(0, 1)], + [Modifier.Variant3, sprite(0, 2)], + [Modifier.Variant4, sprite(0, 3)], + ]), + position: sprite(5, 18), + }, + { fallback: Sea, hidden: true, isolated: true, layer: 1 }, +); + +export const Beach = new TileInfo( + 10, + 'Beach', + `Coastal areas with low cover allowing sea units to load and unload ground units.`, + { + group: TileTypes.Sea, + type: TileTypes.Area | TileTypes.AreaDecorator | TileTypes.Joinable, + }, + { + cover: 5, + movement: new Map([ + ...PlainMovementCosts, + [MovementTypes.Amphibious, 1], + [MovementTypes.Ship, 1], + ]), + transitionCost: new Map([[MovementTypes.Amphibious, 1]]), + }, + { + animation: { ...SeaAnimation, offset: 6 }, + modifiers: new Map([ + [Modifier.Single, sprite(-3, -1)], + + [Modifier.TailUp, sprite(3, -1)], + [Modifier.TailDown, sprite(2, 0)], + [Modifier.TailLeft, sprite(3, 0)], + [Modifier.TailRight, sprite(2, -1)], + + [Modifier.LeftWall, sprite(-1, 1)], + [Modifier.TopWall, sprite(0, 0)], + [Modifier.BottomWall, sprite(0, 2)], + [Modifier.RightWall, sprite(1, 1)], + [Modifier.LeftWallAreaDecorator, sprite(-2, 2)], + [Modifier.LeftWallTopAreaDecorator, sprite(-2, 1)], + [Modifier.LeftWallBottomAreaDecorator, sprite(-2, 0)], + [Modifier.TopWallAreaDecorator, sprite(-1, 4)], + [Modifier.TopWallLeftAreaDecorator, sprite(-2, 4)], + [Modifier.TopWallRightAreaDecorator, sprite(-3, 4)], + [Modifier.BottomWallAreaDecorator, sprite(-1, 3)], + [Modifier.BottomWallLeftAreaDecorator, sprite(-2, 3)], + [Modifier.BottomWallRightAreaDecorator, sprite(-3, 3)], + [Modifier.RightWallAreaDecorator, sprite(-3, 2)], + [Modifier.RightWallTopAreaDecorator, sprite(-3, 1)], + [Modifier.RightWallBottomAreaDecorator, sprite(-3, 0)], + + [Modifier.BottomRightAreaCorner, sprite(1, 2)], + [Modifier.TopRightAreaCorner, sprite(1, 0)], + [Modifier.BottomLeftAreaCorner, sprite(-1, 2)], + [Modifier.TopLeftAreaCorner, sprite(-1, 0)], + + [Modifier.TopLeftAreaDecorator, sprite(-2, -1)], + [Modifier.TopRightAreaDecorator, sprite(1, -1)], + [Modifier.BottomLeftAreaDecorator, sprite(-1, -1)], + [Modifier.BottomRightAreaDecorator, sprite(0, -1)], + + [Modifier.TopLeftBottomAreaDecorator, sprite(3, 3)], + [Modifier.TopLeftRightAreaDecorator, sprite(0, 4)], + [Modifier.TopRightBottomAreaDecorator, sprite(2, 3)], + [Modifier.TopRightLeftAreaDecorator, sprite(1, 4)], + + [Modifier.BottomLeftTopAreaDecorator, sprite(3, 4)], + [Modifier.BottomLeftRightAreaDecorator, sprite(0, 3)], + [Modifier.BottomRightTopAreaDecorator, sprite(2, 4)], + [Modifier.BottomRightLeftAreaDecorator, sprite(1, 3)], + ]), + position: sprite(3, 50), + }, + { fallback: Sea, isolated: true }, +); + +export const Campsite = new TileInfo( + 11, + 'Campsite', + `Unknown`, + TileTypes.Campsite, + { cover: 15, movement: PlainMovementCosts }, + { + animation: { + frames: 4, + horizontal: true, + offset: 1, + ticks: 2, + }, + position: sprite(0, 28), + }, + { isolated: true }, +); + +export const StormCloud = new TileInfo( + 13, + 'Storm Cloud', + `Unknown`, + { + group: TileTypes.StormCloud, + type: TileTypes.Joinable | TileTypes.Inaccessible, + }, + { + cover: Number.POSITIVE_INFINITY, + movement: InaccessibleMovementCosts, + vision: -1, + }, + { + animation: { + frames: 4, + offset: 3, + ticks: 6, + }, + modifiers: new Map([ + [Modifier.Single, sprite(2, 1)], + [Modifier.Horizontal, sprite(0, -1)], + [Modifier.Vertical, sprite(-1, 0)], + [Modifier.TopLeftCorner, sprite(-1, -1)], + [Modifier.BottomLeftCorner, sprite(-1, 1)], + [Modifier.TopRightCorner, sprite(1, -1)], + [Modifier.BottomRightCorner, sprite(1, 1)], + + [Modifier.TailUp, sprite(0, 0)], + [Modifier.TailDown, sprite(0, 1)], + [Modifier.TailLeft, sprite(1, 0)], + [Modifier.TailRight, sprite(2, 0)], + ]), + position: sprite(6, 7), + }, + { isolated: true, layer: 1 }, +); + +// The order must match `Vector.adjacent()`. +export const StormCloudLightningConnectors = [ + sprite(5, 1), + sprite(3, -7), + sprite(3, 1), + sprite(5, -7), +] as const; + +export const Pier = new TileInfo( + 14, + 'Pier', + `Structures extending from the land into the water, allowing ground units to cross over water.`, + { group: TileTypes.Pier, type: TileTypes.Joinable }, + { cover: 10, movement: PlainMovementCosts }, + { + animation: { ...SeaAnimation, offset: 5 }, + modifiers: new Map([ + [Modifier.Single, sprite(0, 0)], + [Modifier.SingleConnectingTailDown, sprite(6, 1)], + [Modifier.SingleConnectingTailLeft, sprite(5, 3)], + [Modifier.SingleConnectingTailRight, sprite(6, 3)], + [Modifier.SingleConnectingTailUp, sprite(6, 0)], + [Modifier.Horizontal, sprite(3, 0)], + [Modifier.Vertical, sprite(0, 3)], + + [Modifier.BottomLeftCorner, sprite(1, 3)], + [Modifier.BottomRightCorner, sprite(3, 3)], + [Modifier.JoinableCenter, sprite(2, 2)], + [Modifier.TBottom, sprite(2, 1)], + [Modifier.TLeft, sprite(3, 2)], + [Modifier.TopLeftCorner, sprite(1, 1)], + [Modifier.TopRightCorner, sprite(3, 1)], + [Modifier.TRight, sprite(1, 2)], + [Modifier.TTop, sprite(2, 3)], + + [Modifier.TailDown, sprite(0, 4)], + [Modifier.TailLeft, sprite(2, 0)], + [Modifier.TailRight, sprite(4, 0)], + [Modifier.TailUp, sprite(0, 2)], + + [Modifier.ConnectingTailDown, sprite(5, 1)], + [Modifier.ConnectingTailLeft, sprite(5, 4)], + [Modifier.ConnectingTailRight, sprite(6, 4)], + [Modifier.ConnectingTailUp, sprite(5, 0)], + ]), + position: sprite(0, 29), + }, + { fallback: Sea, isolated: true, layer: 1 }, +); + +export const ShipyardConstructionSiteDecorator = new TileDecoratorInfo( + sprite(7, 0), + { ...SeaAnimation, offset: 2 }, +); + +export const ShipyardConstructionSite = new TileInfo( + 15, + 'Shipyard Construction Site', + `A strategic location adjacent to water, suitable for constructing a Shipyard to build naval units.`, + { group: TileTypes.Pier, type: TileTypes.Joinable }, + { + ...Pier.configuration, + cover: 20, + }, + { + ...Pier.sprite, + modifiers: new Map( + [...Pier.sprite.modifiers].map(([modifier]) => + composite(Pier, modifier, sprite(4, 2)), + ), + ), + }, + { + ...Pier.style, + decorator: ShipyardConstructionSiteDecorator, + fallback: Pier, + }, +); + +export const Bridge = new TileInfo( + 16, + 'Bridge', + `A structure spanning over water or low ground, allowing units to cross. Ships can create a blockade for ground units.`, + { + group: TileTypes.Street, + type: TileTypes.Bridge | TileTypes.Joinable | TileTypes.ConnectWithEdge, + }, + { + cover: 0, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 1], + [MovementTypes.Soldier, 1], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, 1], + [MovementTypes.Tires, 1], + [MovementTypes.Tread, 1], + ]), + }, + { + modifiers: new Map([ + [Modifier.Horizontal, sprite(2, 0)], + [Modifier.Vertical, sprite(3, -2)], + [Modifier.VerticalSingle, sprite(3, -4)], + [Modifier.Single, sprite(0, 0)], + + [Modifier.ConnectingTailDown, sprite(3, -1)], + [Modifier.ConnectingTailLeft, sprite(1, 0)], + [Modifier.ConnectingTailRight, sprite(3, 0)], + [Modifier.ConnectingTailUp, sprite(3, -3)], + + [Modifier.TailDown, sprite(3, -1)], + [Modifier.TailLeft, sprite(1, 0)], + [Modifier.TailRight, sprite(3, 0)], + [Modifier.TailUp, sprite(3, -3)], + + // Horizontal river and trench bridge could have different variants. + [Modifier.HorizontalCrossing, sprite(0, 0)], + [Modifier.VerticalCrossing, sprite(3, -4)], + [Modifier.Variant2, sprite(0, 0)], + ]), + position: sprite(5, 5), + }, + { connectsWith: Street, layer: 1 }, +); + +export const RailTrack = new TileInfo( + 17, + 'Rail Track', + `Rail tracks allow trains to travel quickly across the terrain to supply units at the frontline or transport them across large distances, ensuring efficient logistics and rapid deployment in the heat of battle.`, + { + group: TileTypes.RailTrack, + type: TileTypes.Joinable, + }, + { + cover: 15, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, 2], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, 1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, 1], + [MovementTypes.Tread, 2], + ]), + }, + { + modifiers: new Map([ + ...JoinableModifiers, + + [Modifier.Single, sprite(1, -1)], + [Modifier.HorizontalCrossing, sprite(-5, 0)], + [Modifier.VerticalCrossing, sprite(-4, 0)], + ]), + position: sprite(10, 28), + }, + { crossesWith: TileTypes.Street }, +); + +export const RailBridge = new TileInfo( + 18, + 'Rail Bridge', + `A bridge structure that extends train tracks over water or low ground, allowing rail units to traverse otherwise impassable areas.`, + { group: TileTypes.RailTrack, type: TileTypes.Bridge | TileTypes.Joinable }, + { + cover: 15, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 1], + [MovementTypes.Soldier, 2], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, 1], + [MovementTypes.Ship, 1], + [MovementTypes.Tires, 2], + [MovementTypes.Tread, 2], + ]), + }, + { + animation: { + ...SeaAnimation, + modifiers: new Set([ + Modifier.ConnectingTailLeft, + Modifier.ConnectingTailRight, + Modifier.Horizontal, + Modifier.TailLeft, + Modifier.TailRight, + ]), + offset: 1, + }, + modifiers: new Map([ + [Modifier.Horizontal, sprite(1, 1)], + [Modifier.Vertical, sprite(2, 0)], + [Modifier.VerticalSingle, sprite(1, 0)], + [Modifier.Single, sprite(0, 0)], + + [Modifier.ConnectingTailDown, horizontal(sprite(2, 0), sprite(1, 0))], + [Modifier.ConnectingTailLeft, sprite(0, 1)], + [Modifier.ConnectingTailRight, sprite(2, 1)], + [Modifier.ConnectingTailUp, horizontal(sprite(1, 0), sprite(2, 0))], + + [Modifier.TailDown, horizontal(sprite(2, 0), sprite(1, 0))], + [Modifier.TailLeft, sprite(0, 1)], + [Modifier.TailRight, sprite(2, 1)], + [Modifier.TailUp, horizontal(sprite(1, 0), sprite(2, 0))], + + // Horizontal river and trench bridge could have different variants. + [Modifier.HorizontalCrossing, sprite(0, 0)], + [Modifier.VerticalCrossing, sprite(1, 0)], + [Modifier.Variant2, sprite(0, 0)], + ]), + position: sprite(5, 0), + }, + { connectsWith: RailTrack, layer: 1 }, +); + +export const Trench = new TileInfo( + 19, + 'Trench', + `Dug-out ground providing high cover and twice the movement speed for foot soldiers. Cannot be accessed by light vehicles and tanks. Foot soldiers use half of a movement point when entering or leaving a Trench.`, + { + group: TileTypes.Trench, + type: TileTypes.Joinable | TileTypes.Area | TileTypes.AreaMatchesAll, + }, + { + cover: 40, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, 0.5], + [MovementTypes.HeavySoldier, 0.5], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + transitionCost: new Map([ + [MovementTypes.Soldier, 0.5], + [MovementTypes.HeavySoldier, 0.5], + ]), + vision: 2, + }, + { + modifiers: new Map([...AreaModifiers, [Modifier.Single, sprite(-1, -2)]]), + position: sprite(1, 16), + }, + { isolated: true }, +); + +export const Airfield = new TileInfo( + 20, + 'Airfield', + `A designated area providing some cover, suitable for constructing an Airbase to build air units.`, + TileTypes.Airfield, + { cover: 20, movement: PlainMovementCosts }, + sprite(4, 1), + { + decorator: new TileDecoratorInfo(sprite(8, 0), { + ...SeaAnimation, + offset: 1, + }), + isolated: true, + }, +); + +export const Lightning = new TileInfo( + 24, + 'Lightning Barrier', + `Unknown`, + TileTypes.Inaccessible, + { + cover: Number.POSITIVE_INFINITY, + movement: InaccessibleMovementCosts, + vision: -1, + }, + { + animation: { + frames: 4, + offset: 1, + ticks: 6, + }, + modifiers: new Map([ + [Modifier.Horizontal, sprite(0, 0)], + [Modifier.Vertical, sprite(0, 6)], + ]), + position: sprite(10, 0), + }, + { isolated: true, layer: 1 }, +); + +export const PoisonSwamp = new TileInfo( + 25, + 'Poison Swamp', + `Unknown`, + TileTypes.Sea, + { + cover: 0, + movement: Reef.configuration.movement, + }, + { + animation: HorizontalSeaAnimation, + position: sprite(5, 26), + }, + { fallback: Sea, hidden: true, isolated: true, layer: 1 }, +); + +export const Computer = new TileInfo( + 26, + 'Computer', + `Unknown`, + TileTypes.Inaccessible, + { + cover: 0, + movement: InaccessibleMovementCosts, + }, + { + animation: { ...SeaAnimation, offset: 1 }, + modifiers: new Map([[Modifier.Variant2, sprite(1, 0)]]), + position: sprite(0, 31), + }, + { isolated: true }, +); + +export const Box = new TileInfo( + 27, + 'Box', + `Unknown`, + TileTypes.Forest, + { + cover: 20, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, 2], + [MovementTypes.Soldier, 1], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, 2], + [MovementTypes.Tread, 2], + ]), + }, + { + modifiers: new Map([ + [Modifier.Variant2, sprite(0, 1)], + [Modifier.Variant3, sprite(1, 1)], + [Modifier.Variant4, sprite(2, 1)], + [Modifier.Variant5, sprite(3, 1)], + [Modifier.Variant6, sprite(4, 1)], + ]), + position: sprite(0, 17), + }, + { + hidden: true, + isolated: true, + }, +); + +export const Box2 = new TileInfo( + 22, + 'Box', + `Unknown`, + Box.group, + Box.configuration, + { + ...Box.sprite, + position: sprite(0, 21), + }, + Box.style, +); + +export const Platform = new TileInfo( + 28, + 'Platform', + `Unknown`, + TileTypes.Mountain, + { + cover: 30, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, 2], + [MovementTypes.HeavySoldier, 1], + [MovementTypes.LowAltitude, 1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + vision: 2, + }, + { + position: sprite(0, 6), + }, + { isolated: true }, +); + +export const Space = new TileInfo( + 29, + 'Space', + `Unknown`, + { group: TileTypes.Sea, type: TileTypes.Joinable | TileTypes.Area }, + { + cover: 10, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, -1], + [MovementTypes.HeavySoldier, -1], + [MovementTypes.LowAltitude, -1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + }, + { + modifiers: AreaModifiers, + position: Sea.sprite.position, + }, + { isolated: true }, +); + +export const Path = new TileInfo( + 30, + 'Path', + `Unknown`, + { + group: TileTypes.Street, + type: TileTypes.Joinable, + }, + { + cover: 0, + movement: PlainMovementCosts, + }, + { + modifiers: JoinableModifiers, + position: sprite(3, 3), + }, +); + +export const Wall = new TileInfo( + 31, + 'Wall', + `Unknown`, + TileTypes.Inaccessible, + { + cover: 0, + movement: InaccessibleMovementCosts, + }, + { + modifiers: new Map([ + [Modifier.Variant2, sprite(1, 0)], + [Modifier.Variant3, sprite(2, 0)], + [Modifier.Variant4, sprite(3, 0)], + [Modifier.Variant5, sprite(4, 0)], + [Modifier.Variant6, sprite(5, 0)], + [Modifier.Variant7, sprite(6, 0)], + ]), + position: sprite(0, 30), + }, + { isolated: true }, +); + +export const Window = new TileInfo( + 32, + 'Window', + `Unknown`, + TileTypes.Inaccessible, + { + cover: 0, + movement: InaccessibleMovementCosts, + }, + { + modifiers: new Map([ + [Modifier.Variant2, sprite(1, 0)], + [Modifier.Variant3, sprite(2, 0)], + ]), + position: sprite(7, 30), + }, + { isolated: true }, +); + +export const SpaceBridge = new TileInfo( + 33, + 'Bridge', + `Unknown`, + { + group: Bridge.group, + type: TileTypes.Bridge | TileTypes.Joinable | TileTypes.ConnectWithEdge, + }, + Bridge.configuration, + Bridge.sprite, + { connectsWith: Path, layer: 1 }, +); + +export const Pipe = new TileInfo( + 34, + 'Pipe', + `Unknown`, + { + group: TileTypes.Pipe, + type: TileTypes.Joinable | TileTypes.Inaccessible, + }, + { + cover: 0, + movement: InaccessibleMovementCosts, + }, + { + modifiers: new Map([ + [Modifier.Single, sprite(0, 0)], + [Modifier.Horizontal, sprite(-2, -2)], + [Modifier.Vertical, sprite(-1, -2)], + [Modifier.TopLeftCorner, sprite(-5, -2)], + [Modifier.BottomLeftCorner, sprite(-5, 0)], + [Modifier.TopRightCorner, sprite(-3, -2)], + [Modifier.BottomRightCorner, sprite(-3, 0)], + [Modifier.TailUp, sprite(-1, 0)], + [Modifier.TailDown, sprite(-1, -1)], + [Modifier.TailLeft, sprite(-2, 0)], + [Modifier.TailRight, sprite(-2, -1)], + ]), + position: sprite(5, 29), + }, +); + +export const Teleporter = new TileInfo( + 35, + 'Teleporter', + `Unknown`, + TileTypes.Teleporter, + Forest.configuration, + { + animation: { + frames: 2, + offset: 1, + ticks: 12, + }, + position: sprite(0, 25), + }, +); + +export const Island = new TileInfo( + 36, + 'Island', + `Unknown`, + TileTypes.Sea, + { + cover: 25, + movement: Reef.configuration.movement, + }, + { + animation: HorizontalSeaAnimation, + modifiers: new Map([ + [Modifier.Variant2, sprite(0, 2)], + [Modifier.Variant3, sprite(0, 4)], + ]), + position: sprite(5, 23), + }, + { + decorator: new TileDecoratorInfo(sprite(4, 0)), + fallback: Sea, + hidden: true, + isolated: true, + layer: 1, + }, +); + +export const Iceberg = new TileInfo( + 37, + 'Iceberg', + `Unknown`, + TileTypes.Sea, + { + cover: 30, + movement: new Map([ + [MovementTypes.Air, 1], + [MovementTypes.AirInfantry, 1], + [MovementTypes.Amphibious, -1], + [MovementTypes.Soldier, -1], + [MovementTypes.HeavySoldier, -1], + [MovementTypes.LowAltitude, -1], + [MovementTypes.Rail, -1], + [MovementTypes.Ship, -1], + [MovementTypes.Tires, -1], + [MovementTypes.Tread, -1], + ]), + }, + { + animation: HorizontalSeaAnimation, + modifiers: new Map([[Modifier.Variant2, sprite(0, 1)]]), + position: sprite(5, 22), + }, + { fallback: Sea, isolated: true, layer: 1 }, +); + +export const Weeds = new TileInfo( + 38, + 'Weeds', + `Unknown`, + TileTypes.Sea, + { + cover: 0, + movement: Reef.configuration.movement, + }, + { + animation: HorizontalSeaAnimation, + modifiers: new Map([ + [Modifier.Variant2, sprite(0, 1)], + [Modifier.Variant3, sprite(0, 2)], + [Modifier.Variant4, sprite(0, 3)], + ]), + position: sprite(5, 22), + }, + { fallback: Sea, hidden: true, isolated: true, layer: 1 }, +); + +export const FloatingEdge = new TileInfo( + 100_000, + 'Floating Edge', + `Unknown`, + TileTypes.Plain, + { + cover: 0, + movement: InaccessibleMovementCosts, + vision: -1, + }, + { + modifiers: new Map([ + ...AreaModifiers, + + [Modifier.RiverFlowsFromTop, sprite(1, 41)], + [Modifier.RiverFlowsFromBottom, sprite(1, 43)], + [Modifier.RiverFlowsFromLeft, sprite(0, 42)], + [Modifier.RiverFlowsFromRight, sprite(2, 42)], + + // Sea borders + [Modifier.BottomWallAreaDecorator, sprite(2, -20)], + [Modifier.LeftWallAreaDecorator, sprite(2, -16)], + [Modifier.RightWallAreaDecorator, sprite(1, -16)], + [Modifier.TopWallAreaDecorator, sprite(1, -20)], + + // Sea edges + [Modifier.BottomLeftAreaDecorator, sprite(2, -12)], + [Modifier.BottomRightAreaDecorator, sprite(1, -12)], + [Modifier.TopLeftAreaDecorator, sprite(2, -11)], + [Modifier.TopRightAreaDecorator, sprite(1, -11)], + ]), + position: sprite(8, 32), + }, +); + +export const FloatingWaterEdge = new TileInfo( + 100_001, + 'Floating Water Edge', + `Unknown`, + TileTypes.Plain, + FloatingEdge.configuration, + { + animation: { ...SeaAnimation, offset: 2 }, + modifiers: new Map([ + [Modifier.BottomLeftEdge, sprite(1, 0)], + [Modifier.BottomRightEdge, sprite(0, 0)], + [Modifier.TopLeftEdge, sprite(1, 1)], + [Modifier.TopRightEdge, sprite(0, 1)], + + [Modifier.BottomLeftAreaDecorator, sprite(3, 0)], + [Modifier.BottomRightAreaDecorator, sprite(2, 0)], + [Modifier.TopLeftAreaDecorator, sprite(3, 1)], + [Modifier.TopRightAreaDecorator, sprite(2, 1)], + ]), + position: sprite(7, 58), + }, +); + +export const getFloatingEdgeAnimation = (modifier: Modifier, biome: Biome) => { + if (biome === Biome.Spaceship) { + return null; + } + + if (WaterfallModifiers.has(modifier)) { + return WaterfallAnimation; + } + + if ( + modifier === Modifier.TopWallAreaDecorator || + modifier === Modifier.BottomWallAreaDecorator || + modifier === Modifier.LeftWallAreaDecorator || + modifier === Modifier.RightWallAreaDecorator + ) { + return { ...SeaAnimation, offset: 1 }; + } + + if ( + modifier === Modifier.TopLeftAreaDecorator || + modifier === Modifier.TopRightAreaDecorator || + modifier === Modifier.BottomLeftAreaDecorator || + modifier === Modifier.BottomRightAreaDecorator + ) { + return { ...SeaAnimation, offset: 2 }; + } + + return null; +}; + +export type MaybeTileID = number | null | false; + +export const isSea = (tile: MaybeTileID) => + !!(tile && isSeaTile(getTileInfo(tile))); + +export const isSeaTile = (tile: TileInfo) => !!(tile.type & TileTypes.Sea); + +// The order of tiles must not be changed. +const Tiles = [ + Plain, + Forest, + Mountain, + Street, + River, + Sea, + Ruins, + ConstructionSite, + Reef, + Beach, + Campsite, + Forest2, + StormCloud, + Pier, + ShipyardConstructionSite, + Bridge, + RailTrack, + RailBridge, + Trench, + Airfield, + DeepSea, + Box2, + Forest3, + Lightning, + PoisonSwamp, + Computer, + Box, + Platform, + Space, + Path, + Wall, + Window, + SpaceBridge, + Pipe, + Teleporter, + Island, + Iceberg, + Weeds, +]; + +const tiles = sortBy(Tiles.slice(), ({ group }) => group); + +export const PlainTileGroup = new Set( + tiles.filter( + ({ id }) => !isSea(id) && id !== StormCloud.id && id !== Lightning.id, + ), +); +export const SeaTileGroup = new Set(tiles.filter(({ id }) => isSea(id))); + +export const SwampBiome = new Set([Forest3, PoisonSwamp, Weeds]); +export const SpaceShipBiome = new Set([ + Box, + Box2, + Computer, + Path, + Platform, + Space, + Wall, + Window, + SpaceBridge, + Pipe, + Teleporter, +]); + +export const CrossOverTiles = new Map([ + [Street, Bridge], + [RailTrack, RailBridge], + [Trench, Bridge], + [Path, SpaceBridge], +]); + +export function getTile(id: TileField, layer?: TileLayer): number | null { + const isNumber = typeof id === 'number'; + if (layer != null) { + return isNumber ? (layer === 0 ? id : null) : id[layer]; + } + return isNumber ? id : id[1] || id[0]; +} + +export function getTileInfo(item: TileField, layer?: TileLayer): TileInfo { + const tile = getTile(item, layer); + const info = tile && Tiles[tile - 1]; + if (!info) { + throw new Error( + 'Tile `' + item + '` on layer `' + layer + '` does not exist.', + ); + } + return info; +} + +export function getAllTiles(): ReadonlyArray { + return tiles; +} + +export function findTile(fn: (tile: TileInfo) => unknown): TileInfo | null { + return Tiles.find(fn) || null; +} + +export function mapTiles(fn: (tile: TileInfo) => T): Array { + return tiles.map(fn); +} + +export function reduceTiles( + fn: (accumulator: T, tile: TileInfo) => T, + initial: T, +): T { + return tiles.reduce(fn, initial); +} + +export function tilesToTileMap(tiles: ReadonlyArray): TileMap { + return tiles.map(({ id, style: { fallback, layer }, type }) => { + while (fallback?.style.layer === 1) { + fallback = fallback.style.fallback; + } + if (!fallback && type & TileTypes.Bridge) { + fallback = River; + } + return layer === 1 ? [(fallback || Plain).id, id] : id; + }); +} diff --git a/athena/info/Unit.tsx b/athena/info/Unit.tsx new file mode 100644 index 00000000..fea6573c --- /dev/null +++ b/athena/info/Unit.tsx @@ -0,0 +1,3822 @@ +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import formatText from '../lib/formatText.tsx'; +import { AIBehavior } from '../map/AIBehavior.tsx'; +import { MaxHealth } from '../map/Configuration.tsx'; +import Entity, { EntityType } from '../map/Entity.tsx'; +import Player, { PlayerID } from '../map/Player.tsx'; +import SpriteVector from '../map/SpriteVector.tsx'; +import type Unit from '../map/Unit.tsx'; +import { ID } from '../MapData.tsx'; +import { AttackSprite } from './AttackSprite.tsx'; +import { MovementType, MovementTypes } from './MovementType.tsx'; +import { SoundName } from './Music.tsx'; +import { + getUnitCost, + getUnitRadius, + getUnitRange, + hasUnlockedUnit, + Skill, +} from './Skill.tsx'; +import { SpriteVariant } from './SpriteVariants.tsx'; +import type { TileInfo } from './Tile.tsx'; +import UnitID from './UnitID.tsx'; + +let _unitClass: typeof Unit; +const sprite = (x: number, y: number) => new SpriteVector(x, y); + +export enum Ability { + AccessBuildings, + Capture, + CreateBuildings, + CreateTracks, + Heal, + MoveAndAct, + Rescue, + Sabotage, + Supply, + Unfold, +} + +export const Abilities = [ + Ability.AccessBuildings, + Ability.Capture, + Ability.CreateBuildings, + Ability.CreateTracks, + Ability.Heal, + Ability.MoveAndAct, + Ability.Rescue, + Ability.Sabotage, + Ability.Supply, + Ability.Unfold, +] as const; + +export enum AttackType { + LongRange, + None, + ShortRange, +} + +export type UnitAnimationSprite = { + fade?: boolean; + frames: number; + offset?: { x?: number; y?: number }; + position: SpriteVector; +}; + +export type AttackStance = 'long' | 'short' | 'once' | false; +export type Gender = 'male' | 'female' | 'unknown'; + +type SpriteConfig = { + alternative?: SpriteVector; + alternativeExplosionSprite?: UnitAnimationSprite; + attackStance: AttackStance; + direction?: 'left' | 'right'; + directionOffset: 1 | 2 | 3; + explosionSprite?: UnitAnimationSprite; + healSprite?: UnitAnimationSprite; + invert: boolean; + leaderAlternative?: boolean; + name: SpriteVariant; + offset?: { x?: number; y?: number }; + portrait: { + position: SpriteVector; + variants: 3 | 6; + }; + position?: SpriteVector; + slow?: boolean; + transports?: SpriteVector; + transportsMany?: SpriteVector; + unfold?: SpriteVector; + unfoldSounds?: { + fold: SoundName; + unfold: SoundName; + }; + unfoldSprite?: { + frames: number; + position: SpriteVector; + }; + withNavalExplosion?: boolean; +}; + +class UnitAbilities { + private readonly accessBuildings: boolean; + private readonly capture: boolean; + private readonly createBuildings: boolean; + private readonly createTracks: boolean; + private readonly heal: boolean; + private readonly moveAndAct: boolean; + private readonly rescue: boolean; + private readonly sabotage: boolean; + private readonly supply: boolean; + private readonly unfold: boolean; + + constructor({ + accessBuildings, + capture, + createBuildings, + createTracks, + heal, + moveAndAct, + rescue, + sabotage, + supply, + unfold, + }: { + accessBuildings?: boolean; + capture?: boolean; + createBuildings?: boolean; + createTracks?: boolean; + heal?: boolean; + moveAndAct?: boolean; + rescue?: boolean; + sabotage?: boolean; + supply?: boolean; + unfold?: boolean; + } = {}) { + this.accessBuildings = accessBuildings ?? false; + this.capture = capture ?? false; + this.createBuildings = createBuildings ?? false; + this.createTracks = createTracks ?? false; + this.heal = heal ?? false; + this.moveAndAct = moveAndAct ?? false; + this.rescue = rescue ?? false; + this.sabotage = sabotage ?? false; + this.supply = supply ?? false; + this.unfold = unfold ?? false; + } + + has(ability: Ability) { + switch (ability) { + case Ability.AccessBuildings: + return this.accessBuildings; + case Ability.Capture: + return this.capture; + case Ability.CreateBuildings: + return this.createBuildings; + case Ability.CreateTracks: + return this.createTracks; + case Ability.Heal: + return this.heal; + case Ability.MoveAndAct: + return this.moveAndAct; + case Ability.Rescue: + return this.rescue; + case Ability.Sabotage: + return this.sabotage; + case Ability.Supply: + return this.supply; + case Ability.Unfold: + return this.unfold; + default: { + ability satisfies never; + throw new UnknownTypeError('UnitBehaviors.has', ability); + } + } + } +} + +type WeaponPositions = Readonly<{ + down?: SpriteVector; + horizontal: SpriteVector; + horizontalAlternative?: SpriteVector; + up?: SpriteVector; +}>; + +type WeaponAnimationConfiguaration = Readonly<{ + cell?: number; + frames: number; + leadingFrames?: number; + mirror?: boolean; + positions?: WeaponPositions; + recoil: boolean; + recoilDelay?: number; + repeat?: number; + rotate?: boolean; + size?: number; + trailingFrames?: number; + unfoldPositions?: WeaponPositions; +}>; + +export type AttackSpriteWithVariants = 'AttackOctopus'; + +export class WeaponAnimation { + public readonly cell: number; + public readonly frames: number; + public readonly leadingFrames: number; + public readonly mirror: boolean; + public readonly positions: WeaponPositions | undefined; + public readonly unfoldPositions: WeaponPositions | undefined; + public readonly recoil: boolean; + public readonly recoilDelay: number; + public readonly repeat: number; + public readonly rotate: boolean; + public readonly size: number; + public readonly trailingFrames: number; + + constructor( + public readonly sprite: AttackSprite | AttackSpriteWithVariants, + public readonly sound: SoundName | null, + { + cell, + frames, + leadingFrames, + mirror, + positions, + recoil, + recoilDelay, + repeat, + rotate, + size, + trailingFrames, + unfoldPositions, + }: WeaponAnimationConfiguaration, + ) { + this.cell = cell ?? 0; + this.frames = frames; + this.leadingFrames = leadingFrames || 0; + this.mirror = mirror ?? false; + this.positions = positions; + this.unfoldPositions = unfoldPositions; + this.recoil = recoil ?? true; + this.recoilDelay = recoilDelay ?? this.frames; + this.repeat = repeat ?? 1; + this.rotate = rotate ?? true; + this.size = size ?? 32; + this.trailingFrames = trailingFrames || 0; + } + + public getPosition( + style: 'unfold' | null, + direction: 'left' | 'right' | 'up' | 'down', + mirror = false, + ) { + const positions = + (style === 'unfold' && this.unfoldPositions) || this.positions; + if (positions) { + const offsetDirection = + direction === 'left' || direction === 'right' + ? 'horizontal' + : direction; + const alternative = + mirror && + offsetDirection === 'horizontal' && + positions.horizontalAlternative; + if (alternative) { + return direction === 'left' + ? { ...alternative, x: alternative.x * -1 } + : alternative; + } + return positions[offsetDirection]; + } + return null; + } + + public copy({ + cell, + frames, + mirror, + positions, + recoil, + repeat, + rotate, + size, + sound, + }: Omit & { + frames?: number; + recoil?: boolean; + sound?: SoundName; + }): WeaponAnimation { + return new WeaponAnimation(this.sprite, sound || this.sound, { + cell: cell ?? this.cell, + frames: frames ?? this.frames, + mirror: mirror ?? this.mirror, + positions: positions ?? this.positions, + recoil: recoil ?? this.recoil, + repeat: repeat ?? this.repeat, + rotate: rotate ?? this.rotate, + size: size ?? this.size, + }); + } + + withSound(sound: SoundName) { + return this.copy({ sound }); + } + + withTrailingFrames(trailingFrames: number) { + return this.copy({ trailingFrames }); + } + + public repeats(repeat: number) { + return this.copy({ repeat }); + } +} + +export type WeaponID = number; +export type Supply = number; +type DamageMap = ReadonlyMap; + +export class Weapon { + constructor( + private readonly internalName: string, + public readonly damage: DamageMap, + public readonly animation: WeaponAnimation, + public readonly hitAnimation?: + | WeaponAnimation + | [ + horizontal: WeaponAnimation, + up: WeaponAnimation, + down: WeaponAnimation, + ], + public readonly supply?: Supply | null, + public readonly id: WeaponID = 1, + ) {} + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } + + withId(id: WeaponID): Weapon { + return new Weapon( + this.internalName, + this.damage, + this.animation, + this.hitAnimation, + this.supply, + id, + ); + } + + withName(name: string) { + return new Weapon( + name, + this.damage, + this.animation, + this.hitAnimation, + this.supply, + this.id, + ); + } + + withDamage(damage: DamageMap): Weapon { + return new Weapon( + this.internalName, + damage, + this.animation, + this.hitAnimation, + this.supply, + this.id, + ); + } + + withSupply(supply: Supply): Weapon { + return new Weapon( + this.internalName, + this.damage, + this.animation, + this.hitAnimation, + supply, + this.id, + ); + } + + withAnimationPositions(positions: WeaponPositions): Weapon { + return new Weapon( + this.internalName, + this.damage, + this.animation.copy({ positions }), + this.hitAnimation, + this.supply, + this.id, + ); + } + + getDamage(entity: Entity) { + return this.damage.get(entity.info.type) || 0; + } +} + +type UnitConfiguration = { + cost: number; + fuel: number; + healTypes?: ReadonlySet; + radius: number; + sabotageTypes?: ReadonlySet; + supplyTypes?: ReadonlySet; + vision: number; +}; + +type UnitAttackConfiguration = Readonly<{ + primaryWeapon: Weapon | null; + type: AttackType; + weapons: Map | null; +}>; + +type UnitTransportConfiguration = Readonly<{ + limit: number; + tiles?: ReadonlySet | null; + types: ReadonlySet; +}>; + +export class UnitInfo { + public readonly attack: UnitAttackConfiguration; + public readonly configuration: Omit; + private readonly cost: number; + private readonly radius: number; + private readonly range: [number, number] | null; + public readonly sprite: Omit & { + direction: 1 | -1; + position: SpriteVector; + }; + private ammunitionSupply: ReadonlyMap | null | undefined = + undefined; + + constructor( + public readonly id: ID, + private readonly internalName: string, + private internalCharacterName: string | UnitInfo, + public readonly gender: Gender, + private readonly internalDescription: string, + private readonly internalCharacterDescription: string, + public readonly defense: number, + public readonly type: EntityType, + public readonly movementType: MovementType, + configuration: UnitConfiguration, + public readonly abilities: UnitAbilities, + attack: { + range?: [number, number]; + type: AttackType; + weapons?: ReadonlyArray; + } | null, + public readonly transports: UnitTransportConfiguration | null, + sprite: Omit< + SpriteConfig, + 'attackStance' | 'directionOffset' | 'invert' + > & { + attackStance?: 'long' | 'short' | 'once'; + directionOffset?: 1 | 2 | 3; + invert?: false; + }, + ) { + this.cost = configuration.cost; + this.radius = configuration.radius; + this.configuration = configuration; + this.sprite = { + invert: true, + ...sprite, + attackStance: sprite.attackStance || false, + direction: sprite.direction === 'right' ? -1 : 1, + directionOffset: sprite.directionOffset || 1, + position: sprite.position || new SpriteVector(0, 0), + }; + + this.range = attack?.range || null; + this.attack = { + type: AttackType.None, + ...attack, + primaryWeapon: attack?.weapons?.[0] || null, + weapons: attack?.weapons + ? new Map( + attack.weapons.map((weapon, index) => [ + index + 1, + weapon.withId(index + 1), + ]), + ) + : null, + }; + } + + get name() { + Object.defineProperty(this, 'name', { value: this.internalName }); + return this.internalName; + } + + get characterName(): string { + if (typeof this.internalCharacterName !== 'string') { + return this.internalCharacterName.characterName; + } + + Object.defineProperty(this, 'characterName', { + value: this.internalCharacterName, + writable: true, + }); + return this.internalCharacterName; + } + + getOriginalCharacterName(): string { + return typeof this.internalCharacterName === 'string' + ? this.internalCharacterName + : this.internalCharacterName.getOriginalCharacterName(); + } + + hasLinkedCharacterName() { + return typeof this.internalCharacterName !== 'string'; + } + + setCharacterName(name: string | (() => string)) { + if (!this.hasLinkedCharacterName()) { + if (typeof name === 'string') { + Object.defineProperty(this, 'characterName', { + value: name, + writable: true, + }); + } else { + Object.defineProperty(this, 'characterName', { + configurable: true, + get: name, + }); + } + } + } + + get description(): string { + Object.defineProperty(this, 'description', { + value: this.internalDescription, + writable: true, + }); + return this.internalDescription; + } + + get characterDescription(): string { + const description = formatText( + this.internalCharacterDescription, + this, + 'characterName', + ); + Object.defineProperty(this, 'characterDescription', { + value: description, + writable: true, + }); + return description; + } + + getOriginalCharacterDescription(): string { + return this.internalCharacterDescription; + } + + getCostFor(player: Player | null) { + if (!player?.skills.size) { + return this.cost; + } + + return getUnitCost(this, this.cost, player.skills, player.activeSkills); + } + + getRadiusFor(player: Player | null) { + if (!player?.skills.size) { + return this.radius; + } + + return getUnitRadius(this, this.radius, player.skills, player.activeSkills); + } + + getRangeFor(player: Player | null): [number, number] | null { + if (!this.isLongRange() || !this.range) { + return null; + } + + if (!player?.skills.size) { + return this.range; + } + + return getUnitRange(this, this.range, player.skills, player.activeSkills); + } + + canAct(player: Player) { + if (this.hasAbility(Ability.MoveAndAct)) { + return true; + } + + return ( + player.skills.size && + this === Battleship && + player.activeSkills.has(Skill.UnitBattleShipMoveAndAct) + ); + } + + hasAttack() { + return this.attack.type !== AttackType.None; + } + + isShortRange() { + return this.attack.type === AttackType.ShortRange; + } + + isLongRange() { + return this.attack.type === AttackType.LongRange; + } + + canAttackAt(distance: number, range: [number, number] | null) { + if (this.isShortRange() && distance === 1) { + return true; + } + + if (!this.isLongRange() || !range) { + return false; + } + + const [low, high] = range; + return !!(low >= 0 && high > 0 && distance >= low && distance <= high); + } + + canTransportUnits(): this is { + transports: UnitTransportConfiguration; + } { + return !!this.transports; + } + + canTransportUnitType(info: UnitInfo) { + return this.transports?.types.has(info.type); + } + + canTransport(info: UnitInfo, tile: TileInfo) { + return !!(this.transports?.types.has(info.type) && this.canDropFrom(tile)); + } + + canDropFrom(tile: TileInfo) { + return !!( + this.transports && + (!this.transports.tiles || this.transports.tiles.has(tile.id)) + ); + } + + canSabotageUnitType(info: UnitInfo) { + return this.configuration?.sabotageTypes?.has(info.type); + } + + hasAbility(ability: Ability) { + return this.abilities.has(ability); + } + + getAmmunitionSupply(): ReadonlyMap | null { + if (this.ammunitionSupply === undefined) { + const { weapons } = this.attack; + if (weapons) { + const actualWeapons = [...weapons] + .filter(([, { supply }]) => supply != null) + .map(([id, { supply }]) => [id, supply]) as ReadonlyArray< + [number, number] + >; + this.ammunitionSupply = new Map(actualWeapons); + } else { + this.ammunitionSupply = null; + } + } + return this.ammunitionSupply; + } + + create( + player: Player | PlayerID, + config?: { + behavior?: AIBehavior; + label?: PlayerID | null; + name?: number | null; + }, + ) { + return new _unitClass( + this.id, + MaxHealth, + typeof player === 'number' ? player : player.id, + this.configuration.fuel, + this.getAmmunitionSupply(), + null, + null, + null, + null, + null, + null, + config?.label != null ? config.label : null, + config?.name ?? null, + config?.behavior || null, + ); + } + + static setConstructor(unitClass: typeof Unit) { + _unitClass = unitClass; + } +} + +const buff = (map: DamageMap, change: number) => + new Map([...map].map(([type, damage]) => [type, damage + change])); + +const MGAnimation = new WeaponAnimation('MG', 'Attack/MG', { + frames: 8, + positions: { + down: sprite(0.05, 0.55), + horizontal: sprite(-0.5, 0.25), + up: sprite(-0.03, -0.3), + }, + recoil: false, + repeat: 2, +}); + +const AntiAirAnimation = new WeaponAnimation('AntiAir', 'Attack/AntiAirGun', { + frames: 8, + positions: { + down: sprite(0, 0.35), + horizontal: sprite(-0.35, -0.13), + up: sprite(0, -0.45), + }, + recoil: false, + size: 24, +}); + +const ArtilleryAnimation = new WeaponAnimation( + 'Artillery', + 'Attack/Artillery', + { + frames: 9, + positions: { + down: sprite(-0.2, 0.3), + horizontal: sprite(-0.4, -0.19), + up: sprite(0.25, -0.45), + }, + recoil: true, + }, +); + +const HeavyArtilleryAnimation = new WeaponAnimation( + 'HeavyArtillery', + 'Attack/HeavyArtillery', + { + frames: 12, + positions: { + down: sprite(-0.2, 0.3), + horizontal: sprite(-0.5, -0.3), + up: sprite(0.25, -0.65), + }, + recoil: true, + }, +); + +const SparkAnimation = new WeaponAnimation('Spark', null, { + frames: 11, + positions: { + horizontal: sprite(0.1, 0.2), + }, + recoil: false, +}); + +const EmptyAnimation = new WeaponAnimation('Empty', null, { + frames: 16, + recoil: false, +}); + +const OctopusBiteHitAnimationPositions = { + horizontal: sprite(0.086, 0), +}; + +const OctopusBiteHitAnimation = new WeaponAnimation( + 'AttackOctopus', + 'Attack/TentacleWhip', + { + frames: 16, + positions: OctopusBiteHitAnimationPositions, + recoil: false, + }, +); + +const BiteAnimation = new WeaponAnimation('Empty', 'Attack/Bite', { + frames: 15, + recoil: false, +}); + +const BiteHitAnimation = new WeaponAnimation('Bite', null, { + frames: 9, + leadingFrames: 6, + positions: { + horizontal: sprite(-0.15, 0.5), + }, + recoil: false, + rotate: false, + size: 64, +}); + +const PowHitAnimation = new WeaponAnimation('Pow', 'Attack/Pow', { + frames: 7, + leadingFrames: 8, + positions: { + horizontal: sprite(0, 0.5), + }, + recoil: false, + rotate: false, + size: 64, +}); + +const SparkMiniAnimation = new WeaponAnimation('SparkMini', null, { + frames: 9, + positions: { + horizontal: sprite(0, 0.1), + }, + recoil: false, + repeat: 2, +}); + +const SmokeAnimation = new WeaponAnimation('Smoke', null, { + frames: 9, + leadingFrames: 4, + recoil: false, + size: 40, +}); + +const ExplosionImpactAnimation = new WeaponAnimation( + 'ExplosionImpact', + 'ExplosionImpact', + { + frames: 10, + leadingFrames: 10, + recoil: false, + size: 48, + }, +); + +const LightGunAnimation = new WeaponAnimation('LightGun', 'Attack/LightGun', { + frames: 13, + positions: { + down: sprite(-0.3, 0.95), + horizontal: sprite(-0.9, 0.13), + up: sprite(0.13, -0.55), + }, + recoil: true, + size: 36, +}); +const SAMAnimation = new WeaponAnimation('SAM', 'Attack/SAM', { + frames: 12, + positions: { + horizontal: sprite(0.08, -0.15), + }, + recoil: false, + rotate: false, + trailingFrames: 10, +}); + +const LightGun = new Weapon( + 'Light Gun', + new Map([ + [EntityType.AirInfantry, 55], + [EntityType.Amphibious, 90], + [EntityType.Artillery, 90], + [EntityType.Building, 70], + [EntityType.Ground, 90], + [EntityType.LowAltitude, 55], + [EntityType.Infantry, 55], + [EntityType.Rail, 75], + [EntityType.Ship, 30], + [EntityType.Structure, 90], + ]), + LightGunAnimation, + SmokeAnimation, +); + +const AntiShipMissile = new Weapon( + 'Anti Ship Missile', + new Map([ + [EntityType.Amphibious, 100], + [EntityType.Artillery, 60], + [EntityType.Ground, 60], + [EntityType.Infantry, 50], + [EntityType.Rail, 50], + [EntityType.Ship, 75], + ]), + LightGunAnimation, + ExplosionImpactAnimation, +).withAnimationPositions({ + down: sprite(-0.2, 0.95), + horizontal: sprite(-0.75, 0.26), + up: sprite(0.23, -0.55), +}); + +const CruiseMissile = new Weapon( + 'Cruise Missile', + new Map([ + [EntityType.Amphibious, 80], + [EntityType.Artillery, 90], + [EntityType.Ground, 90], + [EntityType.Infantry, 80], + [EntityType.Rail, 95], + [EntityType.Ship, 40], + ]), + SAMAnimation.withTrailingFrames(6), + SmokeAnimation, +); + +const HeavyGun = new Weapon( + 'Heavy Gun', + new Map([ + [EntityType.AirInfantry, 55], + [EntityType.Amphibious, 115], + [EntityType.Artillery, 115], + [EntityType.Building, 70], + [EntityType.Ground, 115], + [EntityType.LowAltitude, 55], + [EntityType.Infantry, 65], + [EntityType.Rail, 95], + [EntityType.Ship, 50], + [EntityType.Structure, 110], + ]), + new WeaponAnimation('LightGun', 'Attack/HeavyGun', { + frames: 13, + positions: { + down: sprite(-0.13, 0.95), + horizontal: sprite(-0.9, 0.13), + up: sprite(0.25, -0.55), + }, + recoil: true, + recoilDelay: 6, + size: 36, + trailingFrames: 7, + }), + ExplosionImpactAnimation, +); + +const MG = new Weapon( + 'MG', + new Map([ + [EntityType.AirInfantry, 65], + [EntityType.Amphibious, 35], + [EntityType.Artillery, 35], + [EntityType.Ground, 35], + [EntityType.Infantry, 80], + ]), + MGAnimation, + SparkMiniAnimation, +); + +const MiniGun = new Weapon( + 'Mini Gun', + new Map([ + [EntityType.AirInfantry, 80], + [EntityType.Amphibious, 70], + [EntityType.Artillery, 90], + [EntityType.Building, 70], + [EntityType.Ground, 70], + [EntityType.LowAltitude, 80], + [EntityType.Infantry, 100], + [EntityType.Ship, 50], + [EntityType.Structure, 50], + [EntityType.Rail, 50], + ]), + new WeaponAnimation('Minigun', 'Attack/MiniGun', { + frames: 11, + mirror: true, + positions: { + down: sprite(0.28, 0.67), + horizontal: sprite(-0.27, 0.35), + horizontalAlternative: sprite(0.56, 0.3), + up: sprite(0.25, -0.3), + }, + recoil: false, + }), + SparkAnimation, +); + +const AIUMiniGun = new Weapon( + 'Super Mini Gun', + buff(MiniGun.damage, 40), + new WeaponAnimation('Minigun', 'Attack/MiniGun', { + frames: 11, + positions: { + down: sprite(0, 0.68), + horizontal: sprite(-0.55, 0.1), + up: sprite(0, -0.35), + }, + recoil: false, + repeat: 2, + }), + SparkAnimation, +); + +const ArtilleryWeapon = new Weapon( + 'Artillery', + new Map([ + [EntityType.AirInfantry, 80], + [EntityType.Amphibious, 100], + [EntityType.Artillery, 90], + [EntityType.Ground, 100], + [EntityType.LowAltitude, 80], + [EntityType.Infantry, 100], + [EntityType.Ship, 60], + [EntityType.Rail, 90], + ]), + ArtilleryAnimation, + SmokeAnimation, +); + +const AirToAirMissile = new Weapon( + 'Air to Air Missile', + new Map([ + [EntityType.Airplane, 115], + [EntityType.AirInfantry, 130], + [EntityType.LowAltitude, 130], + ]), + MGAnimation.withSound('Attack/AirToAirMissile'), + SparkMiniAnimation, +); + +const LightAirGun = new Weapon( + 'Light Air Gun', + new Map([ + [EntityType.AirInfantry, 80], + [EntityType.Amphibious, 65], + [EntityType.Artillery, 85], + [EntityType.Ground, 85], + [EntityType.LowAltitude, 80], + [EntityType.Infantry, 75], + [EntityType.Rail, 75], + [EntityType.Ship, 45], + ]), + MGAnimation, + SparkMiniAnimation, +); + +const Bomb = new Weapon( + 'Bomb', + new Map([ + [EntityType.Amphibious, 115], + [EntityType.Artillery, 100], + [EntityType.Building, 70], + [EntityType.Ground, 110], + [EntityType.Infantry, 105], + [EntityType.Ship, 115], + [EntityType.Structure, 80], + [EntityType.Rail, 110], + ]), + new WeaponAnimation('Bomb', 'Attack/Bomb', { + frames: 9, + positions: { + down: sprite(0.3, 0.4), + horizontal: sprite(-0.3, 0.2), + up: sprite(-0.3, -0.4), + }, + recoil: false, + size: 24, + }), + SmokeAnimation, +); + +const SoldierMG = MG.withDamage( + new Map([ + [EntityType.AirInfantry, 65], + [EntityType.Amphibious, 35], + [EntityType.Artillery, 35], + [EntityType.Ground, 35], + [EntityType.Infantry, 60], + [EntityType.LowAltitude, 65], + ]), +).withAnimationPositions({ + ...MGAnimation.positions, + horizontal: sprite(-0.5, 0.195), +}); + +const RocketLauncherWeapon = new Weapon( + 'Rocket Launcher', + new Map([ + [EntityType.AirInfantry, 55], + [EntityType.Amphibious, 100], + [EntityType.Artillery, 100], + [EntityType.Ground, 100], + [EntityType.LowAltitude, 80], + [EntityType.Infantry, 35], + [EntityType.Rail, 70], + ]), + new WeaponAnimation('RocketLauncher', 'Attack/RocketLauncher', { + frames: 11, + positions: { + down: sprite(-0.08, 0.9), + horizontal: sprite(-0.97, 0.1), + up: sprite(0.2, -0.56), + }, + recoil: false, + }), +); + +export const Weapons = { + AIUMiniGun, + AirToAirMissile, + AmphibiousLightGun: new Weapon( + 'Light Gun', + new Map([ + ...LightGun.damage, + + [EntityType.Amphibious, 75], + [EntityType.Ship, 55], + ]), + new WeaponAnimation('Amphibious', 'Attack/LightGun', { + frames: 15, + positions: { + down: sprite(-0.08, 0.4), + horizontal: sprite(-0.36, -0.07), + up: sprite(0.1, -0.3), + }, + recoil: true, + }), + SmokeAnimation, + ), + AntiAirGun: new Weapon( + 'Anti Air Gun', + new Map([ + [EntityType.Airplane, 135], + [EntityType.AirInfantry, 145], + [EntityType.Amphibious, 65], + [EntityType.Artillery, 65], + [EntityType.Building, 70], + [EntityType.Ground, 55], + [EntityType.LowAltitude, 145], + [EntityType.Infantry, 50], + [EntityType.Structure, 20], + [EntityType.Rail, 60], + ]), + AntiAirAnimation.repeats(2), + SparkMiniAnimation, + ), + AntiArtilleryGun: new Weapon( + 'Anti Artillery Gun', + new Map([[EntityType.Artillery, 100]]), + MGAnimation, + ), + Artillery: ArtilleryWeapon, + Battery: new Weapon( + 'Artillery Battery', + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 130], + [EntityType.Artillery, 130], + [EntityType.Ground, 130], + [EntityType.LowAltitude, 100], + [EntityType.Infantry, 110], + [EntityType.Rail, 90], + [EntityType.Ship, 80], + ]), + ArtilleryAnimation.withSound('Attack/ArtilleryBattery'), + ), + Bazooka: RocketLauncherWeapon.withName('Bazooka') + .withDamage( + new Map([ + ...RocketLauncherWeapon.damage, + + [EntityType.AirInfantry, 110], + [EntityType.Airplane, 100], + [EntityType.Amphibious, 110], + [EntityType.Artillery, 110], + [EntityType.Building, 70], + [EntityType.Ground, 110], + [EntityType.LowAltitude, 110], + [EntityType.Infantry, 110], + [EntityType.Rail, 80], + [EntityType.Ship, 80], + [EntityType.Structure, 80], + ]), + ) + .withAnimationPositions({ + down: sprite(-0.25, 0.9), + horizontal: sprite(-0.97, 0.1), + up: sprite(0.28, -0.8), + }), + Bite: new Weapon( + 'Bite', + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 100], + [EntityType.Artillery, 100], + [EntityType.Ground, 100], + [EntityType.Infantry, 120], + ]), + BiteAnimation, + BiteHitAnimation, + ), + Bomb, + Cannon: new Weapon( + 'Cannon', + new Map(buff(ArtilleryWeapon.damage, 10)), + new WeaponAnimation('Cannon', 'Attack/Cannon', { + frames: 14, + positions: { + down: sprite(-0.3, 2), + horizontal: sprite(-0.97, 0.75), + up: sprite(0.2, 0.25), + }, + recoil: false, + size: 80, + trailingFrames: 6, + }), + ExplosionImpactAnimation, + ), + Club: new Weapon( + 'Club', + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 100], + [EntityType.Artillery, 100], + [EntityType.Ground, 100], + [EntityType.Infantry, 120], + ]), + EmptyAnimation.withSound('Attack/Club'), + PowHitAnimation, + ), + CruiseMissile, + DroneBomb: Bomb.withName('Drone Bomb').withDamage( + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 50], + [EntityType.Artillery, 50], + [EntityType.Building, 70], + [EntityType.Ground, 50], + [EntityType.Infantry, 115], + [EntityType.Structure, 50], + [EntityType.Rail, 50], + ]), + ), + Flamethrower: new Weapon( + 'Flamethrower', + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 70], + [EntityType.Artillery, 75], + [EntityType.Ground, 70], + [EntityType.Infantry, 130], + [EntityType.Structure, 80], + ]), + new WeaponAnimation('Flamethrower', 'Attack/Flamethrower', { + frames: 24, + positions: { + down: sprite(-0.35, 1.1), + horizontal: sprite(-1.1, -0.1), + up: sprite(0.45, -0.9), + }, + recoil: false, + }), + new WeaponAnimation('Flamethrower', null, { + cell: 1, + frames: 24, + positions: { + horizontal: sprite(0.1, 0), + }, + recoil: false, + }), + ), + HeavyArtillery: new Weapon( + 'Heavy Artillery', + new Map([ + [EntityType.AirInfantry, 120], + [EntityType.Amphibious, 120], + [EntityType.Artillery, 80], + [EntityType.Ground, 120], + [EntityType.Infantry, 120], + [EntityType.Ship, 80], + [EntityType.Rail, 80], + ]), + HeavyArtilleryAnimation, + SmokeAnimation, + ), + HeavyGun, + LightAirGun, + LightAirToAirMissile: AirToAirMissile.withName('Light Air To Air Missile') + .withDamage( + new Map([ + [EntityType.Airplane, 80], + [EntityType.AirInfantry, 90], + [EntityType.LowAltitude, 90], + ]), + ) + .withAnimationPositions({ + down: sprite(0, 0.55), + horizontal: sprite(-0.35, 0.2), + up: sprite(-0.03, -0.15), + }), + LightGun, + MG, + MiniGun, + Pistol: new Weapon( + 'Pistol', + buff(SoldierMG.damage, 20), + new WeaponAnimation('Pistol', 'Attack/Pistol', { + frames: 10, + positions: { + down: sprite(-0.1, 0.85), + horizontal: sprite(-0.95, 0), + up: sprite(0.22, -0.85), + }, + recoil: true, + }), + SparkMiniAnimation, + ), + Punch: new Weapon( + 'Fist', + new Map([ + [EntityType.AirInfantry, 50], + [EntityType.Amphibious, 25], + [EntityType.Artillery, 25], + [EntityType.Ground, 25], + [EntityType.Infantry, 50], + ]), + // UI/LongPress works great here, lol. + new WeaponAnimation('Punch', 'UI/LongPress', { + frames: 11, + positions: { + down: sprite(-0.1, 1), + horizontal: sprite(-1, 0.2), + up: sprite(0.1, -0.5), + }, + recoil: true, + size: 42, + trailingFrames: 4, + }), + PowHitAnimation, + ), + Railgun: new Weapon( + 'Railgun', + new Map([ + [EntityType.AirInfantry, 90], + [EntityType.Amphibious, 130], + [EntityType.Artillery, 90], + [EntityType.Ground, 130], + [EntityType.LowAltitude, 90], + [EntityType.Infantry, 130], + [EntityType.Rail, 90], + [EntityType.Ship, 90], + ]), + new WeaponAnimation('Railgun', 'Attack/Railgun', { + frames: 23, + positions: { + down: sprite(-0.15, 1.1), + horizontal: sprite(-0.8, -0.1), + up: sprite(0.15, -0.21), + }, + recoil: true, + recoilDelay: 17, + size: 48, + trailingFrames: 16, + }), + new WeaponAnimation('RailgunImpact', 'Attack/RailgunImpact', { + frames: 16, + leadingFrames: 23, + positions: { + horizontal: sprite(0, 0.35), + }, + recoil: false, + size: 64, + }), + ), + Rocket: new Weapon( + 'Rocket', + new Map([ + [EntityType.AirInfantry, 60], + [EntityType.Airplane, 60], + [EntityType.Amphibious, 90], + [EntityType.Artillery, 70], + [EntityType.Ground, 80], + [EntityType.LowAltitude, 60], + [EntityType.Infantry, 80], + [EntityType.Rail, 60], + [EntityType.Ship, 60], + ]), + new WeaponAnimation('Rocket', 'Attack/Rocket', { + frames: 16, + positions: { + down: sprite(-0.5, 1.5), + horizontal: sprite(-0.85, 0.17), + up: sprite(0.8, -0.5), + }, + recoil: true, + size: 64, + trailingFrames: 8, + }), + new WeaponAnimation('ExplosionImpact', 'ExplosionImpact', { + frames: 10, + leadingFrames: 14, + recoil: false, + size: 48, + }), + ), + RocketLauncher: RocketLauncherWeapon, + SAM: new Weapon( + 'SAM', + new Map([ + [EntityType.Airplane, 140], + [EntityType.AirInfantry, 150], + [EntityType.LowAltitude, 150], + ]), + SAMAnimation, + new WeaponAnimation('SAMImpact', 'Attack/SAMImpact', { + frames: 12, + leadingFrames: 10, + positions: { + horizontal: sprite(0.1, 0), + }, + recoil: false, + size: 42, + }), + ), + SeaMG: MG.withDamage( + new Map([ + [EntityType.AirInfantry, 80], + [EntityType.Amphibious, 35], + [EntityType.Artillery, 35], + [EntityType.Ground, 35], + [EntityType.LowAltitude, 80], + [EntityType.Infantry, 80], + [EntityType.Ship, 35], + ]), + ).withAnimationPositions({ + down: sprite(0.05, 0.55), + horizontal: sprite(-0.3, 0.35), + up: sprite(0.03, -0.2), + }), + Shotgun: new Weapon( + 'Shotgun', + buff(SoldierMG.damage, 20), + new WeaponAnimation('Shotgun', 'Attack/Shotgun', { + frames: 17, + positions: { + down: sprite(0.1, 1.2), + horizontal: sprite(-1, 0.115), + up: sprite(0.1, -1), + }, + recoil: false, + }), + ), + SniperRifle: new Weapon( + 'Sniper Rifle', + new Map([ + [EntityType.AirInfantry, 120], + [EntityType.Amphibious, 70], + [EntityType.Artillery, 100], + [EntityType.Ground, 70], + [EntityType.LowAltitude, 120], + [EntityType.Infantry, 120], + [EntityType.Rail, 50], + ]), + new WeaponAnimation('SniperRifle', 'Attack/SniperRifle', { + frames: 14, + positions: { + down: sprite(0.05, 0.9), + horizontal: sprite(-0.9, 0.23), + up: sprite(-0.04, -0.55), + }, + recoil: true, + unfoldPositions: { + down: sprite(0.05, 0.9), + horizontal: sprite(-0.9, 0.45), + up: sprite(-0.05, -0.75), + }, + }), + ), + SoldierMG, + SuperMiniGun: MiniGun.withName('Super Mini Gun').withDamage( + buff(MiniGun.damage, 40), + ), + TentacleWhip: new Weapon( + 'Tentacle Whip', + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 100], + [EntityType.Artillery, 100], + [EntityType.Ground, 100], + [EntityType.Infantry, 120], + [EntityType.Ship, 120], + [EntityType.Rail, 100], + ]), + EmptyAnimation.withSound('Attack/TentacleWhip'), + [ + OctopusBiteHitAnimation, + OctopusBiteHitAnimation.copy({ + cell: 1, + positions: { + horizontal: sprite( + 0.08, + OctopusBiteHitAnimationPositions.horizontal.x, + ), + }, + rotate: false, + }), + OctopusBiteHitAnimation.copy({ + cell: 2, + positions: { + horizontal: sprite( + -0.044, + -OctopusBiteHitAnimationPositions.horizontal.x, + ), + }, + rotate: false, + }), + ], + ), + Torpedo: new Weapon( + 'Torpedo', + new Map([ + [EntityType.Amphibious, 90], + [EntityType.Ship, 65], + ]), + new WeaponAnimation('Torpedo', 'Attack/Torpedo', { + frames: 9, + positions: { + down: sprite(-0.3, 0), + horizontal: sprite(0, 0.2), + up: sprite(0.3, 0), + }, + recoil: false, + trailingFrames: 14, + }), + new WeaponAnimation('Torpedo', 'Attack/TorpedoImpact', { + cell: 1, + frames: 14, + leadingFrames: 9, + positions: { + down: sprite(-0.15, -0.05), + horizontal: sprite(0.1, 0.2), + up: sprite(0.15, -0.45), + }, + recoil: false, + rotate: false, + }), + ), +}; + +export const CaptureWeapon = new Weapon( + 'Capture', + new Map(), + MGAnimation.withSound('Unit/Load').repeats(1), +); + +export const CapturedWeapon = new Weapon( + 'Capture', + new Map(), + EmptyAnimation.withSound('Unit/Capture').repeats(1), +); + +const DefaultUnitAbilities = new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, +}); +const PioneerUnitAbilities = new UnitAbilities({ + accessBuildings: true, + capture: true, + createBuildings: true, + createTracks: true, + moveAndAct: true, + rescue: true, +}); +const DefaultUnitAbilitiesWithCapture = new UnitAbilities({ + accessBuildings: true, + capture: true, + moveAndAct: true, +}); +const HealUnitAbilities = new UnitAbilities({ + accessBuildings: true, + heal: true, + moveAndAct: true, +}); +const SaboteurUnitAbilities = new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + rescue: true, + sabotage: true, +}); +const DefaultSabotageTypes = new Set([ + EntityType.AirInfantry, + EntityType.Amphibious, + EntityType.Artillery, + EntityType.Building, + EntityType.Ground, + EntityType.Infantry, + EntityType.Invincible, + EntityType.LowAltitude, + EntityType.Rail, + EntityType.Ship, + EntityType.Structure, +]); + +export const Pioneer = new UnitInfo( + UnitID.Pioneer, + 'Pioneer', + 'Sam', + 'male', + `Pioneers are an essential utility unit capable of building and capturing structures as well as laying rail tracks. They lack an attack and are therefore vulnerable on the battlefield.`, + `Joining the defense forces to become a pioneer from the countryside, {name} went through training with others in his unit as a junior recruit. Since then, he's been using his determination and growing courage to battle the enemy, often surprising himself with what he can accomplish and build on the battlefield.`, + 0, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 100, + fuel: 40, + radius: 3, + vision: 2, + }, + PioneerUnitAbilities, + null, + null, + { + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + name: 'Units-Pioneer', + portrait: { + position: sprite(4, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Infantry = new UnitInfo( + UnitID.Infantry, + 'Infantry', + 'Valentin', + 'male', + `Infantry units are standard foot soldiers, capable of capturing buildings. With minimal defense and a standard movement range, it is a balanced yet cheap unit. While Infantry is effective against all other foot soldiers, their abilities to defend against stronger units are limited.`, + `Always wanting to be a soldier, {name} enjoys showing off his skills as Infantry on the battlefield. Coming up through the ranks of the defense forces together with Pioneers, {name} sees his duty to always press forward especially when the odds are against him. Together with his friends, he’s eager to leap into the next conflict with a battlecry.`, + 5, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 200, + fuel: 50, + radius: 3, + vision: 2, + }, + DefaultUnitAbilitiesWithCapture, + { type: AttackType.ShortRange, weapons: [Weapons.SoldierMG] }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + name: 'Units-Infantry', + portrait: { + position: sprite(3, 0), + variants: 3, + }, + slow: true, + }, +); + +export const RocketLauncher = new UnitInfo( + 3, + 'Rocket Launcher', + 'Davide', + 'male', + `The Rocket Launcher excels in short-range combat with strong firepower against tanks and other ground units. It is limited by its slow movement and high cost, but is invaluable when defending against a barrage of tanks in your territory.`, + `Tanks? Not a problem. Hailing from the chilly north, {name} leads the Rocket Launcher division of the defense force.`, + 20, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 275, + fuel: 40, + radius: 3, + vision: 2, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [Weapons.RocketLauncher.withSupply(4)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-RocketLauncher', + portrait: { + position: sprite(2, 0), + variants: 3, + }, + slow: true, + }, +); + +export const APU = new UnitInfo( + 4, + 'APU', + 'Nora', + 'female', + `The APU, or Armored Personnel Unit, is a special ground unit with effective firepower against infantry. Capable of traversing challenging terrains, its versatility in both offense and defense is amplified by its relatively low cost.`, + `Ever since the group discovered plans for the APU, {name} has been working on the design and construction of the first prototype. She is the bright light between all the chaos of battle and she always takes time to positively affirm everyone and simply ignores negativity. On the battlefield, {name} is having way too much fun operating the APU.`, + 25, + EntityType.Ground, + MovementTypes.HeavySoldier, + { + cost: 300, + fuel: 40, + radius: 4, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.MiniGun.withSupply(6)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-APU', + portrait: { + position: sprite(7, 0), + variants: 3, + }, + }, +); + +export const SmallTank = new UnitInfo( + 5, + 'Small Tank', + 'Chiara', + 'female', + `Built for combat, the Small Tank is moderately expensive but offers good firepower and robust defense against all other ground units. It can move far but has limited visibility in fog and is vulnerable to attacks from most soldiers.`, + `{name} is a tank commander who loves to be in the thick of the action. While her tank squad is always the first to engage an opponent and {name} can come across as reckless, she is learning battlefield tactics from her uncle, {8.name}.`, + 40, + EntityType.Ground, + MovementTypes.Tread, + { + cost: 375, + fuel: 30, + radius: 7, + vision: 2, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.LightGun.withSupply(7)], + }, + null, + { + direction: 'left', + invert: false, + name: 'Units-SmallTank', + portrait: { + position: sprite(10, 0), + variants: 3, + }, + }, +); + +const GroundSupplyTypes = new Set([ + EntityType.AirInfantry, + EntityType.Amphibious, + EntityType.Artillery, + EntityType.Ground, + EntityType.Infantry, + EntityType.Rail, + EntityType.Ship, +]); + +export const Jeep = new UnitInfo( + 6, + 'Jeep', + 'Remy', + 'male', + `This unit is designed for fast mobility and has the ability to resupply other units. Its defense is low, making it vulnerable to enemy attacks. It can't engage in combat but is essential for logistics and exploration and can transport foot soldiers to the battlefield.`, + `{name} is a cheerful driver who delights in pushing the pedal to the metal, despite his helmet being too big for him to really see ahead! He loves shuttling infantry units around and never hesitates to go as fast as he can.`, + 10, + EntityType.Ground, + MovementTypes.Tires, + { + cost: 150, + fuel: 60, + radius: 7, + supplyTypes: GroundSupplyTypes, + vision: 2, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + supply: true, + }), + null, + { + limit: 2, + types: new Set([EntityType.Infantry]), + }, + { + direction: 'left', + name: 'Units-Jeep', + portrait: { + position: sprite(15, 0), + variants: 3, + }, + transports: sprite(0, 3), + transportsMany: sprite(0, 6), + }, +); + +export const Artillery = new UnitInfo( + UnitID.Artillery, + 'Artillery', + 'Arthur', + 'male', + `Artilleries are cost-effective long-range units that have to be positioned in order to attack. Though immobilized while in position, their strikes are particularly lethal against infantry and light ground forces.`, + `Originally a lawyer, {name} was known for treating his subordinates in a rather… militant manner. He decided to join the defense forces after the passing of his dog. As commander of the artillery squad, {name} is feared by opponents and allies alike.`, + 10, + EntityType.Artillery, + MovementTypes.Tires, + { + cost: 250, + fuel: 40, + radius: 5, + vision: 1, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + unfold: true, + }), + { + range: [2, 4], + type: AttackType.LongRange, + weapons: [Weapons.Artillery.withSupply(7)], + }, + null, + { + name: 'Units-MobileArtillery', + portrait: { + position: sprite(13, 0), + variants: 3, + }, + unfold: sprite(0, 4), + unfoldSounds: { + fold: 'Unit/ArtilleryFold', + unfold: 'Unit/ArtilleryUnfold', + }, + unfoldSprite: { frames: 5, position: sprite(0, 3) }, + }, +); + +export const Battleship = new UnitInfo( + 8, + 'Battleship', + 'Admiral One', + 'male', + `Battleships are the queens of the sea, known for their highly effective long-range attacks. They are among the most expensive units, but when sighted by an opponent, a Battleship often brings disarray to their fleet and requires a strategic adjustment.`, + `{name} is a seasoned admiral and master of naval combat. While he is known for leading his fleets to victory at high sea, he has also been a caring family man. He single-handedly raised his niece {5.name} after her parents passed away.`, + 30, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 1000, + fuel: 40, + radius: 5, + vision: 2, + }, + new UnitAbilities(), + { + range: [3, 6], + type: AttackType.LongRange, + weapons: [Weapons.Battery.withSupply(4)], + }, + null, + { + direction: 'left', + invert: false, + name: 'Units-BattleShip', + portrait: { + position: sprite(37, 0), + variants: 3, + }, + }, +); + +export const Helicopter = new UnitInfo( + 9, + 'Helicopter', + 'Jace', + 'male', + `Helicopter units are essential for low-altitude air control. They can keep soldiers and other ground units at bay while building up a strong defense. Their high mobility and vision range make them particularly useful for scouting in fog.`, + `{name} is usually found skiing in the mountains when he isn't chasing tanks and soldiers through daring maneuvers in the skies. He loves rhymes and always comes up with subtle jokes, similar to his cat and mouse flying style.`, + 40, + EntityType.LowAltitude, + MovementTypes.LowAltitude, + { + cost: 300, + fuel: 40, + radius: 6, + vision: 5, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + LightAirGun.withSupply(8).withAnimationPositions({ + ...LightAirGun.animation.positions, + horizontal: sprite(-0.4, 0.4), + }), + ], + }, + null, + { + direction: 'right', + name: 'Units-Helicopter', + portrait: { + position: sprite(12, 0), + variants: 3, + }, + }, +); + +export const Humvee = new UnitInfo( + 10, + 'Humvee', + 'Andrey', + 'male', + `With their high visibility and large movement range, Humvees are particularly useful for scouting and taking out opposing artillery units. Their high mobility, coupled with their low defense, makes them vulnerable when they cross into enemy territory.`, + `{name} thrives amidst chaos, always alert to the possibility that everything can blow up at any moment. While his intimidating beard suggests he lives for danger, he is actually a caring person who consistently helps his comrades escape tight spots.`, + 20, + EntityType.Ground, + MovementTypes.Tires, + { + cost: 225, + fuel: 50, + radius: 8, + vision: 5, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + Weapons.MG.withDamage( + new Map([...Weapons.MG.damage, [EntityType.LowAltitude, 55]]), + ) + .withSupply(7) + .withAnimationPositions({ + down: sprite(0, 0.4), + horizontal: sprite(-0.45, 0.15), + up: sprite(0.03, -0.4), + }), + Weapons.AntiArtilleryGun.withSupply(5), + ], + }, + null, + { + direction: 'left', + name: 'Units-Humvee', + portrait: { + position: sprite(14, 0), + variants: 3, + }, + }, +); + +export const AntiAir = new UnitInfo( + 11, + 'Anti Air', + 'Sera', + 'female', + `Anti Air tanks are the only ground units that are highly effective against all types of air units. They need to be heavily protected by surrounding units due to their weak defense.`, + `{name} is one of the smartest strategists in the defense force. She is known for her quick thinking and her ability to adapt to any situation. Despite her young age, {name} is constantly sought out for her wisdom about battlefield tactics. She quickly comes up with multiple strategies and lays out the tradeoffs in great detail. Some have said that {name} has a cosmic ability to see many possibilities play out in her mind – where might such a talent come from?`, + 15, + EntityType.Ground, + MovementTypes.Tread, + { + cost: 250, + fuel: 30, + radius: 6, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.AntiAirGun.withSupply(10)], + }, + null, + { + direction: 'left', + invert: false, + name: 'Units-AntiAir', + portrait: { + position: sprite(24, 0), + variants: 3, + }, + }, +); + +export const HeavyArtillery = new UnitInfo( + UnitID.HeavyArtillery, + 'Heavy Artillery', + 'Unknown', + 'male', + `Heavy Artilleries are feared for their large attack range. They deal severe ranged damage, but they are vulnerable to any unit that can get close to them.`, + `To cancel out the noise of the battlefield, {name} listens to jazz music on his headphones. It is not clear if it is the headphones or his goofy attitude that require confirming every target coordinate up to five times, but at least he never misses.`, + 5, + EntityType.Artillery, + MovementTypes.Tread, + { + cost: 650, + fuel: 15, + radius: 3, + vision: 1, + }, + new UnitAbilities({ accessBuildings: true }), + { + range: [3, 5], + type: AttackType.LongRange, + weapons: [Weapons.HeavyArtillery.withSupply(4)], + }, + null, + { + direction: 'left', + invert: false, + name: 'Units-HeavyArtillery', + portrait: { + position: sprite(16, 0), + variants: 3, + }, + }, +); + +const Lander = new UnitInfo( + 13, + 'Lander', + 'Masato', + 'male', + `Landers can transport soldiers across the sea. They are an essential unit for staging an attack on remote islands, but they lack offensive capabilities and need to be protected by other units.`, + `{name}'s blue hair is a stark contrast to his calm demeanor. He frequently travels the most treacherous waters to bring his comrades to the frontlines. When he is not on the battlefield, {name} can be found in the library, studying naval strategies.`, + 10, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 225, + fuel: 60, + radius: 7, + vision: 1, + }, + new UnitAbilities({ + moveAndAct: true, + }), + null, + { + limit: 2, + tiles: new Set([10]), + types: new Set([EntityType.AirInfantry, EntityType.Infantry]), + }, + { + direction: 'left', + name: 'Units-Lander', + portrait: { + position: sprite(40, 0), + variants: 3, + }, + transports: sprite(0, 3), + transportsMany: sprite(0, 6), + }, +); + +export const Sniper = new UnitInfo( + 14, + 'Sniper', + 'Maxima', + 'female', + `Snipers stand out as the sole infantry units capable of long-range attacks. Though costly and requiring positioning to attack, they can make the necessary difference in securing strategic locations.`, + `{name} was raised in the desert region but left with her sister to join the defense forces. Calm and collected, {name} excels at long range attacks while being able to spot threats on the battlefield from far away. Her intelligence carries weight in the conflict and everyone listens when she has a plan.`, + 15, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 375, + fuel: 40, + radius: 4, + vision: 1, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + unfold: true, + }), + { + range: [2, 4], + type: AttackType.LongRange, + weapons: [Weapons.SniperRifle.withSupply(7)], + }, + null, + { + alternativeExplosionSprite: { + frames: 8, + position: sprite(7, 9), + }, + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + name: 'Units-Sniper', + portrait: { + position: sprite(1, 0), + variants: 6, + }, + slow: true, + unfold: sprite(0, 8), + unfoldSounds: { + fold: 'Unit/SniperFold', + unfold: 'Unit/SniperUnfold', + }, + unfoldSprite: { frames: 8, position: sprite(0, 6) }, + }, +); + +export const Flamethrower = new UnitInfo( + 15, + 'Flamethrower', + 'Yuki', + 'unknown', + `Flamethrowers specialize in close combat, wielding immense firepower effective against infantry and light ground units. Howeveer, carrying a volatile gas tank on their backs makes them susceptible to attacks, resulting in low defense.`, + `{name} is a mayhem-loving recruit with a fiery personality. They come from the cold north but bring the warmth with them wherever they go. They look up to more experienced fighters to help them know when to turn down the intensity. As the leader of the flamethrower elites, they are known to revel in the mayhem of a battle and getting a little bit carried away when it comes to burning things down.`, + 0, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 400, + fuel: 30, + radius: 4, + vision: 3, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [Weapons.Flamethrower.withSupply(4)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Flamethrower', + portrait: { + position: sprite(5, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Saboteur = new UnitInfo( + 16, + 'Saboteur', + 'Arvid', + 'male', + `Saboteurs are the most mischievous of all units, capable of sabotaging opposing units and taking away their supplies. They can also rescue neutral units on the battlefield to join their cause. Because they don't carry weapons, they rely on their fists to defend themselves.`, + `{name} was recruited into the defense forces alongside {14.name}. He loves deceitfully sneaking into enemy territory to sabotage, steal secrets or just wreak havoc for the fun of it. Although nobody is quite sure if he is ever really scared of anything, his comrades have seen him pack a punch when his life depends on it.`, + 10, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 325, + fuel: 40, + radius: 6, + sabotageTypes: DefaultSabotageTypes, + vision: 1, + }, + SaboteurUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Punch], + }, + null, + { + attackStance: 'once', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + name: 'Units-Saboteur', + portrait: { + position: sprite(9, 0), + variants: 6, + }, + slow: true, + }, +); + +export const TransportHelicopter = new UnitInfo( + 17, + 'Transport Chopper', + 'Charlie', + 'male', + `Transport Helicopters can resupply air units, ensuring that a fleet doesn't run out of supplies and risk crashing. They can also transport up to two soldiers across the battlefield.`, + `{name} was never limited by his ambition, mainly because his ambitions were never that high. He is a reliable pilot who enjoys a simple life: flying helicopters, engaging in woodwork, and writing short stories. His latest novel is about a helicopter pilot who gets sucked into another world full of dragons and dinosaurs. Hah, as if that could ever happen!`, + 30, + EntityType.LowAltitude, + MovementTypes.LowAltitude, + { + cost: 200, + fuel: 60, + radius: 7, + supplyTypes: new Set([ + EntityType.AirInfantry, + EntityType.LowAltitude, + EntityType.Airplane, + ]), + vision: 3, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + supply: true, + }), + null, + { + limit: 2, + types: new Set([EntityType.AirInfantry, EntityType.Infantry]), + }, + { + direction: 'right', + name: 'Units-TransportHelicopter', + portrait: { + position: sprite(18, 0), + variants: 3, + }, + }, +); + +export const FighterJet = new UnitInfo( + 18, + 'Fighter Jet', + 'Titan', + 'unknown', + `Fighter Jets ensure air superiority with their high mobility and firepower against other air units. While they cannot win a battle alone, lacking Fighter Jets in a fleet means ceding air control to the opponent.`, + `Only known by their call sign, {name} is a mysterious pilot with an unknown background but an impressive record of downing opponents. Whatever their origin, they are an effective leader of the fighter jet division. Given the large range of Fighter Jets, {name} is known to scout ahead and discover enemy forces when nobody else can.`, + 60, + EntityType.Airplane, + MovementTypes.Air, + { + cost: 550, + fuel: 50, + radius: 8, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [AirToAirMissile.withSupply(8)], + }, + null, + { + direction: 'right', + name: 'Units-FighterJet', + portrait: { + position: sprite(19, 0), + variants: 3, + }, + }, +); + +export const Bomber = new UnitInfo( + 19, + 'Bomber', + 'Léon', + 'male', + `If Fighter Jets are the kings of the air, Bombers are the queens. They can deal massive damage to ground units and buildings but have no defenses against air units.`, + `{name} hails from a beautiful mountain region known for its thermal activity. As a child, he would soak in the hot springs, look up, and dream of flying. He lives his dream as the leader of the Bomber squad. When his crew noticed that he easily gets startled by noise, they started playing pranks on him. Now, he is always on edge, but his crew loves him for it.`, + 55, + EntityType.Airplane, + MovementTypes.Air, + { + cost: 800, + fuel: 40, + radius: 6, + vision: 2, + }, + DefaultUnitAbilities, + { type: AttackType.ShortRange, weapons: [Weapons.Bomb.withSupply(5)] }, + null, + { + direction: 'right', + name: 'Units-Bomber', + portrait: { + position: sprite(23, 0), + variants: 3, + }, + }, +); + +export const Jetpack = new UnitInfo( + 20, + 'Jetpack', + 'Sora', + 'male', + `Jetpacks are a new invention. They can navigate almost all terrain types and are effective against air and ground units. They can capture buildings, but their low fuel makes them a risky investment.`, + `An excitable tinkerer, {name} created the jetpack technology and leads the jetpack division through field testing new and exciting ways to use it. When he isn’t flying a jetpack, he researches new technology found by his friends in their exploration of the multiverse. As a creative tinkerer he can't help himself in trying out any new tech he gets his hands on.`, + 20, + EntityType.AirInfantry, + MovementTypes.AirInfantry, + { + cost: 300, + fuel: 30, + radius: 4, + vision: 2, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [ + Weapons.SoldierMG.withSupply(5).withAnimationPositions({ + down: sprite(0.03, 0.45), + horizontal: sprite(-0.45, 0.07), + up: sprite(0, -0.4), + }), + AirToAirMissile.withSupply(3) + .withDamage(buff(AirToAirMissile.damage, -15)) + .withAnimationPositions({ + down: sprite(0.03, 0.45), + horizontal: sprite(-0.45, 0.07), + up: sprite(0, -0.4), + }), + ], + }, + null, + + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Jetpack', + portrait: { + position: sprite(8, 0), + variants: 3, + }, + withNavalExplosion: true, + }, +); + +export const SeaPatrol = new UnitInfo( + 21, + 'Sea Patrol', + 'Unknown', + 'female', + `Low-altitude Sea Patrol units are inexpensive and incredibly useful for securing coastal areas against ships and other low-altitude aircraft. However, their low mobility makes them particularly vulnerable to air attacks, and crash landings on islands are not uncommon.`, + `{name} has a loyal, easy-going, and happy personality. She tends to get angry when one of her friends gets stranded on an island – not because of what happened, but because it affects the perfect record of her squad. Since she is always the first to volunteer for a rescue mission, she at least gets to maintain the record for most rescues.`, + 30, + EntityType.LowAltitude, + MovementTypes.LowAltitude, + { + cost: 200, + fuel: 40, + radius: 4, + vision: 4, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + LightAirGun.withDamage( + new Map([ + [EntityType.AirInfantry, 100], + [EntityType.Amphibious, 60], + [EntityType.Artillery, 55], + [EntityType.Ground, 55], + [EntityType.LowAltitude, 100], + [EntityType.Infantry, 40], + [EntityType.Rail, 55], + [EntityType.Ship, 75], + ]), + ).withSupply(5), + ], + }, + null, + { + direction: 'right', + name: 'Units-SeaPatrol', + portrait: { + position: sprite(17, 0), + variants: 3, + }, + }, +); + +export const AcidBomber = new UnitInfo( + 22, + 'Acid Bomber', + 'Ada', + 'female', + `Unknown`, + `Unknown`, + 50, + EntityType.Airplane, + MovementTypes.Air, + { + cost: Number.POSITIVE_INFINITY, + fuel: 20, + radius: 4, + vision: 1, + }, + DefaultUnitAbilities, + { type: AttackType.ShortRange, weapons: [Weapons.Bomb.withSupply(2)] }, + null, + { + direction: 'right', + name: 'Units-AcidBomber', + portrait: { + position: sprite(25, 0), + variants: 3, + }, + }, +); + +export const Drone = new UnitInfo( + 23, + 'Drone Bomber', + 'N.U.L.L.', + 'unknown', + `Unknown`, + `Unknown`, + 25, + EntityType.Airplane, + MovementTypes.Air, + { + cost: Number.POSITIVE_INFINITY, + fuel: 40, + radius: 5, + vision: 2, + }, + DefaultUnitAbilities, + { type: AttackType.ShortRange, weapons: [Weapons.DroneBomb.withSupply(5)] }, + null, + { + direction: 'right', + name: 'Units-Drone', + portrait: { + position: sprite(22, 0), + variants: 3, + }, + }, +); + +export const ReconDrone = new UnitInfo( + 24, + 'Recon Drone', + 'R.O.D.', + 'unknown', + `Unknown`, + `Unknown`, + 15, + EntityType.Airplane, + MovementTypes.Air, + { + cost: Number.POSITIVE_INFINITY, + fuel: 60, + radius: 7, + vision: 5, + }, + DefaultUnitAbilities, + { type: AttackType.ShortRange, weapons: [Weapons.MG.withSupply(5)] }, + null, + { + direction: 'right', + name: 'Units-ReconDrone', + portrait: { + position: sprite(21, 0), + variants: 3, + }, + }, +); + +export const XFighter = new UnitInfo( + 25, + 'X-Fighter', + 'Amira', + 'female', + `The X-Fighter is a cheap and fast air unit that can attack other air units directly or indirectly. X-Fighters are not strong, but they can lure enemy air units into traps and attack them from a distance.`, + `{14.name}'s younger sister {name} is a cool-headed fighter pilot who is always ready to take on the next challenge. She is a natural leader and is always looking out for her friends. {name} is a perfectionist and always trying to improve her skills on the battlefield.`, + 30, + EntityType.Airplane, + MovementTypes.Air, + { + cost: 250, + fuel: 40, + radius: 5, + vision: 2, + }, + DefaultUnitAbilities, + { + range: [1, 2], + type: AttackType.LongRange, + weapons: [Weapons.LightAirToAirMissile.withSupply(5)], + }, + null, + { + direction: 'right', + name: 'Units-XFighter', + portrait: { + position: sprite(20, 0), + variants: 3, + }, + }, +); + +export const Medic = new UnitInfo( + UnitID.Medic, + 'Medic', + 'Corrado', + 'male', + `Medics are essential on a battlefield with constrained resources. They can patch up wounded soldiers right at the frontlines and keep them going long enough to potentially turn the tide of battle.`, + `{name} is the cool-headed chief medical officer in the defense force and never breaks a sweat in any conflict. Lose an arm? {name} will give you a band-aid and send you back onto the field.`, + 0, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: 250, + fuel: 80, + healTypes: new Set([EntityType.Infantry, EntityType.AirInfantry]), + radius: 4, + vision: 1, + }, + HealUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Punch], + }, + null, + { + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + healSprite: { + frames: 8, + position: sprite(0, 6), + }, + name: 'Units-Medic', + portrait: { + position: sprite(0, 0), + variants: 3, + }, + slow: true, + }, +); + +export const AmphibiousTank = new UnitInfo( + 27, + 'Amphibious Tank', + 'Siegfried', + 'male', + `Amphibious Tanks are versatile units that can move on both land and water. Their attack power is similar to Small Tanks but they can additionally navigate the sea and deal meaningful damage to ships.`, + `{name} is known for his endearing accent and the stories he tells. With a degree in medieval history, he tends to recount tales of knights and dragons. His favorite story is about a knight, also named {name}, who slays the biggest dragon of all to rescue the princess. He often says heroes don't wear capes; in one of his tales, the hero is a plumber. Where did he get that idea from?`, + 35, + EntityType.Amphibious, + MovementTypes.Amphibious, + { + cost: 450, + fuel: 25, + radius: 6, + vision: 2, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.AmphibiousLightGun.withSupply(10)], + }, + null, + { + alternative: sprite(0, 3), + direction: 'left', + name: 'Units-Amphibious', + portrait: { + position: sprite(11, 0), + variants: 3, + }, + }, +); + +export const Destroyer = new UnitInfo( + 28, + 'Destroyer', + 'Cohen', + 'male', + `Destroyers are among the most versatile naval units. Technological advances allow Destroyers to be highly effective against air and ground units, but they are vulnerable to other naval units.`, + `{name} does not kid around on the battlefield. He marches into battle with a stern face and a determined look but is often seen as overconfident by his peers. He once single-handedly destroyed an entire enemy fleet, but his stubbornness has also led to some embarrassing defeats. However, his ability to learn from mistakes has made him a respected leader.`, + 30, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 350, + fuel: 60, + radius: 6, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.SAM.withSupply(6), CruiseMissile.withSupply(4)], + }, + null, + { + direction: 'left', + invert: false, + name: 'Units-Destroyer', + portrait: { + position: sprite(35, 0), + variants: 3, + }, + }, +); + +export const Frigate = new UnitInfo( + 29, + 'Frigate', + 'Unknown', + 'male', + `The Frigate is every naval fleet's workhorse. It's a dual short- and long-range unit that is highly effective against ships. Its lower mobility is countered by its ability to attack from a distance.`, + `{name} is a happy sailor who enjoys the rough seas and thrill of chasing enemy aircraft. Aside from the battlefield, {name} is a talented musician who plays the violin. He is known for his ability to calm the crew with his music, even in the most intense situations.`, + 10, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 400, + fuel: 60, + radius: 5, + vision: 3, + }, + DefaultUnitAbilities, + { + range: [1, 2], + type: AttackType.LongRange, + weapons: [AntiShipMissile.withSupply(8)], + }, + null, + { + direction: 'left', + name: 'Units-Frigate', + portrait: { + position: sprite(39, 0), + variants: 3, + }, + }, +); + +export const Hovercraft = new UnitInfo( + 30, + 'Hovercraft', + 'Jygo', + 'male', + `Hovercrafts are gigantic barges that can transport up to four ground units across the sea. While they cannot attack, they have entertainment and relaxation facilities on board, which keep their loaded troops in high spirits. This morale boost often enables devastating surprise attacks immediately upon landing on enemy shores.`, + `{name} himself is responsible for many of the recent improvements in troop morale. Recognizing the importance of keeping everyone in high spirits, he developed a variety of games and activities that can be attended to while on board. Unfortunately, the program had to be scaled back after a few incidents involving the ship's XO and a competitive duck chase game, which escalated when the XO accidentally launched herself into the sea while trying to capture the "Golden Duck" trophy.`, + 20, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 400, + fuel: 60, + radius: 7, + vision: 3, + }, + new UnitAbilities({ + moveAndAct: true, + }), + null, + { + limit: 4, + tiles: new Set([10]), + types: new Set([ + EntityType.Infantry, + EntityType.AirInfantry, + EntityType.Ground, + EntityType.Artillery, + ]), + }, + { + direction: 'left', + invert: false, + name: 'Units-Hovercraft', + portrait: { + position: sprite(34, 0), + variants: 3, + }, + }, +); + +export const PatrolShip = new UnitInfo( + 31, + 'Patrol Ship', + 'Alex', + 'female', + `The Patrol Ship is an amphibious unit capable of capture both on land and at sea. It can help secure an early economic advantage by taking control of Oil Rigs and Shipyards. It is strong against soldiers but weak against other unit types.`, + `{name} is a free spirit who loves nature, mountains, and her two dogs. She is not a fan of the sea, which is perhaps why she ended up operating an amphibious unit that can traverse even the most difficult terrain on land. Her love for animals is apparent when she brings her dogs to the battlefield, where they are known to cheer up the troops.`, + 20, + EntityType.Amphibious, + MovementTypes.Amphibious, + { + cost: 300, + fuel: 60, + radius: 5, + vision: 3, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [Weapons.SeaMG.withSupply(6)], + }, + null, + { + alternative: sprite(0, 3), + direction: 'left', + name: 'Units-SmallHovercraft', + portrait: { + position: sprite(36, 0), + variants: 3, + }, + }, +); + +const EntityTypesToSupport = new Set([ + EntityType.Amphibious, + EntityType.Artillery, + EntityType.Ground, + EntityType.Infantry, + EntityType.Rail, + EntityType.Ship, +]); +export const SupportShip = new UnitInfo( + 32, + 'Support Ship', + 'Unknown', + 'male', + `The Support Ship can heal and resupply both ground units and ships in its vicinity. While it is not usually seen on the frontlines when a battle commences, it is essential for maintaining supply lines and keeping a fleet in fighting shape.`, + `{name} grew up poor in a landlocked country and never saw the sea until he joined the defense forces. The vastness of the ocean and the power of the ships captivated him from the start. He's always had a knack for fixing things, and now he uses his skills to keep the fleet in top shape. Away from the battlefield, he enjoys playing the guitar in a metal band.`, + 40, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 275, + fuel: 90, + healTypes: EntityTypesToSupport, + radius: 6, + supplyTypes: EntityTypesToSupport, + vision: 5, + }, + new UnitAbilities({ + accessBuildings: true, + heal: true, + moveAndAct: true, + rescue: true, + supply: true, + }), + null, + null, + { + direction: 'left', + name: 'Units-SupportShip', + portrait: { + position: sprite(41, 0), + variants: 3, + }, + }, +); + +export const Corvette = new UnitInfo( + 33, + 'Corvette', + 'Unknown', + 'female', + `The Corvette is the smallest of ships, but its torpedoes can deal significant damage to other ships. Coupled with its low cost and high mobility, it can be devastating to even the largest of fleets when it catches them off guard.`, + `{name} is small but mighty. She is known for her quick wit and her ability to outmaneuver larger ships. Despite having a family to care for, she frequently volunteers for the most dangerous missions. Her bravery and skill have earned her the respect of her peers and the nickname "Mama Shark".`, + 0, + EntityType.Ship, + MovementTypes.Ship, + { + cost: 200, + fuel: 30, + radius: 6, + vision: 1, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Torpedo.withSupply(5)], + }, + null, + { + direction: 'left', + name: 'Units-Corvette', + portrait: { + position: sprite(38, 0), + variants: 3, + }, + }, +); + +export const Mammoth = new UnitInfo( + 34, + 'Mammoth', + 'Shamus', + 'male', + `Unmatched in firepower on the battlefield, the Mammoth is slow but can deal massive damage to distant units. Novel technology enables the Mammoth to move and attack simultaneously, making it extremely dangerous in the hands of a skilled commander.`, + `Operating a gigantig siege artillery is no easy task, but for {name} it's mostly upside! When he is not moving into position or preparing a trajectory, he often spends his free time reading and gardening, all the while humming in tune with the ringing of his ears. Despite his positive demeanor, he is quietly thankful he can remain further away from action.`, + 35, + EntityType.Rail, + MovementTypes.Rail, + { + cost: 900, + fuel: 30, + radius: 4, + vision: 1, + }, + DefaultUnitAbilities, + { + range: [2, 4], + type: AttackType.LongRange, + weapons: [Weapons.Railgun.withSupply(6)], + }, + null, + { + direction: 'left', + name: 'Units-Mammoth', + portrait: { + position: sprite(42, 0), + variants: 3, + }, + }, +); + +export const TransportTrain = new UnitInfo( + 35, + 'Transport Train', + 'Unknown', + 'female', + `While primarily a unit for transporting other ground units to the frontlines quickly, the Transport Train also boasts a formidable attack. Coupled with its high defense it can clear a path before unloading troops.`, + `{name} has had a fair amount of setbacks in her life, but she always manages to get back on track. She is reliable but often dramatic. Known for her love of trains, she is also a talented singer, often entertaining her passengers with songs inspired by her life experiences.`, + 30, + EntityType.Rail, + MovementTypes.Rail, + { + cost: 325, + fuel: 30, + radius: 7, + vision: 2, + }, + new UnitAbilities({ + moveAndAct: true, + }), + { + type: AttackType.ShortRange, + weapons: [ + Weapons.HeavyGun.withDamage( + new Map([ + [EntityType.AirInfantry, 55], + [EntityType.Amphibious, 125], + [EntityType.Artillery, 125], + [EntityType.Building, 70], + [EntityType.Ground, 125], + [EntityType.LowAltitude, 55], + [EntityType.Infantry, 85], + [EntityType.Rail, 115], + [EntityType.Ship, 70], + [EntityType.Structure, 110], + ]), + ).withSupply(5), + ], + }, + { + limit: 4, + types: new Set([ + EntityType.Infantry, + EntityType.AirInfantry, + EntityType.Ground, + EntityType.Artillery, + EntityType.Amphibious, + ]), + }, + { + direction: 'left', + name: 'Units-TransportTrain', + portrait: { + position: sprite(43, 0), + variants: 3, + }, + }, +); + +export const Dinosaur = new UnitInfo( + 36, + 'Dinosaur', + 'Dion', + 'unknown', + 'A Dinosaur in this day and age?? How did it get here? Do you think it hatched from an Easter Egg?', + `{name} is the leader of the Dinosaur faction. In their world, {name} worked as a butcher in a fine dining restaurant. Everything changed when their world was invaded by odd-looking bipedal creatures dressed in colorful outfits, wielding strange weapons. Now, {name} is fighting to protect their world from these invaders.`, + 25, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 100, + radius: 3, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Bite], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Dinosaur', + portrait: { + position: sprite(44, 0), + variants: 3, + }, + slow: true, + }, +); + +export const HeavyTank = new UnitInfo( + 37, + 'Heavy Tank', + 'Unknown', + 'male', + `The Heavy Tank strikes a perfect balance between firepower, defense, and mobility. It excels in most head-to-head battles and can withstand significant damage. Armies typically field a few Heavy Tanks to lead charges and break through enemy lines.`, + `His comrades joke that {name} is living in his tank. He is quiet and reserved, preferring to let his actions speak for him. Not much is known about {name}, except that he loves eating sweets made from rice and red beans, a favorite from his home country.`, + 60, + EntityType.Ground, + MovementTypes.Tread, + { + cost: 600, + fuel: 25, + radius: 5, + vision: 2, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + Weapons.HeavyGun.withSupply(10), + Weapons.MG.withAnimationPositions({ + down: sprite(-0.06, 0.65), + horizontal: sprite(-0.4, 0.13), + up: sprite(-0.08, -0.35), + }), + ], + }, + null, + { + direction: 'left', + name: 'Units-HeavyTank', + portrait: { + position: sprite(47, 0), + variants: 3, + }, + }, +); + +export const SuperTank = new UnitInfo( + 38, + 'Super Tank', + 'Olaf', + 'male', + `The Super Tank is among the most powerful units with extremely high firepower and defense. Unfortunately, due to the complexity of their design nobody has figured out how to build new Super Tanks. They can only be acquired by rescuing them on the battlefield.`, + `{name} hails from the frostbitten regions of the north. His extensive experience in harsh, snowy environments has honed his tactical expertise, particularly in utilizing terrain to his advantage. As the commander of the Super Tank division, {name}'s strategic insights were instrumental in augmenting the Super Tank's formidable offensive capabilities. His leadership is as unyielding as the tundra he calls home, making him a formidable force on the battlefield.`, + 70, + EntityType.Ground, + MovementTypes.Tread, + { + cost: Number.POSITIVE_INFINITY, + fuel: 20, + radius: 3, + vision: 1, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + Weapons.HeavyGun.withDamage(buff(Weapons.HeavyGun.damage, 25)).withSupply( + 10, + ), + ], + }, + null, + { + direction: 'left', + name: 'Units-SuperTank', + portrait: { + position: sprite(48, 0), + variants: 3, + }, + }, +); + +export const HumveeAvenger = new UnitInfo( + 39, + 'Humvee Avenger', + 'Stella', + 'female', + `Unknown`, + `Unknown`, + 30, + EntityType.Ground, + MovementTypes.Tires, + { + cost: Number.POSITIVE_INFINITY, + fuel: 40, + radius: 6, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + new Weapon( + 'Rockets', + new Map([ + [EntityType.AirInfantry, 60], + [EntityType.Airplane, 60], + [EntityType.Amphibious, 90], + [EntityType.Artillery, 70], + [EntityType.Ground, 80], + [EntityType.LowAltitude, 60], + [EntityType.Infantry, 80], + [EntityType.Rail, 60], + [EntityType.Ship, 60], + ]), + new WeaponAnimation('Rockets', 'Attack/Rockets', { + frames: 9, + positions: { + down: sprite(0, 1.5), + horizontal: sprite(-0.9, 0.2), + up: sprite(0.35, -0.75), + }, + recoil: true, + size: 48, + trailingFrames: 10, + }), + new WeaponAnimation('ExplosionImpact', 'ExplosionImpact', { + frames: 10, + leadingFrames: 9, + recoil: false, + size: 48, + }), + ), + ], + }, + null, + { + direction: 'left', + name: 'Units-HumveeAvenger', + portrait: { + position: sprite(49, 0), + variants: 3, + }, + }, +); + +export const ArtilleryHumvee = new UnitInfo( + 40, + 'Artillery Humvee', + 'Unknown', + 'female', + `Unknown`, + `Unknown`, + 30, + EntityType.Ground, + MovementTypes.Tires, + { + cost: Number.POSITIVE_INFINITY, + fuel: 40, + radius: 6, + vision: 3, + }, + DefaultUnitAbilities, + { + range: [2, 3], + type: AttackType.LongRange, + weapons: [Weapons.Rocket.withSupply(10)], + }, + null, + { + direction: 'left', + name: 'Units-ArtilleryHumvee', + portrait: { + position: sprite(50, 0), + variants: 3, + }, + }, +); + +export const SupplyTrain = new UnitInfo( + 41, + 'Supply Train', + 'Unknown', + 'male', + `Supply Trains are among the most cost effective units when it comes to attacking other ground units. They are capable of supplying other units on the battlefield and keeping supply lines open.`, + `{name} always yearns for the serene landscape of his mountainous home region. He enjoys meditating, sitting on a mat in his traditional house and listening to the wind chimes in his garden. Reflecting on the contrast between the chaos of the battlefield and the peacefulness of his home, he often inspires those around him to balance the loudness with calm.`, + 40, + EntityType.Rail, + MovementTypes.Rail, + { + cost: 200, + fuel: 60, + radius: 6, + supplyTypes: GroundSupplyTypes, + vision: 1, + }, + new UnitAbilities({ + moveAndAct: true, + supply: true, + }), + { + type: AttackType.ShortRange, + weapons: [ + Weapons.LightGun.withDamage( + new Map([ + ...LightGun.damage, + + [EntityType.Amphibious, 75], + [EntityType.Ship, 55], + ]), + ).withSupply(5), + ], + }, + null, + { + direction: 'left', + name: 'Units-SupplyTrain', + portrait: { + position: sprite(46, 0), + variants: 3, + }, + }, +); + +export const Truck = new UnitInfo( + 42, + 'Truck', + 'Unknown', + 'female', + `Unknown`, + `Unknown`, + 40, + EntityType.Ground, + MovementTypes.Tires, + { + cost: Number.POSITIVE_INFINITY, + fuel: 100, + radius: 8, + supplyTypes: GroundSupplyTypes, + vision: 2, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + supply: true, + }), + null, + { + limit: 4, + types: new Set([EntityType.Infantry, EntityType.AirInfantry]), + }, + { + direction: 'left', + name: 'Units-Truck', + portrait: { + position: sprite(52, 0), + variants: 3, + }, + }, +); + +export const Octopus = new UnitInfo( + 43, + 'Octopus', + 'General Buccal', + 'unknown', + `Octopus units are a formidable naval force, known for their tentacle whips that deal significant damage to ships and ground units. Wait, isn't anyone curious why octopi are part of the fleet? They just… exist?`, + `{name} was once the chief architect of a vibrant underwater city, renowned for breathtaking coral structures. Their peaceful life of creation was shattered when their world was invaded. Now, their role has changed from creator to destroyer. They view the battlefield as a grand canvas, where every maneuver is part of a larger tapestry of survival and victory.`, + 5, + EntityType.Ship, + MovementTypes.Ship, + { + cost: Number.POSITIVE_INFINITY, + fuel: 200, + radius: 5, + vision: 2, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.TentacleWhip.withSupply(10)], + }, + null, + { + attackStance: 'long', + directionOffset: 3, + explosionSprite: { + frames: 8, + position: sprite(0, 2), + }, + name: 'Units-Octopus', + portrait: { + position: sprite(29, 0), + variants: 3, + }, + }, +); + +export const Dragon = new UnitInfo( + 44, + 'Dragon', + 'Unknown', + 'unknown', + `Dragons are kind of like a multiversal Flamethrower unit, if you think about it. Except they can also fly, are huge, and people tend to freeze when they see a Dragon. So, not really like a Flamethrower unit at all. But they do breathe fire!`, + `{name} is a solitary creature, preferring to keep to themselves and avoid conflict whenever possible. Unfortunately, they were pulled into the multiversal conflict and had no choice but to fight. Originally from a world where dragons were revered as wise and powerful beings, {name} is now seen as a terrifying force of nature. They long for the day when they can return to their peaceful existence.`, + 25, + EntityType.AirInfantry, + MovementTypes.AirInfantry, + { + cost: Number.POSITIVE_INFINITY, + fuel: 50, + radius: 4, + vision: 3, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + Weapons.Flamethrower.withSupply(6).withAnimationPositions({ + down: sprite(-0.4, 1), + horizontal: sprite(-0.85, -0.05), + up: sprite(0.4, -0.85), + }), + ], + }, + null, + { + alternative: sprite(0, 6), + alternativeExplosionSprite: { + frames: 8, + position: sprite(0, 0), + }, + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Dragon', + portrait: { + position: sprite(28, 0), + variants: 3, + }, + withNavalExplosion: true, + }, +); + +export const Bear = new UnitInfo( + 45, + 'Bear', + 'Unknown', + 'female', + `This is a Bear, and Bears tend to be huge. Usually found in forests and mountains, they are hunted for their fur and meat. But this Bear is different. It's a unit in a game. So, don't hunt it, just fight it!`, + `There is not much known about {name}, except that she is bigger and stronger than most other bears. Many stories circulate about a giant in the forest devouring soldiers and even tanks. But that couldn't be her, right? Right??`, + 25, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 100, + radius: 2, + vision: 2, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Bite], + }, + null, + { + attackStance: 'once', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Bear', + portrait: { + position: sprite(45, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Alien = new UnitInfo( + 46, + 'Alien', + 'Unknown', + 'unknown', + `Alien units aren't really that special. They're just like any other soldier, except they come from another dimension. Rumored to possess advanced technology and abilities, and known to travel in spaceships — but other than that, they're just like any other soldier. Really.`, + `Who hasn't been beamed up onto a spaceship and probed by aliens before? Well, anyway, it was definitely not {name}, because {name} is an interior decorator from another dimension. They're here to make sure the multiverse looks good, one universe at a time. Although… they are known to get into disagreements about style. To {name}, all the useless clutter from humans seems out of this world. Who needs it, really?`, + 10, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 20, + radius: 4, + sabotageTypes: DefaultSabotageTypes, + vision: 1, + }, + SaboteurUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Bite], + }, + null, + { + attackStance: 'once', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 2 }, + position: sprite(7, 1), + }, + name: 'Units-Alien', + portrait: { + position: sprite(26, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Zombie = new UnitInfo( + UnitID.Zombie, + 'Zombie', + 'Unknown', + 'unknown', + `Zombies are slow but extremely dangerous. A hit from a Zombie infects the opponent, converting them to the Zombie faction. A Zombie faction? Yes, that's right. They are a formidable faction, and they are coming for all of us.`, + `We do not know where {name} comes from, what "it" is thinking, or even if "it" is capable of thought at all. What we do know is that {name}, along with other Zombies, is trying to convert us all, one by one, into their ranks. They want to eat our brains and are here to do it all very, very slowly.`, + 0, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 20, + radius: 3, + vision: 1, + }, + PioneerUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [ + new Weapon( + 'Bite', + buff(Weapons.Bite.damage, -50), + BiteAnimation.withSound('Attack/ZombieBite'), + BiteHitAnimation, + 5, + ), + ], + }, + null, + { + attackStance: 'once', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 2 }, + position: sprite(7, 1), + }, + name: 'Units-Zombie', + portrait: { + position: sprite(27, 0), + variants: 3, + }, + slow: true, + }, +); +export const Ogre = new UnitInfo( + 48, + 'Ogre', + 'Unknown', + 'unknown', + `Unknown`, + `Unknown`, + 0, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 20, + radius: 4, + vision: 1, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.Club], + }, + null, + { + attackStance: 'once', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-Ogre', + portrait: { + position: sprite(30, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Brute = new UnitInfo( + UnitID.Brute, + 'Brute', + 'Blaine', + 'male', + `The Brute is a huge, slow-moving unit with a powerful shotgun. Its high defense and attack power make it a force to be reckoned with anywhere on the battlefield. If you see a Brute, you better have a plan to deal with it.`, + `{name} believes he can solve all problems with brute force, rarely admitting when he is wrong. Deep down, he is said to have a gentle heart and goes out of his way to protect those he cares about. However, some wonder if this gentleness is just a facade to hide his true intentions, which might simply involve using his shotgun to get what he wants.`, + 50, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 60, + radius: 4, + vision: 1, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [Weapons.Shotgun.withSupply(6)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 2 }, + position: sprite(7, 1), + }, + name: 'Units-Brute', + portrait: { + position: sprite(33, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Commander = new UnitInfo( + 50, + 'Commander', + 'Unknown', + 'male', + `Armies are composed of master strategists and troops on the ground executing orders. Then there's the Commander unit, who can neither strategize nor execute effectively. They excel in scheduling unnecessary meetings and perpetuating confusion under the guise of "alignment". Equipped only with a pistol, one has to wonder: What exactly is their purpose?`, + `Even if it appears {name} is one step behind, he invariably ends up five steps ahead. He seems to be the only Commander unit with actual tactical and strategic abilities, perceiving the multiverse as a complex chessboard of possibilities where everyone but him is a pawn. Unlike his counterparts, who often get lost in bureaucracy and confusion, {name} navigates these complexities with unmatched foresight and precision, masterfully turning tough situations to his advantage.`, + 10, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 30, + radius: 2, + vision: 3, + }, + DefaultUnitAbilitiesWithCapture, + { type: AttackType.ShortRange, weapons: [Weapons.Pistol.withSupply(5)] }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + offset: { y: 1 }, + position: sprite(7, 1), + }, + name: 'Units-Commander', + portrait: { + position: sprite(32, 0), + variants: 3, + }, + slow: true, + }, +); + +export const Cannon = new UnitInfo( + UnitID.Cannon, + 'Cannon', + 'Spike', + 'male', + 'A prototype unit with a powerful cannon but almost no mobility. Only a limited number of these units were produced before the project was scrapped.', + `{name} is a bit of a loose cannon himself and he’s a good guy to have on your side.`, + 40, + EntityType.Artillery, + MovementTypes.Tires, + { + cost: Number.POSITIVE_INFINITY, + fuel: 40, + radius: 1, + vision: 1, + }, + new UnitAbilities({ + accessBuildings: true, + moveAndAct: true, + unfold: true, + }), + { + range: [2, 6], + type: AttackType.LongRange, + weapons: [Weapons.Cannon.withSupply(7)], + }, + null, + { + name: 'Units-Cannon', + offset: { y: 3 }, + portrait: { + position: sprite(51, 0), + variants: 3, + }, + unfold: sprite(0, 4), + unfoldSounds: { + fold: 'Unit/CannonFold', + unfold: 'Unit/CannonUnfold', + }, + unfoldSprite: { + frames: 8, + position: sprite(0, 3), + }, + }, +); + +export const SuperAPU = new UnitInfo( + 52, + 'Super APU', + Brute, + 'male', + `The Super APU, the original version of the APU, is equipped with a powerful minigun and has higher defense compared to the regular APU. It can tear through enemy units with ease and requires a concerted effort to be taken down.`, + `{name} emerged from a war-torn upbringing to become a revered military figure known for solving problems with brute force. His legendary status was cemented during a standoff where he single-handedly defended against an enemy encampment. Despite his fearsome reputation, whispers circulate among his closest allies about his secretive support for war orphans—a stark contrast to his ruthless battlefield persona. {name} continues to navigate the fine line between brutal authority and unexpected compassion, leaving many to question his true intentions.`, + 30, + EntityType.Ground, + MovementTypes.HeavySoldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 60, + radius: 5, + vision: 4, + }, + DefaultUnitAbilities, + { + type: AttackType.ShortRange, + weapons: [Weapons.SuperMiniGun.withSupply(10)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-SuperAPU', + portrait: { + position: sprite(53, 0), + variants: 3, + }, + }, +); + +export const BazookaBear = new UnitInfo( + UnitID.BazookaBear, + 'Bazooka Bear', + 'Bazoo', + 'unknown', + `As an all-rounder, the Bazooka Bear can attack every other unit. Its low mobility is compensated by high long-range attack power. Many have asked: Why is it a bear with a bazooka? You'll have to ask the squad leader that question!`, + `After losing his partner in crime, {name} took up a bazooka to honor his fallen friend. Now, {name} has assembled a team of elite Bazooka Bears to avenge his friend's death. He is a loyal leader who will stop at nothing to protect those he cares about.`, + 10, + EntityType.Infantry, + MovementTypes.Soldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 100, + radius: 3, + vision: 2, + }, + DefaultUnitAbilities, + { + range: [1, 2], + type: AttackType.LongRange, + weapons: [Weapons.Bazooka.withSupply(5)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + leaderAlternative: true, + name: 'Units-BazookaBear', + portrait: { + position: sprite(31, 0), + variants: 3, + }, + slow: true, + }, +); + +export const AIU = new UnitInfo( + UnitID.AIU, + 'AIU', + 'Jaeger', + 'male', + `The AIU, or Artificial Intelligence Unit, is a highly advanced robot — possibly sent from another universe — to wreak havoc on the battlefield. It boasts extremely strong defense and is equipped with a powerful minigun.`, + `{name} is a killing machine through and through. He enjoys walks on the beach only when he can crush an opponent like the sand beneath his feet. We aren't sure if he is conscious or just stringing together random words to sound like he is, but he sure seems smarter than the average human.`, + 40, + EntityType.Infantry, + MovementTypes.HeavySoldier, + { + cost: Number.POSITIVE_INFINITY, + fuel: 60, + radius: 5, + vision: 4, + }, + DefaultUnitAbilitiesWithCapture, + { + type: AttackType.ShortRange, + weapons: [Weapons.AIUMiniGun.withSupply(10)], + }, + null, + { + attackStance: 'short', + directionOffset: 2, + explosionSprite: { + frames: 8, + position: sprite(7, 1), + }, + name: 'Units-AIU', + portrait: { + position: sprite(54, 0), + variants: 3, + }, + }, +); + +// The order of units must not be changed. +const Units = [ + Pioneer, + Infantry, + RocketLauncher, + APU, + SmallTank, + Jeep, + Artillery, + Battleship, + Helicopter, + Humvee, + AntiAir, + HeavyArtillery, + Lander, + Sniper, + Flamethrower, + Saboteur, + TransportHelicopter, + FighterJet, + Bomber, + Jetpack, + SeaPatrol, + AcidBomber, + Drone, + ReconDrone, + XFighter, + Medic, + AmphibiousTank, + Destroyer, + Frigate, + Hovercraft, + PatrolShip, + SupportShip, + Corvette, + Mammoth, + TransportTrain, + Dinosaur, + HeavyTank, + SuperTank, + HumveeAvenger, + ArtilleryHumvee, + SupplyTrain, + Truck, + Octopus, + Dragon, + Bear, + Alien, + Zombie, + Ogre, + Brute, + Commander, + Cannon, + SuperAPU, + BazookaBear, + AIU, +]; + +export const InitialAvailablePortraits = new Set([ + Pioneer, + Sniper, + Flamethrower, +]); + +export const SpecialUnits = new Set([ + Alien, + BazookaBear, + Bear, + Dinosaur, + Dragon, + Octopus, + Ogre, +]); + +export function getUnitInfo(id: number): UnitInfo | null { + return Units[id - 1] || null; +} + +export function getUnitInfoOrThrow(id: number): UnitInfo { + const unit = getUnitInfo(id); + if (!unit) { + throw new Error(`getUnitInfoOrThrow: Could not find unit with id '${id}'.`); + } + return unit; +} + +const units = Units.slice().sort((infoA, infoB) => { + if (infoA.movementType.sortOrder === infoB.movementType.sortOrder) { + return infoA.getCostFor(null) - infoB.getCostFor(null); + } + return infoA.movementType.sortOrder - infoB.movementType.sortOrder; +}); + +export function filterUnits( + fn: (unitInfo: UnitInfo) => boolean | undefined, +): ReadonlyArray { + return units.filter(fn); +} + +export function mapUnits( + fn: (unitInfo: UnitInfo, index: number) => T, +): Array { + return units.map(fn); +} + +export function mapUnitsWithContentRestriction( + fn: (unitInfo: UnitInfo, index: number) => T, + skills: ReadonlySet, +): Array { + return units + .filter( + (unit) => + (!SpecialUnits.has(unit) && + unit.getCostFor(null) < Number.POSITIVE_INFINITY) || + hasUnlockedUnit(unit, skills), + ) + .map(fn); +} + +export function getAllUnits(): ReadonlyArray { + return units; +} + +export function mapMovementTypes( + fn: (movementType: MovementType) => T, +): Array { + return ( + Object.keys(MovementTypes) as ReadonlyArray + ).map((key) => fn(MovementTypes[key])); +} + +export function mapWeapons(fn: (weapon: Weapon) => T): Array { + return (Object.keys(Weapons) as ReadonlyArray).map( + (key) => fn(Weapons[key]), + ); +} diff --git a/athena/info/UnitID.tsx b/athena/info/UnitID.tsx new file mode 100644 index 00000000..f2f6ac89 --- /dev/null +++ b/athena/info/UnitID.tsx @@ -0,0 +1,14 @@ +/* eslint-disable sort-keys-fix/sort-keys-fix */ +export default { + Pioneer: 1, + Infantry: 2, + Artillery: 7, + HeavyArtillery: 12, + Medic: 26, + Zombie: 47, + Brute: 49, + Cannon: 51, + BazookaBear: 53, + AIU: 54, +} as const; +/* eslint-enable sort-keys-fix/sort-keys-fix */ diff --git a/athena/info/UnitNames.tsx b/athena/info/UnitNames.tsx new file mode 100644 index 00000000..9e3bf9a9 --- /dev/null +++ b/athena/info/UnitNames.tsx @@ -0,0 +1,117 @@ +import random from '@deities/hephaestus/random.tsx'; +import _hasLeader from '../lib/hasLeader.tsx'; +import { PlayerID } from '../map/Player.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { Gender, UnitInfo } from './Unit.tsx'; + +const names = { + female: [ + 'Ava', + 'Calista', + 'Casandra', + 'Chihiro', + 'Daelia', + 'Eris', + 'Hannah', + 'Jane', + 'Kyra', + 'Laura', + 'Maria', + 'May', + 'Nyra', + 'Phoebe', + 'Rey', + 'Selene', + 'Sophia', + 'Vaela', + 'Wren', + 'Yara', + 'Zoe', + 'Chloe', + ], + male: [ + 'Alden', + 'Brock', + 'Calder', + 'Darian', + 'Demetrius', + 'Eamon', + 'Felix', + 'Finn', + 'Garrick', + 'Idris', + 'Joe', + 'Liam', + 'Nero', + 'Orion', + 'Paul', + 'Rick', + 'Sebastian', + 'Sylas', + 'Thane', + 'Yusuke', + 'Walter', + 'Emilio', + ], + unknown: [ + 'Sasha', + 'Avery', + 'Blair', + 'Cameron', + 'Casey', + 'Eden', + 'Ira', + 'Jess', + 'Jordan', + 'Kelly', + 'Lee', + 'Morgan', + 'Quinn', + 'Reese', + 'Skyler', + 'Taylor', + 'Uli', + 'Xan', + 'Yael', + 'Zephyr', + 'Kai', + 'Yan', + ], +}; + +const amount = Math.min( + names.male.length, + names.female.length, + names.unknown.length, +); + +export default function getUnitName(gender: Gender, name: number) { + return names[gender][name < 0 ? name * -1 - 1 : name]; +} + +export function generateUnitName(isLeader = false) { + const name = random(0, amount - 1); + return isLeader ? name * -1 - 1 : name; +} + +export function getDeterministicUnitName( + map: MapData, + vector: Vector, + player: PlayerID, + info: UnitInfo, + offset = 0, + hasLeader = _hasLeader(map, player, info), +) { + const units = map.units.filter((unit) => map.matchesPlayer(unit, player)); + const name = + (units + .map((unit) => unit.id * 11 + unit.health * 2 + unit.fuel * 3) + .reduce((sum, value) => sum + value, 0) + + vector.x * 5 + + vector.y * 7 + + info.id * 13 + + offset) % + amount; + return hasLeader ? name : name * -1 - 1; +} diff --git a/athena/info/__tests__/UnitNames.test.tsx b/athena/info/__tests__/UnitNames.test.tsx new file mode 100644 index 00000000..4ee26973 --- /dev/null +++ b/athena/info/__tests__/UnitNames.test.tsx @@ -0,0 +1,75 @@ +import { expect, test } from 'vitest'; +import { Flamethrower, Jeep, Pioneer, SmallTank } from '../../info/Unit.tsx'; +import withModifiers from '../../lib/withModifiers.tsx'; +import Unit from '../../map/Unit.tsx'; +import vec from '../../map/vec.tsx'; +import MapData from '../../MapData.tsx'; +import { getDeterministicUnitName } from '../UnitNames.tsx'; + +test('assignUnitNames` assigns unit names to all units', () => { + let map = withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, 1, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }), + ); + + map = map.copy({ + units: map.units + .set(vec(1, 1), Pioneer.create(1)) + .set(vec(2, 1), SmallTank.create(2)) + .set(vec(3, 1), Pioneer.create(1)) + .set(vec(1, 2), Flamethrower.create(2)) + .set(vec(2, 2), Pioneer.create(1)) + .set(vec(3, 2), Jeep.create(2)), + }); + + const vector = vec(1, 1); + expect(getDeterministicUnitName(map, vector, 1, Pioneer)).toEqual( + getDeterministicUnitName(map, vector, 1, Pioneer), + ); + expect(getDeterministicUnitName(map, vector, 2, Pioneer)).toEqual( + getDeterministicUnitName(map, vector, 2, Pioneer), + ); + + expect(getDeterministicUnitName(map, vector, 1, Pioneer)).not.toEqual( + getDeterministicUnitName(map, vec(2, 1), 1, Pioneer), + ); + + const map2 = map.copy({ + units: map.units.set(vec(1, 1), Flamethrower.create(1)), + }); + expect(getDeterministicUnitName(map, vector, 1, Pioneer)).not.toEqual( + getDeterministicUnitName(map2, vector, 1, Pioneer), + ); + expect(getDeterministicUnitName(map2, vector, 2, Pioneer)).toEqual( + getDeterministicUnitName(map2, vector, 2, Pioneer), + ); + + expect(getDeterministicUnitName(map, vector, 1, Pioneer)).not.toEqual( + getDeterministicUnitName(map, vector, 1, Pioneer, 1), + ); +}); + +test('does not lose unit names when encoding and decoding them', () => { + const pioneer = Pioneer.create(1, { name: 0 }); + const jeepA = Jeep.create(1, { name: 1 }).load(pioneer.transport()); + const jeepB = Unit.fromJSON(jeepA.toJSON()); + + expect(Unit.fromJSON(pioneer.toJSON()).getName()).toEqual(pioneer.getName()); + expect(Unit.fromJSON(pioneer.toJSON()).getName()).toEqual(pioneer.getName()); + expect(jeepA.getName()).toEqual(jeepB.getName()); + expect(jeepB.transports?.[0].getName()).toEqual(pioneer.getName()); +}); diff --git a/athena/lib/Modifier.tsx b/athena/lib/Modifier.tsx new file mode 100644 index 00000000..ef941112 --- /dev/null +++ b/athena/lib/Modifier.tsx @@ -0,0 +1,112 @@ +export enum Modifier { + None = 0, + Vertical = 1, + Horizontal = 2, + Single = 4, + // Crossings + HorizontalCrossing = 5, + VerticalCrossing = 6, + // Joinable + JoinableCenter = 7, + TopLeftCorner = 8, + BottomLeftCorner = 9, + TopRightCorner = 10, + BottomRightCorner = 11, + TRight = 12, + TLeft = 13, + TTop = 14, + TBottom = 15, + // Area + TailUp = 16, + TailDown = 17, + TailLeft = 18, + TailRight = 19, + Center = 20, + TopLeftAreaCorner = 21, + TopRightAreaCorner = 22, + BottomLeftAreaCorner = 23, + BottomRightAreaCorner = 24, + LeftWall = 25, + TopWall = 26, + BottomWall = 27, + RightWall = 28, + BottomRightEdge = 29, + TopRightEdge = 30, + BottomLeftEdge = 31, + TopLeftEdge = 32, + TopRightBottomLeftEdge = 33, + TopLeftBottomRightEdge = 34, + BottomLeftAndRightEdge = 35, + TopLeftAndRightEdge = 36, + TopRightBottomRightEdge = 37, + TopLeftBottomLeftEdge = 38, + TopRightIsArea = 39, + TopLeftIsArea = 40, + BottomRightIsArea = 41, + BottomLeftIsArea = 42, + + RightWallBottomLeftEdge = 43, + LeftWallBottomRightEdge = 44, + BottomWallRightTopEdge = 45, + TopWallBottomLeftEdge = 46, + BottomWallLeftTopEdge = 47, + LeftWallTopRightEdge = 48, + RightWallTopLeftEdge = 49, + TopWallRightBottomEdge = 50, + + RiverFlowsFromTop = 51, + RiverFlowsFromBottom = 52, + RiverFlowsFromLeft = 53, + RiverFlowsFromRight = 54, + + // Variants + Variant2 = 58, + Variant3 = 59, + + // Area Decorators + TopLeftAreaDecorator = 61, + TopRightAreaDecorator = 62, + BottomLeftAreaDecorator = 63, + BottomRightAreaDecorator = 64, + TopLeftBottomAreaDecorator = 65, + TopLeftRightAreaDecorator = 66, + TopRightBottomAreaDecorator = 67, + TopRightLeftAreaDecorator = 68, + BottomLeftTopAreaDecorator = 69, + BottomLeftRightAreaDecorator = 70, + BottomRightTopAreaDecorator = 71, + BottomRightLeftAreaDecorator = 72, + LeftWallAreaDecorator = 73, + TopWallAreaDecorator = 74, + BottomWallAreaDecorator = 75, + RightWallAreaDecorator = 76, + LeftWallTopAreaDecorator = 77, + LeftWallBottomAreaDecorator = 78, + TopWallLeftAreaDecorator = 79, + TopWallRightAreaDecorator = 80, + BottomWallLeftAreaDecorator = 81, + BottomWallRightAreaDecorator = 82, + RightWallTopAreaDecorator = 83, + RightWallBottomAreaDecorator = 84, + + Variant4 = 85, + Variant5 = 3, + Variant6 = 60, + Variant7 = 86, + + ConnectingTailUp = 87, + ConnectingTailDown = 88, + ConnectingTailLeft = 89, + ConnectingTailRight = 90, + + SingleConnectingTailUp = 91, + SingleConnectingTailDown = 92, + SingleConnectingTailLeft = 93, + SingleConnectingTailRight = 94, + VerticalSingle = 96, + + Unused1 = 55, + Unused2 = 56, + Unused3 = 57, + Unused4 = 95, +} diff --git a/athena/lib/__tests__/assignUnitNames.test.tsx b/athena/lib/__tests__/assignUnitNames.test.tsx new file mode 100644 index 00000000..e6697fc3 --- /dev/null +++ b/athena/lib/__tests__/assignUnitNames.test.tsx @@ -0,0 +1,132 @@ +import { expect, test } from 'vitest'; +import { Sea } from '../../info/Tile.tsx'; +import { + Flamethrower, + Hovercraft, + Jeep, + Jetpack, + Pioneer, + RocketLauncher, + Sniper, + Truck, +} from '../../info/Unit.tsx'; +import Unit, { TransportedUnit } from '../../map/Unit.tsx'; +import vec from '../../map/vec.tsx'; +import MapData from '../../MapData.tsx'; +import assignUnitNames from '../assignUnitNames.tsx'; +import withModifiers from '../withModifiers.tsx'; + +test('assignUnitNames` assigns unit names to all units', () => { + let map = withModifiers( + MapData.createMap({ + map: [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + Sea.id, + ], + size: { height: 5, width: 5 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }), + ); + + map = assignUnitNames( + map.copy({ + units: map.units + .set(vec(1, 1), Pioneer.create(1)) + .set(vec(2, 1), Pioneer.create(2)) + .set(vec(3, 1), Pioneer.create(1)) + .set(vec(4, 1), Flamethrower.create(1)) + .set(vec(1, 2), Pioneer.create(2)) + .set(vec(2, 2), Pioneer.create(1)) + .set(vec(3, 2), Pioneer.create(2)) + .set( + vec(4, 2), + Jeep.create(2).copy({ + transports: [Flamethrower.create(2).transport()], + }), + ) + .set(vec(1, 3), Sniper.create(2).withName(-1)) + .set(vec(2, 3), Sniper.create(1)) + .set(vec(3, 3), Sniper.create(1).withName(-1)) + .set(vec(4, 3), Flamethrower.create(2)) + .set( + vec(5, 5), + Hovercraft.create(2).copy({ + transports: [ + Truck.create(2) + .copy({ + transports: [ + Jetpack.create(2).transport(), + RocketLauncher.create(2).transport(), + ], + }) + .transport(), + ], + }), + ), + }), + ); + + expect( + map.units.filter((unit) => unit.info === Pioneer && unit.isLeader()).size, + ).toBe(2); + + const flatten = ( + unit: T, + ): ReadonlyArray => { + if (!unit.transports) { + return [unit]; + } + return unit.transports?.length + ? [unit, ...unit.transports.flatMap(flatten)] + : [unit]; + }; + + expect( + [...map.units.values()].flatMap(flatten).filter((unit) => unit.isLeader()) + .length, + ).toBe(11); + + expect(map.units.get(vec(1, 3))?.isLeader()).toBe(true); + expect(map.units.get(vec(3, 3))?.isLeader()).toBe(true); + + expect( + map.units.map((unit) => unit.getName(1)).filter((name) => !!name).size, + ).toEqual(map.units.size); + expect( + map.units.map((unit) => unit.getName(2)).filter((name) => !!name).size, + ).toEqual(map.units.size); +}); diff --git a/athena/lib/__tests__/calculateClusters.test.tsx b/athena/lib/__tests__/calculateClusters.test.tsx new file mode 100644 index 00000000..8c7cfb82 --- /dev/null +++ b/athena/lib/__tests__/calculateClusters.test.tsx @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; +import vec from '../../map/vec.tsx'; +import { SizeVector } from '../../MapData.tsx'; +import calculateClusters from '../calculateClusters.tsx'; + +test('calculateClusters` removes nearby clusters when there is a low cluster count', () => { + expect( + [ + ...calculateClusters(new SizeVector(10, 10), [vec(1, 1), vec(2, 2)]), + ].sort(), + ).toMatchInlineSnapshot(` + [ + [ + 1, + 1, + ], + ] + `); + + expect( + [ + ...calculateClusters(new SizeVector(400, 400), [ + vec(1, 1), + vec(2, 2), + vec(2, 3), + vec(1, 3), + vec(6, 6), + vec(5, 5), + vec(6, 6), + vec(10, 10), + vec(8, 10), + ]), + ].sort(), + ).toMatchInlineSnapshot(` + [ + [ + 1, + 1, + ], + [ + 10, + 10, + ], + [ + 6, + 6, + ], + ] + `); +}); diff --git a/athena/lib/__tests__/canDeploy.test.tsx b/athena/lib/__tests__/canDeploy.test.tsx new file mode 100644 index 00000000..b215ef51 --- /dev/null +++ b/athena/lib/__tests__/canDeploy.test.tsx @@ -0,0 +1,78 @@ +import { expect, test } from 'vitest'; +import { Barracks } from '../../info/Building.tsx'; +import { Skill } from '../../info/Skill.tsx'; +import { Sea } from '../../info/Tile.tsx'; +import { Infantry, Jeep, Pioneer } from '../../info/Unit.tsx'; +import vec from '../../map/vec.tsx'; +import MapData from '../../MapData.tsx'; +import canDeploy from '../canDeploy.tsx'; +import getDeployableVectors from '../getDeployableVectors.tsx'; +import updatePlayer from '../updatePlayer.tsx'; +import withModifiers from '../withModifiers.tsx'; + +const initialMap = withModifiers( + MapData.createMap({ + map: Array(15 * 15).fill(1), + size: { height: 15, width: 15 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }), +); + +test('`canDeploy` ensures that units can only be created on valid fields', () => { + const vecA = vec(3, 3); + const [vecB, vecC, vecD, vecE] = vecA.adjacent(); + const tileMap = initialMap.map.slice(); + tileMap[initialMap.getTileIndex(vecD)] = Sea.id; + const map = initialMap.copy({ + buildings: initialMap.buildings.set(vecA, Barracks.create(1)), + map: tileMap, + units: initialMap.units + .set(vecB, Pioneer.create(1)) + .set(vecC, Jeep.create(1)), + }); + + expect(canDeploy(map, Infantry, vecA, false)).toBeTruthy(); + expect(canDeploy(map, Infantry, vecB, false)).toBeFalsy(); + expect(canDeploy(map, Infantry, vecC, false)).toBeFalsy(); + expect(canDeploy(map, Infantry, vecD, false)).toBeFalsy(); + expect(canDeploy(map, Infantry, vecE, false)).toBeTruthy(); +}); + +test('restricted units can be created by using a skill', () => { + const vecA = vec(3, 3); + const map = initialMap.copy({ + buildings: initialMap.buildings.set(vecA, Barracks.create(1)), + config: initialMap.config.copy({ + blocklistedUnits: new Set([Infantry.id]), + }), + }); + + expect(getDeployableVectors(map, Infantry, vecA, 1).length).toEqual(0); + + expect( + getDeployableVectors( + map.copy({ + teams: updatePlayer( + initialMap.teams, + initialMap.getPlayer(1).copy({ + skills: new Set([Skill.NoUnitRestrictions]), + }), + ), + }), + Infantry, + vecA, + 1, + ).length, + ).toEqual(5); +}); diff --git a/athena/lib/__tests__/determineUnitsToCreate.test.tsx b/athena/lib/__tests__/determineUnitsToCreate.test.tsx new file mode 100644 index 00000000..10a79052 --- /dev/null +++ b/athena/lib/__tests__/determineUnitsToCreate.test.tsx @@ -0,0 +1,330 @@ +import { expect, test } from 'vitest'; +import { Barracks, Factory, House } from '../../info/Building.tsx'; +import { ConstructionSite } from '../../info/Tile.tsx'; +import { Infantry, Jeep, SmallTank, UnitInfo } from '../../info/Unit.tsx'; +import vec from '../../map/vec.tsx'; +import MapData from '../../MapData.tsx'; +import determineUnitsToCreate from '../determineUnitsToCreate.tsx'; +import withModifiers from '../withModifiers.tsx'; + +const format = (unitInfos: ReadonlyArray) => + unitInfos.map((info) => info.name).sort(); + +const map = withModifiers( + MapData.createMap({ + map: Array(15 * 15).fill(1), + size: { height: 15, width: 15 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }), +); +const player1 = map.getCurrentPlayer(); + +const options = { + canCreateBuildUnits: true, + canCreateCaptureUnits: true, + canCreateSupplyUnits: true, + canCreateTransportUnits: true, +}; +const barrackUnits = [...Barracks.create(1).getBuildableUnits(player1)]; +const factoryUnits = [...Factory.create(1).getBuildableUnits(player1)]; + +const getPlayerUnits = (map: MapData) => [ + ...map.units.filter((unit) => map.matchesPlayer(unit, player1)).values(), +]; + +test('`determineUnitsToCreate` builds Pioneers at the beginning of a game with Construction Sites', () => { + expect( + format( + determineUnitsToCreate( + map, + player1, + getPlayerUnits(map), + barrackUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Flamethrower", + "Infantry", + "Medic", + "Pioneer", + "Rocket Launcher", + "Saboteur", + "Sniper", + ] + `); + + expect( + format( + determineUnitsToCreate( + map.copy({ + map: map.map.slice().fill(ConstructionSite.id), + }), + player1, + getPlayerUnits(map), + barrackUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Pioneer", + ] + `); +}); + +test('`determineUnitsToCreate` builds Infantry, Rocket Launchers or Flamethrowers at the beginning of a game with neutral structures', () => { + expect( + format( + determineUnitsToCreate( + map.copy({ + buildings: map.buildings + .set(vec(1, 1), House.create(0)) + .set(vec(2, 1), House.create(0)) + .set(vec(3, 1), House.create(0)) + .set(vec(4, 1), House.create(0)) + .set(vec(5, 1), House.create(0)), + }), + player1, + getPlayerUnits(map), + barrackUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Flamethrower", + "Infantry", + "Pioneer", + "Rocket Launcher", + ] + `); +}); + +test('`determineUnitsToCreate` builds supply units when there are units with supply needs', () => { + const currentMap = map.copy({ + units: map.units + .set(vec(1, 1), SmallTank.create(1).setFuel(3)) + .set(vec(2, 1), SmallTank.create(1).setFuel(3)) + .set(vec(3, 1), SmallTank.create(1).setFuel(3)) + .set(vec(4, 1), SmallTank.create(1).setFuel(3)) + .set(vec(5, 1), SmallTank.create(1).setFuel(3)), + }); + expect( + format( + determineUnitsToCreate( + currentMap, + player1, + getPlayerUnits(currentMap), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Jeep", + "Supply Train", + ] + `); +}); + +test('`determineUnitsToCreate` does not build supply units when there are units with supply needs but there enough supply units', () => { + const currentMap = map.copy({ + units: map.units + .set(vec(1, 1), SmallTank.create(1).setFuel(3)) + .set(vec(2, 1), SmallTank.create(1).setFuel(3)) + .set(vec(3, 1), SmallTank.create(1).setFuel(3)) + .set(vec(4, 1), SmallTank.create(1).setFuel(3)) + .set(vec(5, 1), Jeep.create(1)), + }); + expect( + format( + determineUnitsToCreate( + currentMap, + player1, + getPlayerUnits(currentMap), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "APU", + "Anti Air", + "Artillery", + "Heavy Artillery", + "Heavy Tank", + "Humvee", + "Jeep", + "Mammoth", + "Small Tank", + "Supply Train", + "Transport Train", + ] + `); +}); + +test('`determineUnitsToCreate` builds transporters early in the game', () => { + const currentMap = map.copy({ + round: 3, + units: map.units + .set(vec(1, 1), Infantry.create(1)) + .set(vec(2, 1), Infantry.create(1)) + .set(vec(3, 1), Infantry.create(1)), + }); + expect( + format( + determineUnitsToCreate( + currentMap, + player1, + getPlayerUnits(currentMap), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Jeep", + "Transport Train", + ] + `); +}); + +test('`determineUnitsToCreate` does not build transporters all the time later in the game', () => { + // Does not build transporters in the first round. + const map1 = map.copy({ + round: 1, + units: map.units + .set(vec(1, 1), Infantry.create(1)) + .set(vec(2, 1), Infantry.create(1)) + .set(vec(3, 1), Infantry.create(1)), + }); + const map2 = map.copy({ + round: 7, + units: map.units + .set(vec(1, 1), Infantry.create(1)) + .set(vec(2, 1), Infantry.create(1)) + .set(vec(3, 1), Infantry.create(1)), + }); + const map3 = map.copy({ + round: 10, + units: map.units + .set(vec(1, 1), Infantry.create(1)) + .set(vec(2, 1), Infantry.create(1)) + .set(vec(3, 1), Infantry.create(1)), + }); + const map4 = map.copy({ + round: 13, + units: map.units + .set(vec(1, 1), Infantry.create(1)) + .set(vec(2, 1), Infantry.create(1)) + .set(vec(3, 1), Infantry.create(1)), + }); + + expect( + format( + determineUnitsToCreate( + map1, + player1, + getPlayerUnits(map1), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "APU", + "Anti Air", + "Artillery", + "Heavy Artillery", + "Heavy Tank", + "Humvee", + "Jeep", + "Mammoth", + "Small Tank", + "Supply Train", + "Transport Train", + ] + `); + + expect( + format( + determineUnitsToCreate( + map2, + player1, + getPlayerUnits(map2), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "APU", + "Anti Air", + "Artillery", + "Heavy Artillery", + "Heavy Tank", + "Humvee", + "Jeep", + "Mammoth", + "Small Tank", + "Supply Train", + "Transport Train", + ] + `); + + expect( + format( + determineUnitsToCreate( + map3, + player1, + getPlayerUnits(map3), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "Jeep", + "Transport Train", + ] + `); + + expect( + format( + determineUnitsToCreate( + map4, + player1, + getPlayerUnits(map4), + factoryUnits, + options, + ), + ), + ).toMatchInlineSnapshot(` + [ + "APU", + "Anti Air", + "Artillery", + "Heavy Artillery", + "Heavy Tank", + "Humvee", + "Jeep", + "Mammoth", + "Small Tank", + "Supply Train", + "Transport Train", + ] + `); +}); diff --git a/athena/lib/__tests__/startGame.test.tsx b/athena/lib/__tests__/startGame.test.tsx new file mode 100644 index 00000000..7139408f --- /dev/null +++ b/athena/lib/__tests__/startGame.test.tsx @@ -0,0 +1,127 @@ +import { expect, test } from 'vitest'; +import { Barracks, House, HQ } from '../../info/Building.tsx'; +import { Flamethrower, Hovercraft, Jeep, SmallTank } from '../../info/Unit.tsx'; +import vec from '../../map/vec.tsx'; +import MapData from '../../MapData.tsx'; +import startGame from '../startGame.tsx'; +import withModifiers from '../withModifiers.tsx'; + +test('`startGame` calculates the correct funds for all players', () => { + const map = withModifiers( + MapData.createMap({ + buildings: [ + [1, 1, HQ.create(1).toJSON()], + [5, 5, HQ.create(2).toJSON()], + [1, 2, House.create(1).toJSON()], + [1, 3, House.create(1).toJSON()], + [1, 4, House.create(2).toJSON()], + [1, 5, House.create(2).toJSON()], + [2, 1, Barracks.create(1).toJSON()], + ], + config: { + seedCapital: 10_000, + }, + map: [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, + ], + size: { height: 5, width: 5 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + }), + ); + + expect( + startGame(map) + .getPlayers() + .map(({ funds }) => funds), + ).toMatchInlineSnapshot(` + [ + 10200, + 10000, + ] + `); +}); + +test('`startGame` resets fuel or ammo that is beyond the maximum', () => { + const flamethrowerFuel = Math.floor(Flamethrower.configuration.fuel / 2); + const tank = SmallTank.create(2); + const map = startGame( + withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, 1, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2, name: 'AI' }], + }, + ], + units: [ + [1, 1, Jeep.create(1).copy({ fuel: 3000 }).toJSON()], + [1, 2, Flamethrower.create(1).setFuel(0).toJSON()], + [ + 1, + 3, + Hovercraft.create(1) + .load( + Jeep.create(1) + .copy({ fuel: 3000 }) + .load(Flamethrower.create(1).copy({ fuel: 1000 }).transport()) + .transport(), + ) + .toJSON(), + ], + [2, 2, Flamethrower.create(2).setFuel(flamethrowerFuel).toJSON()], + [3, 1, tank.subtractAmmo(tank.getAttackWeapon(tank)!, 2).toJSON()], + [ + 3, + 3, + SmallTank.create(2) + .setAmmo(new Map([[1, 1000]])) + .toJSON(), + ], + ], + }), + ), + ); + expect(map.units.get(vec(1, 1))!.fuel).toEqual(Jeep.configuration.fuel); + expect(map.units.get(vec(1, 2))!.fuel).toEqual(0); + expect(map.units.get(vec(2, 2))!.fuel).toEqual(flamethrowerFuel); + expect(map.units.get(vec(3, 1))!.ammo).toMatchInlineSnapshot(` + Map { + 1 => 5, + } + `); + expect(map.units.get(vec(3, 3))!.ammo).toMatchInlineSnapshot(` + Map { + 1 => 7, + } + `); + + const hovercraft = map.units.get(vec(1, 3))!; + const jeep = hovercraft.getTransportedUnit(0)!.deploy(); + const flamethrower = jeep.getTransportedUnit(0)!.deploy(); + + expect(jeep.id).toEqual(Jeep.id); + expect(jeep.fuel).toEqual(Jeep.configuration.fuel); + + expect(flamethrower.id).toEqual(Flamethrower.id); + expect(flamethrower.fuel).toEqual(Flamethrower.configuration.fuel); +}); diff --git a/athena/lib/__tests__/validateTeams.test.tsx b/athena/lib/__tests__/validateTeams.test.tsx new file mode 100644 index 00000000..50581c7a --- /dev/null +++ b/athena/lib/__tests__/validateTeams.test.tsx @@ -0,0 +1,199 @@ +import { expect, test } from 'vitest'; +import { toPlayerID } from '../../map/Player.tsx'; +import { PlainTeams, toTeamArray } from '../../map/Team.tsx'; +import MapData from '../../MapData.tsx'; +import validateTeams from '../validateTeams.tsx'; +import withModifiers from '../withModifiers.tsx'; + +const defaultTeams: PlainTeams = [ + { id: 1, name: '', players: [{ funds: 0, id: 1 }] }, + { id: 3, name: '', players: [{ funds: 0, id: 2 }] }, + { id: 3, name: '', players: [{ funds: 0, id: 3 }] }, + { id: 4, name: '', players: [{ funds: 0, id: 4 }] }, +]; + +const getTeams = (teams?: PlainTeams): PlainTeams => { + const seen = new Set(teams?.map(({ id }) => id)); + return [ + ...(teams || []), + ...defaultTeams.filter(({ id }) => !seen.has(toPlayerID(id))), + ]; +}; + +test('validates teams', () => { + let map = withModifiers( + MapData.createMap({ + active: [1, 2, 3, 4], + teams: getTeams([ + { + id: 1, + name: '', + players: [ + { funds: 0, id: 1 }, + { funds: 0, id: 2 }, + ], + }, + { + id: 2, + name: '', + players: [ + { funds: 0, id: 2 }, + { funds: 0, id: 3 }, + { funds: 0, id: 4 }, + ], + }, + ]), + }), + ); + + expect( + validateTeams(map, toTeamArray(map.teams))[0]?.teams.toArray(), + ).toBeUndefined(); + + map = withModifiers( + MapData.createMap({ + active: [1, 2, 3, 4], + teams: getTeams([ + { + id: 2, + name: '', + players: [{ funds: 0, id: 2 }], + }, + ]), + }), + ); + + expect(validateTeams(map, toTeamArray(map.teams))[0]?.teams.toArray()) + .toMatchInlineSnapshot(` + [ + [ + 1, + { + "id": 1, + "name": "", + "players": [ + { + "funds": 0, + "id": 1, + "skills": [], + }, + ], + }, + ], + [ + 2, + { + "id": 2, + "name": "", + "players": [ + { + "funds": 0, + "id": 2, + "skills": [], + }, + ], + }, + ], + [ + 3, + { + "id": 3, + "name": "", + "players": [ + { + "funds": 0, + "id": 3, + "skills": [], + }, + ], + }, + ], + [ + 4, + { + "id": 4, + "name": "", + "players": [ + { + "funds": 0, + "id": 4, + "skills": [], + }, + ], + }, + ], + ] + `); + + map = withModifiers( + MapData.createMap({ + active: [1, 2], + teams: [ + { + id: 1, + name: '', + players: [{ funds: 0, id: 1 }], + }, + { + id: 2, + name: '', + players: [{ funds: 0, id: 2 }], + }, + ], + }), + ); + + expect(validateTeams(map, toTeamArray(map.teams))[0]?.teams.toArray()) + .toMatchInlineSnapshot(` + [ + [ + 1, + { + "id": 1, + "name": "", + "players": [ + { + "funds": 0, + "id": 1, + "skills": [], + }, + ], + }, + ], + [ + 2, + { + "id": 2, + "name": "", + "players": [ + { + "funds": 0, + "id": 2, + "skills": [], + }, + ], + }, + ], + ] + `); + + map = withModifiers( + MapData.createMap({ + active: [1, 2], + teams: [ + { + id: 1, + name: '', + players: [ + { funds: 0, id: 1 }, + { funds: 0, id: 2 }, + ], + }, + ], + }), + ); + + expect( + validateTeams(map, toTeamArray(map.teams))[0]?.teams.toArray(), + ).toBeUndefined(); +}); diff --git a/athena/lib/assignUnitNames.tsx b/athena/lib/assignUnitNames.tsx new file mode 100644 index 00000000..f6c9c081 --- /dev/null +++ b/athena/lib/assignUnitNames.tsx @@ -0,0 +1,31 @@ +import { generateUnitName } from '../info/UnitNames.tsx'; +import Unit, { TransportedUnit } from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; +import getLeaders from './getLeaders.tsx'; + +export default function assignUnitNames(map: MapData): MapData { + const { addLeader, hasLeader } = getLeaders(map); + + const assignName = (unit: T): T => { + if (!unit.hasName()) { + const isLeader = unit.player > 0 && !hasLeader(unit.player, unit.id); + const name = generateUnitName(isLeader); + if (isLeader) { + addLeader(unit.player, unit.id); + } + unit = unit.withName(name) as T; + } + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map(assignName), + }) as T; + } + + return unit; + }; + + return map.copy({ + units: map.units.map(assignName), + }); +} diff --git a/athena/lib/calculateClusters.tsx b/athena/lib/calculateClusters.tsx new file mode 100644 index 00000000..cd03b507 --- /dev/null +++ b/athena/lib/calculateClusters.tsx @@ -0,0 +1,58 @@ +import skmeans from 'skmeans'; +import Vector from '../map/Vector.tsx'; +import { SizeVector } from '../MapData.tsx'; +import vec from './../map/vec.tsx'; + +const getClusterCount = (size: SizeVector, max: number) => + Math.min( + max, + Math.ceil(3 + ((size.height * size.width) / /* scale */ 200) ** 1.2), + ); + +export default function calculateClusters( + size: SizeVector, + vectors: ReadonlyArray, + maxClusters = 10, +): ReadonlyArray { + const count = getClusterCount(size, maxClusters); + if (vectors.length <= count) { + if (vectors.length === 1) { + return vectors; + } + + const fields = new Set(); + return [ + ...new Set( + vectors.filter((vector) => { + if (fields.has(vector)) { + return false; + } + + fields.add(vector); + for (const adjacent of vector + .adjacentStar() + .flatMap((vector) => vector.adjacent())) { + fields.add(adjacent); + } + return true; + }), + ), + ]; + } + + return skmeans( + vectors.map((vector) => [vector.x, vector.y]), + count, + 'kmpp', + 10, + ([xA, yA], [xB, yB]) => Math.abs(xA - xB) + Math.abs(yA - yB), + ) + .centroids.map((centroid) => { + if (Array.isArray(centroid)) { + return vec(Math.round(centroid[0]), Math.round(centroid[1])); + } + // Make TypeScript happy. + return vec(-1, -1); + }) + .filter((vector) => vector && size.contains(vector)); +} diff --git a/athena/lib/calculateDamage.tsx b/athena/lib/calculateDamage.tsx new file mode 100644 index 00000000..2a6bac00 --- /dev/null +++ b/athena/lib/calculateDamage.tsx @@ -0,0 +1,25 @@ +import { Weapon } from '../info/Unit.tsx'; +import { MaxHealth, MinDamage } from '../map/Configuration.tsx'; +import Entity, { isEntityWithoutCover } from '../map/Entity.tsx'; +import Unit from '../map/Unit.tsx'; + +export default function calculateDamage( + unitA: Unit, + entityB: Entity, + weapon: Weapon, + coverA: number, + coverB: number, + attackStatusEffect: number, + defenseStatusEffect: number, + luckA: number, +): number { + const health = 0.666 * (unitA.health / MaxHealth) + 0.334; + const offenseA = weapon.getDamage(entityB) * attackStatusEffect; + const defenseB = entityB.info.defense * defenseStatusEffect; + const coverA2 = isEntityWithoutCover(unitA) ? 1 : 1 + coverA / 400; + const coverB2 = isEntityWithoutCover(entityB) ? 1 : 1 + coverB / 100; + return Math.max( + MinDamage, + health * offenseA * coverA2 * luckA - defenseB * coverB2, + ); +} diff --git a/athena/lib/calculateEmptyClusters.tsx b/athena/lib/calculateEmptyClusters.tsx new file mode 100644 index 00000000..f585d30e --- /dev/null +++ b/athena/lib/calculateEmptyClusters.tsx @@ -0,0 +1,27 @@ +import MapData from '../MapData.tsx'; +import calculateClusters from './calculateClusters.tsx'; + +export default function calculateEmptyClusters(map: MapData) { + const movementTypes = [ + ...new Set(map.units.map((unit) => unit.info.movementType).values()), + ]; + + const occupied = new Set( + [...map.units.keys(), ...map.buildings.keys()].flatMap((vector) => + vector.expandWithDiagonals(), + ), + ); + return calculateClusters( + map.size, + map + .mapFields((vector) => vector) + .filter( + (vector) => + !occupied.has(vector) && + movementTypes.some( + (movementType) => + map.getTileInfo(vector).getMovementCost({ movementType }) !== -1, + ), + ), + ); +} diff --git a/athena/lib/calculateFunds.tsx b/athena/lib/calculateFunds.tsx new file mode 100644 index 00000000..dcc90105 --- /dev/null +++ b/athena/lib/calculateFunds.tsx @@ -0,0 +1,25 @@ +import Player, { PlayerID } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; + +export default function calculateFunds( + map: MapData, + player: Player | PlayerID, +): number { + return ( + map.buildings.reduce( + (sum, building) => + sum + + (map.matchesPlayer(player, building) + ? building.info.configuration.funds + : 0), + 0, + ) * map.config.multiplier + ); +} + +export function calculateTotalPossibleFunds(map: MapData): number { + return map.buildings.reduce( + (sum, building) => sum + building.info.configuration.funds, + 0, + ); +} diff --git a/athena/lib/calculateLikelyDamage.tsx b/athena/lib/calculateLikelyDamage.tsx new file mode 100644 index 00000000..2096b5a5 --- /dev/null +++ b/athena/lib/calculateLikelyDamage.tsx @@ -0,0 +1,40 @@ +import { BuildingCover, MinDamage } from '../map/Configuration.tsx'; +import Entity from '../map/Entity.tsx'; +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import calculateDamage from './calculateDamage.tsx'; + +export default function calculateLikelyDamage( + unitA: Unit, + entityB: Entity, + map: MapData, + from: Vector, + to: Vector, + attackStatusEffect: number, + defenseStatusEffect: number, + modifier: number, + weapon = unitA.getAttackWeapon(entityB), +): number | null { + if (weapon) { + return Math.floor( + Math.max( + MinDamage, + calculateDamage( + unitA, + entityB, + weapon, + map.getTileInfo(from).configuration.cover + + (map.buildings.has(from) ? BuildingCover : 0), + map.getTileInfo(to).configuration.cover + + (map.buildings.has(to) ? BuildingCover : 0), + attackStatusEffect, + defenseStatusEffect, + 1, + ) * modifier, + ), + ); + } + + return null; +} diff --git a/athena/lib/canBuild.tsx b/athena/lib/canBuild.tsx new file mode 100644 index 00000000..832c405f --- /dev/null +++ b/athena/lib/canBuild.tsx @@ -0,0 +1,25 @@ +import { BuildingInfo } from '../info/Building.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData, { PlayerOrPlayerID } from '../MapData.tsx'; + +export default function canBuild( + map: MapData, + info: BuildingInfo, + player: PlayerOrPlayerID, + to: Vector, + isEditor = false, +): boolean { + const tile = map.getTileInfo(to); + return !!( + tile && + (isEditor || info.configuration.canBeCreated) && + (info.canBeCreatedOn(tile) || + (isEditor && info.editorCanBeCreatedOn(tile))) && + !map.config.blocklistedBuildings.has(info.id) && + (info.configuration.limit === 0 || + map.buildings.filter( + (building) => + building.id == info.id && map.matchesPlayer(building, player), + ).size < info.configuration.limit) + ); +} diff --git a/athena/lib/canDeploy.tsx b/athena/lib/canDeploy.tsx new file mode 100644 index 00000000..2b489044 --- /dev/null +++ b/athena/lib/canDeploy.tsx @@ -0,0 +1,19 @@ +import { UnitInfo } from '../info/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function canDeploy( + map: MapData, + unit: UnitInfo, + to: Vector, + ignoreBlocklist: boolean, +) { + const building = map.buildings.get(to); + return ( + map.contains(to) && + map.getTileInfo(to).getMovementCost(unit) !== -1 && + !map.units.get(to) && + (ignoreBlocklist || !map.config.blocklistedUnits.has(unit.id)) && + (!building || building.info.isAccessibleBy(unit)) + ); +} diff --git a/athena/lib/canLoad.tsx b/athena/lib/canLoad.tsx new file mode 100644 index 00000000..ee0c0616 --- /dev/null +++ b/athena/lib/canLoad.tsx @@ -0,0 +1,16 @@ +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function canLoad( + map: MapData, + maybeTransporter: Unit, + unitA: Unit, + vector: Vector, +) { + return ( + map.matchesPlayer(unitA, maybeTransporter) && + maybeTransporter.info.canTransport(unitA.info, map.getTileInfo(vector)) && + !maybeTransporter.isFull() + ); +} diff --git a/athena/lib/canPlaceDecorator.tsx b/athena/lib/canPlaceDecorator.tsx new file mode 100644 index 00000000..6652a242 --- /dev/null +++ b/athena/lib/canPlaceDecorator.tsx @@ -0,0 +1,16 @@ +import { getDecorator } from '../info/Decorator.tsx'; +import { getDecoratorLimit } from '../map/Configuration.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function canPlaceDecorator( + map: MapData, + vector: Vector, + decorator: number, + _getDecoratorLimit = getDecoratorLimit, +): boolean { + return !!( + getDecorator(decorator)?.placeOn.has(map.getTileInfo(vector)) && + map.reduceEachDecorator((sum) => sum + 1, 0) < _getDecoratorLimit(map.size) + ); +} diff --git a/athena/lib/canPlaceLightning.tsx b/athena/lib/canPlaceLightning.tsx new file mode 100644 index 00000000..c005722c --- /dev/null +++ b/athena/lib/canPlaceLightning.tsx @@ -0,0 +1,16 @@ +import { Lightning, StormCloud } from '../info/Tile.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import canPlaceTile from './canPlaceTile.tsx'; + +export default function canPlaceLightning(map: MapData, vector: Vector) { + const tile = map.getTileInfo(vector); + const unit = map.units.get(vector); + return ( + tile !== StormCloud && + tile !== Lightning && + !map.buildings.has(vector) && + (!unit || !map.matchesPlayer(map.getCurrentPlayer(), unit)) && + canPlaceTile(map, vector, Lightning) + ); +} diff --git a/athena/lib/canPlaceRailTrack.tsx b/athena/lib/canPlaceRailTrack.tsx new file mode 100644 index 00000000..053b18d8 --- /dev/null +++ b/athena/lib/canPlaceRailTrack.tsx @@ -0,0 +1,31 @@ +import { Plain, RailBridge, RailTrack, River } from '../info/Tile.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import canPlaceTile from './canPlaceTile.tsx'; + +export default function canPlaceRailTrack(map: MapData, vector: Vector) { + if (map.buildings.has(vector)) { + return false; + } + + const tile = map.getTileInfo(vector); + const filter = (vector: Vector) => + map.contains(vector) && map.getTileInfo(vector) === RailTrack; + if (tile.id === Plain.id) { + return ( + canPlaceTile(map, vector, RailTrack) && vector.adjacent().some(filter) + ); + } + + if (tile.id === River.id) { + const vertical = [vector.up(), vector.down()].filter(filter); + const horizontal = [vector.left(), vector.right()].filter(filter); + return ( + canPlaceTile(map, vector, RailBridge) && + ((vertical.length === 2 && horizontal.length === 0) || + (horizontal.length === 2 && vertical.length === 0)) + ); + } + + return false; +} diff --git a/athena/lib/canPlaceTile.tsx b/athena/lib/canPlaceTile.tsx new file mode 100644 index 00000000..a8fd9226 --- /dev/null +++ b/athena/lib/canPlaceTile.tsx @@ -0,0 +1,213 @@ +import { + Beach, + DeepSea, + getTileInfo, + Iceberg, + Island, + isSea, + Lightning, + MaybeTileID, + Pier, + Pipe, + PoisonSwamp, + Reef, + River, + Sea, + ShipyardConstructionSite, + StormCloud, + TileInfo, + TileTypes, + Trench, + Weeds, +} from '../info/Tile.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import getBiomeStyle from './getBiomeStyle.tsx'; +import getModifier, { getWaterfallModifier } from './getModifier.tsx'; +import { Modifier } from './Modifier.tsx'; + +// The order matches `vector.adjacent()`. +const PierModifiers = [ + new Set([Modifier.SingleConnectingTailUp, Modifier.ConnectingTailUp]), + new Set([Modifier.SingleConnectingTailRight, Modifier.ConnectingTailRight]), + new Set([Modifier.SingleConnectingTailDown, Modifier.ConnectingTailDown]), + new Set([Modifier.SingleConnectingTailLeft, Modifier.ConnectingTailLeft]), +]; + +const isRiver = (tile: MaybeTileID) => !tile || tile === River.id; +const isTrench = (tile: MaybeTileID) => !tile || tile === Trench.id; + +const canPlacePier = (map: MapData, vector: Vector, modifier: Modifier) => { + if (!(map.getTileInfo(vector, 0).type & TileTypes.Sea)) { + return false; + } + + if ( + vector.adjacent().some((vector, index) => { + const tile = map.getTile(vector, 0); + return ( + tile && + !(getTileInfo(tile).type & TileTypes.Sea) && + !PierModifiers[index].has(modifier) + ); + }) + ) { + return false; + } + return true; +}; + +export default function canPlaceTile( + map: MapData, + vector: Vector, + tile: TileInfo, +): boolean { + if ( + !map.contains(vector) || + getBiomeStyle(map.config.biome).tileRestrictions?.has(tile) + ) { + return false; + } + + const { layer } = tile.style; + if ( + tile === Reef || + tile === Island || + tile === Iceberg || + tile === Weeds || + tile == PoisonSwamp + ) { + if ( + tile === Island && + map.getTileInfo(vector, 0).type & TileTypes.DeepSea + ) { + return false; + } + + return vector + .expandWithDiagonals() + .every( + (vector) => + !map.getTile(vector, 0) || + map.getTileInfo(vector, 0).type & TileTypes.Sea, + ); + } + + if (tile === Lightning) { + const [up, right, down, left] = vector + .adjacent() + .map((vector) => map.getTile(vector, 1) === StormCloud.id); + + return (up && down && !left && !right) || (left && right && !up && !down); + } + + if (tile === DeepSea) { + return vector.adjacentWithDiagonals().every((vector) => { + const tile = map.getTile(vector, 0); + return !tile || isSea(tile) || tile === DeepSea.id; + }); + } + + if (tile === Beach) { + const currentTile = map.getTileInfo(vector, layer); + return ( + (currentTile === Sea || currentTile === Beach) && + tile.sprite.modifiers.has(map.getModifier(vector, layer)) + ); + } + + if (tile === StormCloud || tile === Pier || tile === Pipe) { + const modifier = getModifier(map, vector, tile, layer); + return tile === Pier + ? canPlacePier(map, vector, modifier) + : tile.sprite.modifiers.has(modifier); + } + + if (tile === ShipyardConstructionSite) { + const layer0Tile = map.getTileInfo(vector, 0); + const currentTile = map.getTile(vector, layer); + return currentTile && + getTileInfo(currentTile).type & TileTypes.Pier && + layer0Tile.type & TileTypes.Sea && + canPlacePier(map, vector, getModifier(map, vector, tile, layer)) + ? tile.sprite.modifiers.has(map.getModifier(vector, layer)) + : false; + } + + if (tile.type & TileTypes.Bridge && tile.style.connectsWith) { + const connectionTile = tile.style.connectsWith; + const isConnectedWith = (tile: MaybeTileID) => tile === connectionTile.id; + const isConnectedType = (tile: MaybeTileID) => + !!(tile && getTileInfo(tile).group & connectionTile.type); + + const currentTile = map.getTileInfo(vector, 0); + const [up, right, down, left] = vector.adjacent().map((vector) => { + if (!map.contains(vector)) { + return null; + } + const layer1Tile = map.contains(vector) && map.getTile(vector, 1); + if (isConnectedType(layer1Tile)) { + return layer1Tile; + } + + // For the purpose of placing bridges, consider waterfalls as rivers. + const layer0Tile = map.getTileInfo(vector, 0); + return getWaterfallModifier(map, vector, layer0Tile, 0) + ? River.id + : layer0Tile.id; + }); + const horizontal = [left, right].every(isConnectedWith); + const vertical = [up, down].every(isConnectedWith); + if (currentTile === River) { + return ( + (horizontal && [up, down].every(isRiver)) || + (vertical && [left, right].every(isRiver)) + ); + } else if (currentTile === Trench) { + return ( + (horizontal && [up, down].every(isTrench)) || + (vertical && [left, right].every(isTrench)) + ); + } else if (currentTile.type & TileTypes.Sea) { + if ( + (horizontal && [up, down].every(isSea)) || + (vertical && [left, right].every(isSea)) + ) { + return true; + } + + if ( + (isConnectedWith(up) || + [up, map.getTile(vector.up(2))].every(isConnectedType)) && + ![left, right].some(isConnectedType) + ) { + return true; + } + if ( + (isConnectedWith(down) || + [down, map.getTile(vector.down(2))].every(isConnectedType)) && + ![left, right].some(isConnectedType) + ) { + return true; + } + if ( + (isConnectedWith(left) || + [left, map.getTile(vector.left(2))].every(isConnectedType)) && + ![up, down].some(isConnectedType) + ) { + return true; + } + if ( + (isConnectedWith(right) || + [right, map.getTile(vector.right(2))].every(isConnectedType)) && + ![up, down].some(isConnectedType) + ) { + return true; + } + } + + return false; + } + + return true; +} diff --git a/athena/lib/convertBiome.tsx b/athena/lib/convertBiome.tsx new file mode 100644 index 00000000..be88e497 --- /dev/null +++ b/athena/lib/convertBiome.tsx @@ -0,0 +1,44 @@ +import { TileInfo } from '../info/Tile.tsx'; +import { Biome } from '../map/Biome.tsx'; +import MapData from '../MapData.tsx'; +import writeTile from '../mutation/writeTile.tsx'; +import getBiomeStyle from './getBiomeStyle.tsx'; +import { verifyMap } from './verifyTiles.tsx'; +import withModifiers from './withModifiers.tsx'; + +export default function convertBiome(map: MapData, biome: Biome) { + const fromBiome = getBiomeStyle(map.config.biome); + const toBiome = getBiomeStyle(biome); + + const reversedConversions = new Map(); + if (fromBiome.tileConversions) { + for (const [from, to] of fromBiome.tileConversions) { + if (!reversedConversions.has(to)) { + reversedConversions.set(to, from); + } + } + } + + map = map.copy({ config: map.config.copy({ biome }) }); + const newMap = map.map.slice(); + const newModifiers = map.modifiers.slice(); + + map.forEachTile((vector, tile, layer) => { + const reversedTile = reversedConversions.get(tile); + let newTile = reversedTile || tile; + const toTile = toBiome.tileConversions?.get(newTile); + newTile = toTile || newTile; + writeTile(newMap, newModifiers, map.getTileIndex(vector), newTile, layer); + }); + + map = verifyMap( + withModifiers(map.copy({ map: newMap, modifiers: newModifiers })), + ); + + return map.copy({ + units: map.units.filter( + (unit, vector) => + map.getTileInfo(vector).getMovementCost(unit.info) !== -1, + ), + }); +} diff --git a/athena/lib/determineUnitsToCreate.tsx b/athena/lib/determineUnitsToCreate.tsx new file mode 100644 index 00000000..070b8ae9 --- /dev/null +++ b/athena/lib/determineUnitsToCreate.tsx @@ -0,0 +1,131 @@ +import { PotentialUnitAbilities } from '../../dionysus/lib/getPossibleUnitAbilities.tsx'; +import needsSupply from '../../dionysus/lib/needsSupply.tsx'; +import { filterBuildings, MinFunds } from '../info/Building.tsx'; +import { Ability, UnitInfo } from '../info/Unit.tsx'; +import { getEntityInfoGroup } from '../map/Entity.tsx'; +import Player, { PlayerID } from '../map/Player.tsx'; +import Unit from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; +import calculateFunds, { + calculateTotalPossibleFunds, +} from './calculateFunds.tsx'; + +const buildableTiles = new Set( + filterBuildings( + ({ configuration: { canBeCreated } }) => canBeCreated, + ).flatMap(({ configuration: { placeOn } }) => (placeOn ? [...placeOn] : [])), +); + +export default function determineUnitsToCreate( + map: MapData, + currentPlayer: Player | PlayerID, + playerUnits: ReadonlyArray, + buildableUnits: ReadonlyArray, + { + canCreateBuildUnits, + canCreateCaptureUnits, + canCreateSupplyUnits, + canCreateTransportUnits, + }: PotentialUnitAbilities = { + canCreateBuildUnits: true, + canCreateCaptureUnits: true, + canCreateSupplyUnits: true, + canCreateTransportUnits: true, + }, +): ReadonlyArray { + if (!buildableUnits.length) { + return []; + } + + const availableTiles = map.reduceEachField( + (sum, vector) => + sum + + (buildableTiles.has(map.getTileInfo(vector)) && !map.buildings.has(vector) + ? 1 + : 0), + 0, + ); + const structures = map.buildings.filter( + (building) => map.isNeutral(building) && !building.info.isStructure(), + ); + + const totalFunds = calculateTotalPossibleFunds(map); + const minUnitsWithCaptureAbility = + Math.max(structures.size, totalFunds / MinFunds) * 0.2; + const unitsWithCreateBuildingsAbility = playerUnits.filter((unit) => + unit.info.hasAbility(Ability.CreateBuildings), + ); + const shouldBuildCaptureUnits = + calculateFunds(map, currentPlayer) / totalFunds < 0.4 / map.active.length && + playerUnits.filter((unit) => unit.info.hasAbility(Ability.Capture)).length < + minUnitsWithCaptureAbility; + + const unitsWithSupplyNeeds = playerUnits.filter(needsSupply); + const entitiesWithSupplyNeeds = new Set( + unitsWithSupplyNeeds.map(({ info }) => info.type), + ); + const unitsWithSupplyAbility = playerUnits.filter((unit) => + unit.info.hasAbility(Ability.Supply), + ); + + if ( + canCreateSupplyUnits && + unitsWithSupplyNeeds.length && + unitsWithSupplyAbility.length <= playerUnits.length * 0.05 && + buildableUnits.some( + (info) => + info.hasAbility(Ability.Supply) && + Array.from(info.configuration.supplyTypes || []).some((type) => + entitiesWithSupplyNeeds.has(type), + ), + ) + ) { + return buildableUnits.filter((info) => info.hasAbility(Ability.Supply)); + // If there are many neutral buildings, prefer building units that can capture. + // Otherwise, prefer units that can build buildings, if there is space. + } else if ( + (canCreateBuildUnits || canCreateCaptureUnits) && + (availableTiles || structures.size) && + (map.round <= 2 || + (map.round > 4 && !(map.round % 4)) || + (!unitsWithCreateBuildingsAbility.length && availableTiles > 3)) && + (shouldBuildCaptureUnits || + buildableUnits.some( + (info) => + info.hasAbility(Ability.Capture) || + info.hasAbility(Ability.CreateBuildings), + )) + ) { + if ( + playerUnits.filter(({ info }) => info.hasAbility(Ability.Capture)) + .length < + structures.size * 0.3 && + (map.round <= 2 || + buildableUnits.some((info) => info.hasAbility(Ability.Capture))) + ) { + return buildableUnits.filter((info) => info.hasAbility(Ability.Capture)); + } + + if ( + unitsWithCreateBuildingsAbility.length < availableTiles && + (map.round <= 2 || + buildableUnits.some((info) => info.hasAbility(Ability.CreateBuildings))) + ) { + return buildableUnits.filter((info) => + info.hasAbility(Ability.CreateBuildings), + ); + } + } else if ( + canCreateTransportUnits && + ((map.round >= 3 && map.round <= 4) || + (map.round > 5 && !(map.round % 5))) && + (map.size.width >= 15 || + map.size.height >= 15 || + buildableUnits.some((info) => getEntityInfoGroup(info) === 'naval')) && + playerUnits.filter(({ info }) => info.canTransportUnits()).length < + playerUnits.length * 0.15 + ) { + return buildableUnits.filter((info) => info.canTransportUnits()); + } + return buildableUnits; +} diff --git a/athena/lib/dropInactivePlayers.tsx b/athena/lib/dropInactivePlayers.tsx new file mode 100644 index 00000000..103e6908 --- /dev/null +++ b/athena/lib/dropInactivePlayers.tsx @@ -0,0 +1,16 @@ +import MapData from '../MapData.tsx'; +import getActivePlayers from './getActivePlayers.tsx'; + +export default function dropInactivePlayers(map: MapData) { + const active = new Set(getActivePlayers(map)); + return map.copy({ + active: [...active], + teams: map.teams + .map((team) => + team.copy({ + players: team.players.filter(({ id }) => active.has(id)), + }), + ) + .filter((team) => team.players.size), + }); +} diff --git a/athena/lib/dropLabels.tsx b/athena/lib/dropLabels.tsx new file mode 100644 index 00000000..4cff4911 --- /dev/null +++ b/athena/lib/dropLabels.tsx @@ -0,0 +1,12 @@ +import MapData from '../MapData.tsx'; +import { getHiddenLabels } from '../WinConditions.tsx'; + +export default function dropLabels(map: MapData) { + const labels = getHiddenLabels(map.config.winConditions); + return labels?.size + ? map.copy({ + buildings: map.buildings.map((building) => building.dropLabel(labels)), + units: map.units.map((unit) => unit.dropLabel(labels)), + }) + : map; +} diff --git a/athena/lib/encodedMapDataHasHiddenWinCondition.tsx b/athena/lib/encodedMapDataHasHiddenWinCondition.tsx new file mode 100644 index 00000000..7f34a3cc --- /dev/null +++ b/athena/lib/encodedMapDataHasHiddenWinCondition.tsx @@ -0,0 +1,5 @@ +import { PlainMap } from '../map/PlainMap.tsx'; + +export default function encodedMapDataHasHiddenWinCondition(state: PlainMap) { + return state.config.winConditions?.some(([, hidden]) => hidden); +} diff --git a/athena/lib/filterNullables.tsx b/athena/lib/filterNullables.tsx new file mode 100644 index 00000000..50d6b910 --- /dev/null +++ b/athena/lib/filterNullables.tsx @@ -0,0 +1,12 @@ +import { PlainEntity } from '../map/Entity.tsx'; + +export default function filterNullables< + T extends PlainEntity | Record, +>(object: T): T { + for (const key in object) { + if (object[key] == null) { + delete object[key]; + } + } + return object; +} diff --git a/athena/lib/followMovementPath.tsx b/athena/lib/followMovementPath.tsx new file mode 100644 index 00000000..c903bd15 --- /dev/null +++ b/athena/lib/followMovementPath.tsx @@ -0,0 +1,21 @@ +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { VisionT } from '../Vision.tsx'; + +export default function followMovementPath( + map: MapData, + path: ReadonlyArray, + vision: VisionT, +): { blockedBy: Vector | null; path: ReadonlyArray } { + let blockedBy: Vector | null = null; + for (const vector of path) { + if (!vision.isVisible(map, vector) && map.units.has(vector)) { + blockedBy = vector; + break; + } + } + return { + blockedBy, + path: blockedBy ? path.slice(0, path.indexOf(blockedBy)) : path, + }; +} diff --git a/athena/lib/formatText.tsx b/athena/lib/formatText.tsx new file mode 100644 index 00000000..2e358208 --- /dev/null +++ b/athena/lib/formatText.tsx @@ -0,0 +1,22 @@ +import parseInteger from '@deities/hephaestus/parseInteger.tsx'; +import { getUnitInfo, UnitInfo } from '../info/Unit.tsx'; + +export default function formatText( + text: string, + unit: UnitInfo, + name: 'name' | 'characterName', + extra?: ReadonlyArray, +) { + if (extra) { + for (const [key, value] of extra) { + text = text.replaceAll(`{${key}}`, value); + } + } + + return text + .replaceAll(/{(?:(\d+)\.)?name}/g, (_, id: string) => { + const maybeUnitID = id?.length && parseInteger(id); + return ((maybeUnitID && getUnitInfo(maybeUnitID)) || unit)[name]; + }) + .trim(); +} diff --git a/athena/lib/getActivePlayers.tsx b/athena/lib/getActivePlayers.tsx new file mode 100644 index 00000000..660e86ef --- /dev/null +++ b/athena/lib/getActivePlayers.tsx @@ -0,0 +1,18 @@ +import { PlayerIDs } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; + +export default function getActivePlayers(map: MapData): PlayerIDs { + return [ + ...new Set([ + ...map.buildings + .filter((building) => + building.canBuildUnits(map.getPlayer(building.player)), + ) + .map(({ player }) => player) + .values(), + ...map.units.map(({ player }) => player).values(), + ]), + ] + .filter((id) => id > 0) + .sort((a, b) => a - b); +} diff --git a/athena/lib/getAllUnitsToRefill.tsx b/athena/lib/getAllUnitsToRefill.tsx new file mode 100644 index 00000000..0de42cc8 --- /dev/null +++ b/athena/lib/getAllUnitsToRefill.tsx @@ -0,0 +1,25 @@ +import { Ability } from '../info/Unit.tsx'; +import Player from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; +import { VisionT } from '../Vision.tsx'; +import { UnitsWithPosition } from './getUnitsByPositions.tsx'; +import getUnitsToRefill from './getUnitsToRefill.tsx'; + +export default function getAllUnitsToRefill( + map: MapData, + vision: VisionT, + player: Player, + type: 'visible' | 'hidden' = 'visible', +): UnitsWithPosition { + return getUnitsToRefill(map, vision, player, [ + ...map.units + .filter( + (unit, vector) => + map.matchesPlayer(player, unit) && + unit.info.hasAbility(Ability.Supply) && + ((type === 'visible' && vision.isVisible(map, vector)) || + (type === 'hidden' && !vision.isVisible(map, vector))), + ) + .keys(), + ]); +} diff --git a/athena/lib/getAttackStatusEffect.tsx b/athena/lib/getAttackStatusEffect.tsx new file mode 100644 index 00000000..041434c0 --- /dev/null +++ b/athena/lib/getAttackStatusEffect.tsx @@ -0,0 +1,31 @@ +import { getSkillAttackStatusEffects } from '../info/Skill.tsx'; +import { TileInfo } from '../info/Tile.tsx'; +import { LeaderStatusEffect } from '../map/Configuration.tsx'; +import Unit from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; + +export default function getAttackStatusEffect( + map: MapData, + unit: Unit, + tile: TileInfo | null, +) { + let buildingEffect = 0; + for (const [, building] of map.buildings) { + if (map.matchesPlayer(building, unit.player)) { + buildingEffect += building.info.configuration.attackStatusEffect; + } + } + + const player = map.getPlayer(unit); + return ( + 1 + + buildingEffect + + (unit.isLeader() ? LeaderStatusEffect : 0) + + getSkillAttackStatusEffects( + unit.info, + tile, + player.skills, + player.activeSkills, + ) + ); +} diff --git a/athena/lib/getAttackableEntitiesInRange.tsx b/athena/lib/getAttackableEntitiesInRange.tsx new file mode 100644 index 00000000..865eca4c --- /dev/null +++ b/athena/lib/getAttackableEntitiesInRange.tsx @@ -0,0 +1,31 @@ +import { EntityType } from '../map/Entity.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { attackable } from '../Radius.tsx'; +import { VisionT } from '../Vision.tsx'; + +export default function getAttackableEntitiesInRange( + map: MapData, + position: Vector, + vision: VisionT, +) { + const unitA = map.units.get(position); + return new Map( + unitA + ? [...attackable(map, unitA, position, 'cover')].filter(([vector]) => { + const unitB = map.units.get(vector); + const building = map.buildings.get(vector); + const unitIsOpponent = unitB && map.isOpponent(unitA, unitB); + return ( + vision.isVisible(map, vector) && + ((unitIsOpponent && unitA.getAttackWeapon(unitB)) || + (building && + building.info.type !== EntityType.Invincible && + map.isOpponent(unitA, building) && + (!unitB || unitIsOpponent) && + unitA.getAttackWeapon(building))) + ); + }) + : null, + ); +} diff --git a/athena/lib/getAttributeRange.tsx b/athena/lib/getAttributeRange.tsx new file mode 100644 index 00000000..b15885b4 --- /dev/null +++ b/athena/lib/getAttributeRange.tsx @@ -0,0 +1,61 @@ +import maxBy from '@deities/hephaestus/maxBy.tsx'; + +export type AttributeRange = 1 | 2 | 3 | 4 | 5; +export type AttributeRangeWithZero = 0 | 1 | 2 | 3 | 4 | 5; +export type LargeAttributeRangeWithZero = + | AttributeRangeWithZero + | 6 + | 7 + | 8 + | 9 + | 10; + +export function validateAttributeRange( + value?: number | null, +): value is AttributeRange { + return !!value && value >= 1 && value <= 5; +} + +export default function getAttributeRange( + list: ReadonlyArray, + extract: (entry: T) => number, + min: number = 0, + length: number = 5, +) { + const entry = maxBy(list, extract); + if (!entry) { + return []; + } + + const step = (extract(entry) - min) / (length - 1); + return Array.from( + { length }, + (_, index) => min + index * step - (index > 0 ? step / 2 : 0), + ); +} + +export function getAttributeRangeValue( + range: ReadonlyArray, + value: number, +) { + if (value < range[0]) { + return 0; + } + + const index = range.findLastIndex((item) => item <= value); + return (index === -1 ? range.length : index + 1) as AttributeRangeWithZero; +} + +export function getLargeAttributeRangeValue( + range: ReadonlyArray, + value: number, +) { + if (value < range[0]) { + return 0; + } + + const index = range.findLastIndex((item) => item <= value); + return ( + index === -1 ? range.length : index + 1 + ) as LargeAttributeRangeWithZero; +} diff --git a/athena/lib/getAvailableUnitActions.tsx b/athena/lib/getAvailableUnitActions.tsx new file mode 100644 index 00000000..39a8845e --- /dev/null +++ b/athena/lib/getAvailableUnitActions.tsx @@ -0,0 +1,121 @@ +import { filterBuildings } from '../info/Building.tsx'; +import { Ability } from '../info/Unit.tsx'; +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { RadiusItem } from '../Radius.tsx'; +import { VisionT } from '../Vision.tsx'; +import canBuild from './canBuild.tsx'; +import canPlaceRailTrack from './canPlaceRailTrack.tsx'; +import getAttackableEntitiesInRange from './getAttackableEntitiesInRange.tsx'; +import getHealableVectors from './getHealableVectors.tsx'; +import getRescuableVectors from './getRescuableVectors.tsx'; +import getSabotageableVectors from './getSabotageableVectors.tsx'; +import getUnitsToRefill from './getUnitsToRefill.tsx'; + +export type UnitActionTypes = + | 'attack' + | 'capture' + | 'createBuildings' + | 'createTracks' + | 'drop' + | 'fold' + | 'heal' + | 'move' + | 'rescue' + | 'sabotage' + | 'supply' + | 'unfold'; + +export default function getAvailableUnitActions( + map: MapData, + position: Vector, + unit: Unit, + vision: VisionT, + attackable: ReadonlyMap | null, +): ReadonlySet | null { + if (!position || !unit || unit.isCompleted()) { + return null; + } + + const info = unit.info; + const canMove = unit.canMove(); + const building = map.buildings.get(position); + const actions = new Set(); + + if ( + attackable?.size || + (!attackable && + info.hasAttack() && + (canMove || getAttackableEntitiesInRange(map, position, vision).size)) + ) { + actions.add('attack'); + } + + if (info.hasAbility(Ability.Unfold)) { + actions.add(unit.isUnfolded() ? 'fold' : 'unfold'); + } + + if ( + unit.isTransportingUnits() && + info.canDropFrom(map.getTileInfo(position)) + ) { + actions.add('drop'); + } + + if ( + info.hasAbility(Ability.CreateBuildings) && + !map.buildings.has(position) && + filterBuildings((building) => + canBuild(map, building, map.getCurrentPlayer(), position), + ).length > 0 + ) { + actions.add('createBuildings'); + } + + if ( + building && + map.isOpponent(unit, building) && + info.hasAbility(Ability.Capture) + ) { + actions.add('capture'); + } + + if ( + info.hasAbility(Ability.Supply) && + getUnitsToRefill(map, vision, map.getPlayer(unit), position).size + ) { + actions.add('supply'); + } + + if (info.hasAbility(Ability.Heal) && getHealableVectors(map, position).size) { + actions.add('heal'); + } + + if ( + info.hasAbility(Ability.Rescue) && + getRescuableVectors(map, position).size + ) { + actions.add('rescue'); + } + + if ( + info.hasAbility(Ability.Sabotage) && + getSabotageableVectors(map, position).size + ) { + actions.add('sabotage'); + } + + if ( + info.hasAbility(Ability.CreateTracks) && + canPlaceRailTrack(map, position) + ) { + actions.add('createTracks'); + } + + if (canMove) { + actions.add('move'); + } + + return actions; +} diff --git a/athena/lib/getAverageVector.tsx b/athena/lib/getAverageVector.tsx new file mode 100644 index 00000000..f5b97fcf --- /dev/null +++ b/athena/lib/getAverageVector.tsx @@ -0,0 +1,18 @@ +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; + +class AverageVector extends Vector {} + +export default function getAverageVector(vectors: ReadonlyArray) { + const { x, y } = vectors + .slice(1) + .reduce( + (average, vector) => + new AverageVector( + (average.x + vector.x) / 2, + (average.y + vector.y) / 2, + ), + vectors[0] || vec(0, 0), + ); + return vec(Math.round(x), Math.round(y)); +} diff --git a/athena/lib/getBiomeStyle.tsx b/athena/lib/getBiomeStyle.tsx new file mode 100644 index 00000000..dc32cad5 --- /dev/null +++ b/athena/lib/getBiomeStyle.tsx @@ -0,0 +1,188 @@ +import { + Beach, + Box, + Box2, + Bridge, + Forest, + Forest2, + Forest3, + Iceberg, + Island, + Lightning, + Mountain, + Path, + Plain, + PlainTileGroup, + Platform, + River, + Sea, + SeaTileGroup, + Space, + SpaceBridge, + SpaceShipBiome, + StormCloud, + Street, + SwampBiome, + TileInfo, + Weeds, +} from '../info/Tile.tsx'; +import { Biome } from '../map/Biome.tsx'; + +type HEX = `#${string}`; + +export type Palette = Map; + +type BiomeStyle = { + palette?: Palette | null; + tileConversions?: Map; + tileRestrictions?: Set; + waterSwap?: Palette; +}; + +const SpaceShipTileRestrictions = new Set([ + ...PlainTileGroup, + ...SeaTileGroup, + StormCloud, + Lightning, +]); +SpaceShipTileRestrictions.delete(Plain); +for (const info of SpaceShipBiome) { + SpaceShipTileRestrictions.delete(info); +} + +const VolcanoTileRestrictions = new Set([ + ...SwampBiome, + ...SpaceShipBiome, + Island, + Iceberg, +]); +VolcanoTileRestrictions.delete(Weeds); + +const LunaTileRestrictions = new Set([ + ...SwampBiome, + ...SpaceShipBiome, + Island, + Iceberg, + Forest2, +]); +LunaTileRestrictions.delete(Forest3); + +const tileConversions = new Map([ + [Weeds, Island], + [Iceberg, Island], +]); +const tileRestrictions = new Set([...SwampBiome, ...SpaceShipBiome, Iceberg]); + +const style = { + [Biome.Grassland]: { + palette: null, + tileConversions, + tileRestrictions, + }, + [Biome.Desert]: { + palette: new Map([ + ['#27810d', '#e09455'], + ['#4f9e1b', '#fbc774'], + ['#743d26', '#8c490e'], + ]), + tileConversions, + tileRestrictions, + }, + [Biome.Snow]: { + palette: new Map([ + ['#27810d', '#bccee7'], + ['#4f9e1b', '#ebebeb'], + ]), + tileConversions: new Map([ + [Island, Iceberg], + [Weeds, Iceberg], + ]), + tileRestrictions: new Set([ + Island, + Weeds, + ...SwampBiome, + ...SpaceShipBiome, + ]), + }, + [Biome.Swamp]: { + palette: new Map([ + ['#27810d', '#27810e'], + ['#4f9e1b', '#3b932f'], + ]), + tileConversions: new Map([ + [Forest2, Forest3], + [Iceberg, Weeds], + [Island, Weeds], + ]), + tileRestrictions: new Set([...SpaceShipBiome, Forest2, Island, Iceberg]), + waterSwap: new Map([ + ['#13a6e2', '#899f06'], + ['#1ab7eb', '#9fb108'], + ['#62d4f1', '#d3dc31'], + ['#85e2f5', '#ecf471'], + ['#8ce5f5', '#ecf471'], + ['#d8fffd', '#fffdca'], + ]), + }, + + [Biome.Spaceship]: { + palette: new Map([ + ['#27810d', '#4f5677'], + ['#4f9e1b', '#6d7488'], + ]), + tileConversions: new Map([ + [Forest, Box], + [Forest2, Box2], + [Bridge, SpaceBridge], + [Mountain, Platform], + [Sea, Space], + [River, Space], + [Beach, Space], + [Street, Path], + ]), + tileRestrictions: SpaceShipTileRestrictions, + }, + [Biome.Volcano]: { + palette: new Map([ + ['#27810d', '#544440'], + ['#4f9e1b', '#6b5b54'], + ['#854c30', '#544440'], + ['#743d26', '#553a36'], + ]), + tileConversions: new Map([ + [Iceberg, Weeds], + [Island, Weeds], + ]), + tileRestrictions: VolcanoTileRestrictions, + waterSwap: new Map([ + ['#13a6e2', '#a1190b'], + ['#1ab7eb', '#b61b12'], + ['#62d4f1', '#e84949'], + ['#85e2f5', '#fbc58c'], + ['#8ce5f5', '#fbc58c'], + ['#d8fffd', '#ffd5be'], + ]), + }, + [Biome.Luna]: { + palette: new Map([ + ['#27810d', '#2c4c52'], + ['#4f9e1b', '#385e5d'], + ['#854c30', '#28454c'], + ['#743d26', '#2a434d'], + ]), + tileConversions: new Map([...tileConversions, [Forest2, Forest3]]), + tileRestrictions: LunaTileRestrictions, + waterSwap: new Map([ + ['#13a6e2', '#a90b50'], + ['#1ab7eb', '#bc1261'], + ['#62d4f1', '#e8499b'], + ['#85e2f5', '#f593c6'], + ['#8ce5f5', '#f593c6'], + ['#d8fffd', '#ffbeff'], + ]), + }, +}; + +export default function getBiomeStyle(biome: Biome): BiomeStyle { + return style[biome]; +} diff --git a/athena/lib/getBuildableUnits.tsx b/athena/lib/getBuildableUnits.tsx new file mode 100644 index 00000000..ef6912e9 --- /dev/null +++ b/athena/lib/getBuildableUnits.tsx @@ -0,0 +1,21 @@ +import { MovementTypes } from '../info/MovementType.tsx'; +import { Skill } from '../info/Skill.tsx'; +import Building from '../map/Building.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import getDeployableVectors from './getDeployableVectors.tsx'; + +export default function getBuildableUnits( + map: MapData, + building: Building, + vector: Vector, +) { + const player = map.getPlayer(building.player); + return [...building.getBuildableUnits(player)].filter( + (unit) => + (player.skills.has(Skill.NoUnitRestrictions) || + !map.config.blocklistedUnits.has(unit.id)) && + (unit.movementType !== MovementTypes.Rail || + getDeployableVectors(map, unit, vector, player.id).length), + ); +} diff --git a/athena/lib/getChargeValue.tsx b/athena/lib/getChargeValue.tsx new file mode 100644 index 00000000..2d321d05 --- /dev/null +++ b/athena/lib/getChargeValue.tsx @@ -0,0 +1,28 @@ +import { MinFunds } from '../info/Building.tsx'; +import { Charge, MaxHealth } from '../map/Configuration.tsx'; +import Entity, { isBuilding, isUnit } from '../map/Entity.tsx'; +import Player from '../map/Player.tsx'; +import getUnitValue from './getUnitValue.tsx'; + +export default function getChargeValue( + entity: Entity, + player: Player, + newEntity: Entity, + modifier = 1, +) { + if (!isUnit(entity)) { + return newEntity.isDead() + ? Math.floor( + (Charge * 2) / 3 + + (isBuilding(newEntity) + ? newEntity.info.configuration.cost + : MinFunds) * + 2, + ) + : 0; + } + + const value = getUnitValue(entity, player); + const difference = entity.health - newEntity.health; + return Math.floor(value * (difference / MaxHealth) * modifier); +} diff --git a/athena/lib/getDecoratorIndex.tsx b/athena/lib/getDecoratorIndex.tsx new file mode 100644 index 00000000..46112cb4 --- /dev/null +++ b/athena/lib/getDecoratorIndex.tsx @@ -0,0 +1,12 @@ +import Vector from '../map/Vector.tsx'; +import { SizeVector } from '../MapData.tsx'; + +export default function getDecoratorIndex( + vector: Vector, + size: SizeVector, +): number { + if (!size.contains(vector)) { + throw new Error(`vector: Invalid vector '${String(vector)}'.`); + } + return (vector.y - 1) * size.width + (vector.x - 1); +} diff --git a/athena/lib/getDecoratorsAtField.tsx b/athena/lib/getDecoratorsAtField.tsx new file mode 100644 index 00000000..a67f0793 --- /dev/null +++ b/athena/lib/getDecoratorsAtField.tsx @@ -0,0 +1,28 @@ +import { DecoratorInfo, getDecorator } from '../info/Decorator.tsx'; +import { DecoratorsPerSide } from '../map/Configuration.tsx'; +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import getDecoratorIndex from './getDecoratorIndex.tsx'; + +export default function getDecoratorsAtField( + map: MapData, + vector: Vector, +): ReadonlyMap | null { + const decoratorMap = new Map(); + const decoratorSize = map.size.toDecoratorSizeVector(); + for (let x = 1; x <= DecoratorsPerSide; x++) { + for (let y = 1; y <= DecoratorsPerSide; y++) { + const subVector = vec( + (vector.x - 1) * DecoratorsPerSide + x, + (vector.y - 1) * DecoratorsPerSide + y, + ); + const index = getDecoratorIndex(subVector, decoratorSize); + const decorator = getDecorator(map.decorators[index]); + if (decorator) { + decoratorMap.set(vec(x, y), decorator); + } + } + } + return decoratorMap.size ? decoratorMap : null; +} diff --git a/athena/lib/getDefenseStatusEffect.tsx b/athena/lib/getDefenseStatusEffect.tsx new file mode 100644 index 00000000..cab30308 --- /dev/null +++ b/athena/lib/getDefenseStatusEffect.tsx @@ -0,0 +1,23 @@ +import { getSkillDefenseStatusEffects } from '../info/Skill.tsx'; +import { TileInfo } from '../info/Tile.tsx'; +import { LeaderStatusEffect } from '../map/Configuration.tsx'; +import Entity, { isUnit } from '../map/Entity.tsx'; +import MapData from '../MapData.tsx'; + +export default function getDefenseStatusEffect( + map: MapData, + entity: Entity, + tile: TileInfo | null, +) { + const player = map.getPlayer(entity); + return ( + 1 + + (isUnit(entity) && entity.isLeader() ? LeaderStatusEffect : 0) + + getSkillDefenseStatusEffects( + entity.info, + tile, + player.skills, + player.activeSkills, + ) + ); +} diff --git a/athena/lib/getDeployableVectors.tsx b/athena/lib/getDeployableVectors.tsx new file mode 100644 index 00000000..4634ac58 --- /dev/null +++ b/athena/lib/getDeployableVectors.tsx @@ -0,0 +1,24 @@ +import { Skill } from '../info/Skill.tsx'; +import { UnitInfo } from '../info/Unit.tsx'; +import { PlayerID } from '../map/Player.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import canDeploy from './canDeploy.tsx'; + +export default function getDeployableVectors( + map: MapData, + unit: UnitInfo, + vector: Vector, + player: PlayerID, +) { + return vector + .expand() + .filter((vector) => + canDeploy( + map, + unit, + vector, + map.getPlayer(player).skills.has(Skill.NoUnitRestrictions), + ), + ); +} diff --git a/athena/lib/getFirstHumanPlayer.tsx b/athena/lib/getFirstHumanPlayer.tsx new file mode 100644 index 00000000..88cd2f16 --- /dev/null +++ b/athena/lib/getFirstHumanPlayer.tsx @@ -0,0 +1,5 @@ +import MapData from '../MapData.tsx'; + +export default function getFirstHumanPlayer(map: MapData) { + return map.getPlayers().find((player) => player.isHumanPlayer()); +} diff --git a/athena/lib/getFloatingEdgeModifier.tsx b/athena/lib/getFloatingEdgeModifier.tsx new file mode 100644 index 00000000..5d8661e8 --- /dev/null +++ b/athena/lib/getFloatingEdgeModifier.tsx @@ -0,0 +1,138 @@ +import { DeepSea, isSea, MaybeTileID, River } from '../info/Tile.tsx'; +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData, { ModifierField } from '../MapData.tsx'; +import { Modifier } from './Modifier.tsx'; + +const isSingleSea = (tile: MaybeTileID, modifier: number | null) => + tile !== DeepSea.id && modifier === Modifier.Single; + +const shouldPlaceSeaDecorator = (tile: MaybeTileID, modifier: number | null) => + isSea(tile) && !isSingleSea(tile, modifier); + +export default function getFloatingEdgeModifier( + map: MapData, + vector: Vector, +): ModifierField | null { + let modifier: ModifierField | null = null; + if (vector.y === 0) { + const adjacent = vector.down(); + const left = vector.x === 1 || isSea(map.getTile(adjacent.left(), 0)); + const right = + vector.x === map.size.width || isSea(map.getTile(adjacent.right(), 0)); + const tile = map.getTile(adjacent, 0); + const adjacentModifier = tile && map.getModifier(adjacent, 0); + if (shouldPlaceSeaDecorator(tile, adjacentModifier)) { + modifier = + left && right + ? Modifier.BottomWallAreaDecorator + : left + ? [Modifier.BottomWallAreaDecorator, Modifier.BottomRightEdge] + : right + ? [Modifier.BottomWallAreaDecorator, Modifier.BottomLeftEdge] + : Modifier.BottomWall; + } else if (vector.x === 0) { + const adjacent = vec(1, 1); + const tile = map.getTile(adjacent, 0); + modifier = shouldPlaceSeaDecorator(tile, map.getModifier(adjacent, 0)) + ? Modifier.BottomRightAreaDecorator + : Modifier.BottomRightEdge; + } else if (vector.x === map.size.width + 1) { + const adjacent = vec(map.size.width, 1); + const tile = map.getTile(adjacent, 0); + modifier = shouldPlaceSeaDecorator(tile, map.getModifier(adjacent, 0)) + ? Modifier.BottomLeftAreaDecorator + : Modifier.BottomLeftEdge; + } else { + modifier = + tile === River.id && adjacentModifier === Modifier.Vertical + ? Modifier.RiverFlowsFromBottom + : Modifier.BottomWall; + } + } else if (vector.y === map.size.height + 1) { + const adjacent = vector.up(); + const left = vector.x === 1 || isSea(map.getTile(adjacent.left(), 0)); + const right = + vector.x === map.size.width || isSea(map.getTile(adjacent.right(), 0)); + const tile = map.getTile(adjacent, 0); + const adjacentModifier = tile && map.getModifier(adjacent, 0); + if (shouldPlaceSeaDecorator(tile, adjacentModifier)) { + modifier = + left && right + ? Modifier.TopWallAreaDecorator + : left + ? [Modifier.TopWallAreaDecorator, Modifier.TopRightEdge] + : right + ? [Modifier.TopWallAreaDecorator, Modifier.TopLeftEdge] + : Modifier.TopWall; + } else if (vector.x === 0) { + const adjacent = vec(1, map.size.height); + const tile = map.getTile(adjacent, 0); + modifier = shouldPlaceSeaDecorator(tile, map.getModifier(adjacent, 0)) + ? Modifier.TopRightAreaDecorator + : Modifier.TopRightEdge; + } else if (vector.x === map.size.width + 1) { + const adjacent = vec(map.size.width, map.size.height); + const tile = map.getTile(adjacent, 0); + modifier = shouldPlaceSeaDecorator(tile, map.getModifier(adjacent, 0)) + ? Modifier.TopLeftAreaDecorator + : Modifier.TopLeftEdge; + } else { + modifier = + tile === River.id && adjacentModifier === Modifier.Vertical + ? Modifier.RiverFlowsFromTop + : Modifier.TopWall; + } + } else if (vector.x === 0) { + const adjacent = vector.right(); + const up = vector.y === 1 || isSea(map.getTile(adjacent.up(), 0)); + const down = + vector.y === map.size.height || isSea(map.getTile(adjacent.down(), 0)); + const tile = map.getTile(adjacent, 0); + const adjacentModifier = tile && map.getModifier(adjacent, 0); + if (shouldPlaceSeaDecorator(tile, adjacentModifier)) { + modifier = + up && down + ? Modifier.RightWallAreaDecorator + : up + ? [ + Modifier.RightWallAreaDecorator, + Modifier.BottomRightAreaDecorator, + ] + : down + ? [ + Modifier.RightWallAreaDecorator, + Modifier.TopRightAreaDecorator, + ] + : Modifier.RightWall; + } else { + modifier = + tile === River.id && adjacentModifier === Modifier.None + ? Modifier.RiverFlowsFromRight + : Modifier.RightWall; + } + } else if (vector.x === map.size.width + 1) { + const adjacent = vector.left(); + const up = vector.y === 1 || isSea(map.getTile(adjacent.up(), 0)); + const down = + vector.y === map.size.height || isSea(map.getTile(adjacent.down(), 0)); + const tile = map.getTile(adjacent, 0); + const adjacentModifier = tile && map.getModifier(adjacent, 0); + if (shouldPlaceSeaDecorator(tile, adjacentModifier)) { + modifier = + up && down + ? Modifier.LeftWallAreaDecorator + : up + ? [Modifier.LeftWallAreaDecorator, Modifier.BottomLeftAreaDecorator] + : down + ? [Modifier.LeftWallAreaDecorator, Modifier.TopLeftAreaDecorator] + : Modifier.LeftWall; + } else { + modifier = + tile === River.id && adjacentModifier === Modifier.None + ? Modifier.RiverFlowsFromLeft + : Modifier.LeftWall; + } + } + return modifier; +} diff --git a/athena/lib/getHealCost.tsx b/athena/lib/getHealCost.tsx new file mode 100644 index 00000000..73ae75f6 --- /dev/null +++ b/athena/lib/getHealCost.tsx @@ -0,0 +1,13 @@ +import { HealAmount, MaxHealth } from '../map/Configuration.tsx'; +import Player from '../map/Player.tsx'; +import Unit from '../map/Unit.tsx'; +import getUnitValue from './getUnitValue.tsx'; + +export default function getHealCost(unit: Unit, player: Player) { + return Math.round( + ((Math.min(MaxHealth, unit.health + HealAmount) - unit.health) / + MaxHealth) * + getUnitValue(unit, player) * + 0.8, + ); +} diff --git a/athena/lib/getHealableVectors.tsx b/athena/lib/getHealableVectors.tsx new file mode 100644 index 00000000..c539be74 --- /dev/null +++ b/athena/lib/getHealableVectors.tsx @@ -0,0 +1,22 @@ +import { Ability } from '../info/Unit.tsx'; +import { MaxHealth } from '../map/Configuration.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function getHealableVectors(map: MapData, position: Vector) { + const unitA = map.units.get(position); + const healTypes = unitA?.info.configuration.healTypes; + return new Set( + healTypes && unitA.info.hasAbility(Ability.Heal) + ? position.adjacent().filter((vector) => { + const unitB = map.units.get(vector); + return ( + unitB && + map.matchesPlayer(unitB, unitA) && + unitB.health < MaxHealth && + healTypes.has(unitB.info.type) + ); + }) + : [], + ); +} diff --git a/athena/lib/getLeaders.tsx b/athena/lib/getLeaders.tsx new file mode 100644 index 00000000..674e912d --- /dev/null +++ b/athena/lib/getLeaders.tsx @@ -0,0 +1,42 @@ +import { PlayerID } from '../map/Player.tsx'; +import Unit, { TransportedUnit } from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; + +export default function getLeaders( + map: MapData, + player?: PlayerID, +): Readonly<{ + addLeader: (player: PlayerID, id: number) => void; + hasLeader: (player: PlayerID, id: number) => boolean; +}> { + const leaders = new Map>(); + const addLeader = (player: PlayerID, id: number) => { + const set = leaders.get(player); + if (set) { + set.add(id); + } else { + leaders.set(player, new Set([id])); + } + }; + + const addLeaders = (unit: Unit | TransportedUnit) => { + if ((player == null || unit.player === player) && unit.isLeader()) { + addLeader(unit.player, unit.id); + } + + if (unit.transports) { + for (const transportedUnit of unit.transports) { + addLeaders(transportedUnit); + } + } + }; + + const hasLeader = (player: PlayerID, id: number) => + leaders.get(player)?.has(id) || false; + + for (const [, unit] of map.units) { + addLeaders(unit); + } + + return { addLeader, hasLeader }; +} diff --git a/athena/lib/getMapSize.tsx b/athena/lib/getMapSize.tsx new file mode 100644 index 00000000..bcace279 --- /dev/null +++ b/athena/lib/getMapSize.tsx @@ -0,0 +1,21 @@ +type MapSize = 'Micro' | 'Small' | 'Medium' | 'Large'; + +export default function getMapSize( + { + height, + width, + }: { + height: number; + width: number; + }, + mapSize: Record, +): T { + const area = width * height; + return area < 120 + ? mapSize.Micro + : area < 260 + ? mapSize.Small + : area < 550 + ? mapSize.Medium + : mapSize.Large; +} diff --git a/athena/lib/getModifier.tsx b/athena/lib/getModifier.tsx new file mode 100644 index 00000000..ae66234c --- /dev/null +++ b/athena/lib/getModifier.tsx @@ -0,0 +1,569 @@ +import { + Box, + Box2, + Computer, + Forest3, + getTileInfo, + Iceberg, + Island, + Lightning, + Plain, + Reef, + River, + Sea, + StormCloud, + TileInfo, + TileLayer, + TileTypes, + Trench, + Wall, + Weeds, + Window, +} from '../info/Tile.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData, { ModifierMap } from '../MapData.tsx'; +import { Modifier } from './Modifier.tsx'; + +type AnyTileInfo = TileInfo | null | undefined; + +const matchesSame = (t: TileInfo) => (x: AnyTileInfo) => x && t.id === x.id; +const matchesAll = (t: TileInfo) => (x: AnyTileInfo) => x && t.group & x.type; +const matchesAny = (t: TileInfo) => (x: AnyTileInfo) => !x || t.group & x.type; + +const makeTFn = + (map: MapData, info: TileInfo, layer: TileLayer) => + (vector: Vector, _layer = info.style.isolated ? layer : undefined) => { + const tile = map.getTile(vector, _layer); + const tileInfo = tile ? getTileInfo(tile) : null; + // Tiles that are not isolated will verify that the tile group matches, otherwise + // attempt to fall through to layer 0. + return tileInfo && + _layer === undefined && + !info.style.isolated && + tileInfo.group !== info.group + ? map.getTileInfo(vector, 0) + : tileInfo; + }; + +const byGroup = (map: MapData, group: number, vector: Vector) => { + if (!map.contains(vector)) { + return null; + } + const layer1Tile = map.getTile(vector, 1); + const tile = layer1Tile && getTileInfo(layer1Tile); + return tile && group & tile.type ? tile : map.getTileInfo(vector, 0); +}; + +export const getWaterfallModifier = ( + map: MapData, + vector: Vector, + info: TileInfo, + layer: TileLayer, +) => { + if (info !== Sea) { + return null; + } + + const t = makeTFn(map, info, layer); + const [, up, rp, dp, lp, lup, rup, ldp, rdp] = vector.expandWithDiagonals(); + const ru = t(rup); + const lu = t(lup); + const rd = t(rdp); + const ld = t(ldp); + const u = t(up); + const r = t(rp); + const d = t(dp); + const l = t(lp); + + const matchAny = matchesAny(info); + + if ( + u && + u.type & TileTypes.River && + [l, r, d, ld, rd].every(matchAny) && + ![lu, ru].some(matchesAll(u)) && + vector.x !== 1 && + vector.x !== map.size.width && + matchesSame(u)(t(up.up())) + ) { + return Modifier.RiverFlowsFromTop; + } else if ( + d && + d.type & TileTypes.River && + [l, r, u, lu, ru].every(matchAny) && + ![ld, rd].some(matchesAll(d)) && + vector.x !== 1 && + vector.x !== map.size.width && + matchesSame(d)(t(dp.down())) + ) { + return Modifier.RiverFlowsFromBottom; + } else if ( + r && + r.type & TileTypes.River && + [l, u, d, lu, ld].every(matchAny) && + ![ru, rd].some(matchesAll(r)) && + vector.y !== 1 && + vector.y !== map.size.height && + matchesSame(r)(t(rp.right())) + ) { + return Modifier.RiverFlowsFromRight; + } else if ( + l && + l.type & TileTypes.River && + [u, r, d, ru, rd].every(matchAny) && + ![lu, ld].some(matchesAll(l)) && + vector.y !== 1 && + vector.y !== map.size.height && + matchesSame(l)(t(lp.left())) + ) { + return Modifier.RiverFlowsFromLeft; + } + + return null; +}; + +export default function getModifier( + map: MapData, + vector: Vector, + info: TileInfo, + layer: TileLayer, +): number { + let modifier: Modifier | null = null; + + const t = makeTFn(map, info, layer); + const [, up, rp, dp, lp, lup, rup, ldp, rdp] = vector.expandWithDiagonals(); + const ru = t(rup); + const lu = t(lup); + const rd = t(rdp); + const ld = t(ldp); + let u = t(up); + let r = t(rp); + let d = t(dp); + let l = t(lp); + + const matchSame = matchesSame(info); + const matchAll = matchesAll(info); + const matchAny = matchesAny(info); + const isAreaDecorator = info.type & TileTypes.AreaDecorator; + + if (info === Computer || info === Iceberg) { + const modulo = (vector.x + vector.y) % 2; + modifier = modulo === 1 ? Modifier.Variant2 : Modifier.None; + } else if (info === Lightning) { + if ([u, d].every(matchesSame(StormCloud))) { + modifier = Modifier.Vertical; + } + } else if (info === Plain || info === Window || info === Island) { + const modulo = (vector.x + vector.y) % 3; + modifier = + modulo === 1 + ? Modifier.Variant2 + : modulo === 2 + ? Modifier.Variant3 + : Modifier.None; + } else if (info === Wall || info === Forest3) { + const modulo = (vector.x + vector.y) % 7; + modifier = + modulo === 1 + ? Modifier.Variant2 + : modulo === 2 + ? Modifier.Variant3 + : modulo === 3 + ? Modifier.Variant4 + : modulo === 4 + ? Modifier.Variant5 + : modulo === 5 + ? Modifier.Variant6 + : modulo === 6 + ? Modifier.Variant7 + : Modifier.None; + } else if ( + info === Reef || + info === Weeds || + info.type & TileTypes.ConstructionSite + ) { + const modulo = (vector.x + vector.y) % 4; + modifier = + modulo === 1 + ? Modifier.Variant2 + : modulo === 2 + ? Modifier.Variant3 + : modulo === 3 + ? Modifier.Variant4 + : Modifier.None; + } else if (info === Box || info === Box2) { + const modulo = (vector.x + vector.y) % 6; + modifier = + modulo === 1 + ? Modifier.Variant2 + : modulo === 2 + ? Modifier.Variant3 + : modulo === 3 + ? Modifier.Variant4 + : modulo === 4 + ? Modifier.Variant5 + : modulo === 5 + ? Modifier.Variant6 + : Modifier.None; + } + + if (info.type & TileTypes.Joinable) { + // If the nearest tile can cross with the current one (bridge), advance the vector one item further for the check. + if ( + u?.style.crossesWith && + u.style.crossesWith & info.type && + [ + byGroup(map, u.group, up.left()), + byGroup(map, u.group, up.right()), + ].every(matchesAny(u)) + ) { + u = t(up.up()); + } + if ( + r?.style.crossesWith && + r.style.crossesWith & info.type && + [byGroup(map, r.group, rp.up()), byGroup(map, r.group, rp.down())].every( + matchesAny(r), + ) + ) { + r = t(rp.right()); + } + if ( + d?.style.crossesWith && + d.style.crossesWith & info.type && + [ + byGroup(map, d.group, dp.left()), + byGroup(map, d.group, dp.right()), + ].every(matchesAny(d)) + ) { + d = t(dp.down()); + } + if ( + l?.style.crossesWith && + l.style.crossesWith & info.type && + [byGroup(map, l.group, lp.up()), byGroup(map, l.group, lp.down())].every( + matchesAny(l), + ) + ) { + l = t(lp.left()); + } + } + + if (info.type & TileTypes.Area) { + const match = info.type & TileTypes.AreaMatchesAll ? matchAll : matchAny; + if ([u, r, d, l, ru, lu, rd, ld].every(match)) { + modifier = Modifier.Center; + // Concatenation 1 vertex. + } else if ([u, r, d, l, ru, lu, ld].every(match)) { + modifier = Modifier.BottomRightEdge; + } else if ([u, r, d, l, lu, rd, ld].every(match)) { + modifier = Modifier.TopRightEdge; + } else if ([u, r, d, l, ru, lu, rd].every(match)) { + modifier = Modifier.BottomLeftEdge; + } else if ([u, r, d, l, ru, rd, ld].every(match)) { + modifier = Modifier.TopLeftEdge; + // Concatenation 2 opposite vertices. + } else if ([u, r, d, l, lu, rd].every(match)) { + modifier = Modifier.TopRightBottomLeftEdge; + } else if ([u, r, d, l, ru, ld].every(match)) { + modifier = Modifier.TopLeftBottomRightEdge; + // Concatenation 2 vertices. + } else if ([u, r, d, l, ru, lu].every(match)) { + modifier = Modifier.BottomLeftAndRightEdge; + } else if ([u, r, d, l, rd, ld].every(match)) { + modifier = Modifier.TopLeftAndRightEdge; + } else if ([u, r, d, l, lu, ld].every(match)) { + modifier = Modifier.TopRightBottomRightEdge; + } else if ([u, r, d, l, ru, rd].every(match)) { + modifier = Modifier.TopLeftBottomLeftEdge; + // Concatenation 3 vertices. + } else if ([u, r, d, l, ru].every(match)) { + modifier = Modifier.TopRightIsArea; + } else if ([u, r, d, l, lu].every(match)) { + modifier = Modifier.TopLeftIsArea; + } else if ([u, r, d, l, rd].every(match)) { + modifier = Modifier.BottomRightIsArea; + } else if ([u, r, d, l, ld].every(match)) { + modifier = Modifier.BottomLeftIsArea; + // Edges. + } else if ([u, r, d, ru, rd].every(match)) { + if (!isAreaDecorator || [u, d].every(matchSame)) { + modifier = Modifier.LeftWall; + } else if (isAreaDecorator && matchSame(u)) { + modifier = Modifier.LeftWallTopAreaDecorator; + } else if (isAreaDecorator && matchSame(d)) { + modifier = Modifier.LeftWallBottomAreaDecorator; + } else { + modifier = Modifier.LeftWallAreaDecorator; + } + } else if ([r, d, l, rd, ld].every(match)) { + if (!isAreaDecorator || [l, r].every(matchSame)) { + modifier = Modifier.TopWall; + } else if (isAreaDecorator && matchSame(l)) { + modifier = Modifier.TopWallLeftAreaDecorator; + } else if (isAreaDecorator && matchSame(r)) { + modifier = Modifier.TopWallRightAreaDecorator; + } else { + modifier = Modifier.TopWallAreaDecorator; + } + } else if ([u, r, l, ru, lu].every(match)) { + if (!isAreaDecorator || [l, r].every(matchSame)) { + modifier = Modifier.BottomWall; + } else if (isAreaDecorator && matchSame(l)) { + modifier = Modifier.BottomWallLeftAreaDecorator; + } else if (isAreaDecorator && matchSame(r)) { + modifier = Modifier.BottomWallRightAreaDecorator; + } else { + modifier = Modifier.BottomWallAreaDecorator; + } + } else if ([u, d, l, lu, ld].every(match)) { + if (!isAreaDecorator || [u, d].every(matchSame)) { + modifier = Modifier.RightWall; + } else if (isAreaDecorator && matchSame(u)) { + modifier = Modifier.RightWallTopAreaDecorator; + } else if (isAreaDecorator && matchSame(d)) { + modifier = Modifier.RightWallBottomAreaDecorator; + } else { + modifier = Modifier.RightWallAreaDecorator; + } + // Concatenation edge + 1 vertex. + } else if ([r, d, l, rd].every(match)) { + modifier = Modifier.TopWallBottomLeftEdge; + } else if ([u, r, l, ru].every(match)) { + modifier = Modifier.BottomWallLeftTopEdge; + } else if ([u, r, d, rd].every(match)) { + modifier = Modifier.LeftWallTopRightEdge; + } else if ([u, d, l, ld].every(match)) { + modifier = Modifier.RightWallTopLeftEdge; + } else if ([r, d, l, ld].every(match)) { + modifier = Modifier.TopWallRightBottomEdge; + } else if ([u, r, l, lu].every(match)) { + modifier = Modifier.BottomWallRightTopEdge; + } else if ([u, r, d, ru].every(match)) { + modifier = Modifier.LeftWallBottomRightEdge; + } else if ([u, d, l, lu].every(match)) { + modifier = Modifier.RightWallBottomLeftEdge; + // Corners. + } else if ([r, d, rd].every(match)) { + if (!isAreaDecorator || [r, d].every(matchSame)) { + modifier = Modifier.TopLeftAreaCorner; + } else if (isAreaDecorator && matchSame(r)) { + modifier = Modifier.TopLeftRightAreaDecorator; + } else if (isAreaDecorator && matchSame(d)) { + modifier = Modifier.TopLeftBottomAreaDecorator; + } else { + modifier = Modifier.TopLeftAreaDecorator; + } + } else if ([r, u, ru].every(match)) { + if (!isAreaDecorator || [r, u].every(matchSame)) { + modifier = Modifier.BottomLeftAreaCorner; + } else if (isAreaDecorator && matchSame(r)) { + modifier = Modifier.BottomLeftRightAreaDecorator; + } else if (isAreaDecorator && matchSame(u)) { + modifier = Modifier.BottomLeftTopAreaDecorator; + } else { + modifier = Modifier.BottomLeftAreaDecorator; + } + } else if ([d, l, ld].every(match)) { + if (!isAreaDecorator || [d, l].every(matchSame)) { + modifier = Modifier.TopRightAreaCorner; + } else if (isAreaDecorator && matchSame(d)) { + modifier = Modifier.TopRightBottomAreaDecorator; + } else if (isAreaDecorator && matchSame(l)) { + modifier = Modifier.TopRightLeftAreaDecorator; + } else { + modifier = Modifier.TopRightAreaDecorator; + } + } else if ([u, l, lu].every(match)) { + if (!isAreaDecorator || [u, l].every(matchSame)) { + modifier = Modifier.BottomRightAreaCorner; + } else if (isAreaDecorator && matchSame(u)) { + modifier = Modifier.BottomRightTopAreaDecorator; + } else if (isAreaDecorator && matchSame(l)) { + modifier = Modifier.BottomRightLeftAreaDecorator; + } else { + modifier = Modifier.BottomRightAreaDecorator; + } + } + + const waterfallModifier = getWaterfallModifier(map, vector, info, layer); + if (waterfallModifier != null) { + modifier = waterfallModifier; + } + } + + if (info.type & TileTypes.Joinable && modifier == null) { + // This is order dependent. + if ([u, r, d, l].every(matchAll)) { + modifier = Modifier.JoinableCenter; + } else if ([u, d, l].every(matchAll)) { + modifier = Modifier.TLeft; + } else if ([u, r, l].every(matchAll)) { + modifier = Modifier.TTop; + } else if ([r, d, l].every(matchAll)) { + modifier = Modifier.TBottom; + } else if ([u, r, d].every(matchAll)) { + modifier = Modifier.TRight; + // First, check real adjacent tiles. + } else if ([r, d].every(matchAll)) { + modifier = Modifier.TopLeftCorner; + } else if ([u, r].every(matchAll)) { + modifier = Modifier.BottomLeftCorner; + } else if ([d, l].every(matchAll)) { + modifier = Modifier.TopRightCorner; + } else if ([u, l].every(matchAll)) { + modifier = Modifier.BottomRightCorner; + } else if ( + (info.type & TileTypes.ConnectWithEdge && [r, l].every(matchAny)) || + [r, l].every(matchAll) + ) { + if (info.type & TileTypes.Bridge) { + const layer0Tile = map.getTileInfo(vector, 0); + modifier = + r !== info && l !== info + ? layer0Tile === River + ? Modifier.HorizontalCrossing + : layer0Tile === Trench + ? Modifier.Variant2 + : Modifier.Single + : r !== info + ? Modifier.ConnectingTailRight + : l !== info + ? Modifier.ConnectingTailLeft + : Modifier.Horizontal; + } else { + modifier = Modifier.Horizontal; + } + } else if ( + (info.type & TileTypes.ConnectWithEdge && [u, d].every(matchAny)) || + [u, d].every(matchAll) + ) { + if (info.type & TileTypes.Bridge) { + const layer0Tile = map.getTileInfo(vector, 0); + modifier = + u !== info && d !== info + ? layer0Tile === River || layer0Tile === Trench + ? Modifier.VerticalCrossing + : Modifier.VerticalSingle + : u !== info + ? Modifier.ConnectingTailUp + : d !== info + ? Modifier.ConnectingTailDown + : Modifier.Vertical; + } else { + modifier = Modifier.Vertical; + } + // Check if we need to use one of the tails. + } else if (matchAll(d)) { + modifier = + (info.group === TileTypes.Pier && + !((t(up, 0)?.type || -1) & TileTypes.Sea)) || + (info === River && u && getWaterfallModifier(map, up, u, 0)) + ? Modifier.ConnectingTailUp + : info.type & TileTypes.Bridge && d !== info + ? Modifier.VerticalSingle + : Modifier.TailUp; + } else if (matchAll(u)) { + modifier = + (info.group === TileTypes.Pier && + !((t(dp, 0)?.type || -1) & TileTypes.Sea)) || + (info === River && d && getWaterfallModifier(map, dp, d, 0)) + ? Modifier.ConnectingTailDown + : info.type & TileTypes.Bridge && u !== info + ? Modifier.VerticalSingle + : Modifier.TailDown; + } else if (matchAll(r)) { + modifier = + (info.group === TileTypes.Pier && + !((t(lp, 0)?.type || -1) & TileTypes.Sea)) || + (info === River && l && getWaterfallModifier(map, lp, l, 0)) + ? Modifier.ConnectingTailLeft + : info.type & TileTypes.Bridge && r !== info + ? Modifier.Single + : Modifier.TailLeft; + } else if (matchAll(l)) { + modifier = + (info.group === TileTypes.Pier && + !((t(rp, 0)?.type || -1) & TileTypes.Sea)) || + (info === River && r && getWaterfallModifier(map, rp, r, 0)) + ? Modifier.ConnectingTailRight + : info.type & TileTypes.Bridge && l !== info + ? Modifier.Single + : Modifier.TailRight; + // Up/Down. + } else if ([u, d].some(matchAll)) { + modifier = Modifier.Vertical; + // Lonely tile. + } else { + if (info.group === TileTypes.Pier) { + if (!((t(dp, 0)?.type || -1) & TileTypes.Sea)) { + modifier = Modifier.SingleConnectingTailDown; + } else if (!((t(up, 0)?.type || -1) & TileTypes.Sea)) { + modifier = Modifier.SingleConnectingTailUp; + } else if (!((t(rp, 0)?.type || -1) & TileTypes.Sea)) { + modifier = Modifier.SingleConnectingTailRight; + } else if (!((t(lp, 0)?.type || -1) & TileTypes.Sea)) { + modifier = Modifier.SingleConnectingTailLeft; + } + } + + if (modifier == null) { + modifier = Modifier.Single; + } + } + } + + if ( + modifier == null && + info.style.connectsWith && + info.type & TileTypes.Bridge + ) { + if ( + [t(up, 0), t(dp, 0)].every(matchesAll(info.style.connectsWith)) || + [u, d].some(matchesAll(info.style.connectsWith)) + ) { + modifier = Modifier.Vertical; + } else if ( + [t(rp, 0), t(lp, 0)].every(matchesAll(info.style.connectsWith)) + ) { + modifier = Modifier.Horizontal; + } + } + + if (info.style.crossesWith) { + const dg = byGroup(map, info.style.crossesWith, dp); + const lg = byGroup(map, info.style.crossesWith, lp); + const rg = byGroup(map, info.style.crossesWith, rp); + const ug = byGroup(map, info.style.crossesWith, up); + + if ( + [r, l].every(matchAny) && + ug && + info.style.crossesWith & ug.type && + matchesAll(ug)(dg) + ) { + modifier = Modifier.HorizontalCrossing; + } else if ( + [u, d].every(matchAny) && + rg && + info.style.crossesWith & rg.type && + matchesAll(rg)(lg) + ) { + modifier = Modifier.VerticalCrossing; + } + } + + return modifier != null && info.sprite.modifiers.has(modifier) + ? modifier + : Modifier.None; +} + +export function getAllModifiers(map: MapData): ModifierMap { + const modifiers = Array(map.map.length); + map.forEachTile((vector, tile, layer) => { + const index = map.getTileIndex(vector); + const modifier = getModifier(map, vector, tile, layer); + modifiers[index] = layer === 1 ? [modifiers[index], modifier] : modifier; + }); + return modifiers; +} diff --git a/athena/lib/getMovementPath.tsx b/athena/lib/getMovementPath.tsx new file mode 100644 index 00000000..674b0690 --- /dev/null +++ b/athena/lib/getMovementPath.tsx @@ -0,0 +1,28 @@ +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { RadiusItem } from '../Radius.tsx'; +import { VisionT } from '../Vision.tsx'; + +export default function getMovementPath( + map: MapData, + to: Vector, + fields: ReadonlyMap, + vision: VisionT | null, +): { blockedBy: Vector | null; path: ReadonlyArray } { + let item = fields.get(to); + let blockedBy: Vector | null = null; + const path: Set = new Set(); + while (item?.parent && !path.has(item.vector)) { + const { parent, vector } = item; + path.add(vector); + if (vision && !vision.isVisible(map, vector) && map.units.has(vector)) { + blockedBy = vector; + } + item = fields.get(parent); + } + const list = [...path].reverse(); + return { + blockedBy, + path: blockedBy ? list.slice(0, list.indexOf(blockedBy)) : list, + }; +} diff --git a/athena/lib/getParentToMoveTo.tsx b/athena/lib/getParentToMoveTo.tsx new file mode 100644 index 00000000..72e24352 --- /dev/null +++ b/athena/lib/getParentToMoveTo.tsx @@ -0,0 +1,51 @@ +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { RadiusItem } from '../Radius.tsx'; + +export default function getParentToMoveTo( + map: MapData, + unit: Unit, + from: Vector, + to: RadiusItem | undefined, + fields: ReadonlyMap, +) { + const { info } = unit; + const isLongRange = info.isLongRange(); + const player = map.getPlayer(unit); + const parent = + to?.parent || + (isLongRange && to && unit.canAttackAt(from.distance(to.vector), player) + ? from + : null); + + if (parent && isLongRange) { + const range = info.getRangeFor(player); + if (info.canAttackAt(1, range) && info.canAttackAt(2, range)) { + const grandparent = fields.get(parent)?.parent || from; + if (grandparent && info.canAttackAt(3, range)) { + const greatgrandparent = fields.get(grandparent)?.parent || from; + if (canAttackFrom(map, from, greatgrandparent, to, 3)) { + return greatgrandparent; + } + } + + if (canAttackFrom(map, from, grandparent, to, 2)) { + return grandparent; + } + } + } + + return parent; +} + +const canAttackFrom = ( + map: MapData, + from: Vector, + candidate: Vector, + field: RadiusItem | undefined, + distance: number, +) => + candidate && + (!field || candidate.distance(field.vector) <= distance) && + (from.equals(candidate) || !map.units.has(candidate)); diff --git a/athena/lib/getPathFields.tsx b/athena/lib/getPathFields.tsx new file mode 100644 index 00000000..f1ef0d43 --- /dev/null +++ b/athena/lib/getPathFields.tsx @@ -0,0 +1,16 @@ +import Vector from '../map/Vector.tsx'; +import { RadiusItem } from '../Radius.tsx'; + +export default function getPathFields( + path: ReadonlyArray, + radiusFields: ReadonlyMap, +) { + const fields = new Map(); + for (const vector of path) { + const item = radiusFields.get(vector); + if (item) { + fields.set(vector, item); + } + } + return fields; +} diff --git a/athena/lib/getRescuableVectors.tsx b/athena/lib/getRescuableVectors.tsx new file mode 100644 index 00000000..fcf63f2e --- /dev/null +++ b/athena/lib/getRescuableVectors.tsx @@ -0,0 +1,14 @@ +import { Ability } from '../info/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function getRescuableVectors(map: MapData, position: Vector) { + const unitA = map.units.get(position); + return new Set( + unitA?.info.hasAbility(Ability.Rescue) + ? position + .adjacent() + .filter((vector) => map.units.get(vector)?.player === 0) + : [], + ); +} diff --git a/athena/lib/getSabotageableVectors.tsx b/athena/lib/getSabotageableVectors.tsx new file mode 100644 index 00000000..25973cc7 --- /dev/null +++ b/athena/lib/getSabotageableVectors.tsx @@ -0,0 +1,22 @@ +import { Ability } from '../info/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function getSabotageableVectors( + map: MapData, + position: Vector, +): ReadonlySet { + const unitA = map.units.get(position); + return new Set( + unitA?.info.hasAbility(Ability.Sabotage) + ? position.adjacent().filter((vector) => { + const unitB = map.units.get(vector); + return ( + unitB && + map.isOpponent(unitB, unitA) && + unitA.info.canSabotageUnitType(unitB.info) + ); + }) + : [], + ); +} diff --git a/athena/lib/getUnitValue.tsx b/athena/lib/getUnitValue.tsx new file mode 100644 index 00000000..d618385b --- /dev/null +++ b/athena/lib/getUnitValue.tsx @@ -0,0 +1,9 @@ +import Player from '../map/Player.tsx'; +import Unit from '../map/Unit.tsx'; + +export default function getUnitValue(unit: Unit, player: Player) { + const cost = unit.info.getCostFor(player); + return cost < Number.POSITIVE_INFINITY + ? cost + : (unit.info.defense + unit.info.configuration.fuel) * 20; +} diff --git a/athena/lib/getUnitsByPositions.tsx b/athena/lib/getUnitsByPositions.tsx new file mode 100644 index 00000000..ae497948 --- /dev/null +++ b/athena/lib/getUnitsByPositions.tsx @@ -0,0 +1,19 @@ +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export type UnitsWithPosition = ReadonlyMap; + +export default function getUnitsByPositions( + map: MapData, + positions: ReadonlyArray, +): UnitsWithPosition { + const result = new Map(); + for (const position of positions) { + const unit = map.units.get(position); + if (unit) { + result.set(position, unit); + } + } + return result; +} diff --git a/athena/lib/getUnitsToHealOnBuildings.tsx b/athena/lib/getUnitsToHealOnBuildings.tsx new file mode 100644 index 00000000..2fd72d6a --- /dev/null +++ b/athena/lib/getUnitsToHealOnBuildings.tsx @@ -0,0 +1,19 @@ +import Player, { PlayerID } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; + +export default function getUnitsToHealOnBuildings( + map: MapData, + player: Player | PlayerID, +) { + return map.units.filter((unit, vector) => { + if (!map.matchesPlayer(unit, player)) { + return false; + } + const building = map.buildings.get(vector); + return ( + building && + map.matchesPlayer(building, unit) && + building.info.canHeal(unit.info) + ); + }); +} diff --git a/athena/lib/getUnitsToRefill.tsx b/athena/lib/getUnitsToRefill.tsx new file mode 100644 index 00000000..24bc6f0e --- /dev/null +++ b/athena/lib/getUnitsToRefill.tsx @@ -0,0 +1,39 @@ +import { Ability } from '../info/Unit.tsx'; +import Player from '../map/Player.tsx'; +import Vector, { isVector } from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import { VisionT } from '../Vision.tsx'; +import { UnitsWithPosition } from './getUnitsByPositions.tsx'; + +export default function getUnitsToRefill( + map: MapData, + vision: VisionT, + player: Player, + supplyUnits: Vector | ReadonlyArray, +): UnitsWithPosition { + const unitsWithPosition = new Map(); + const list = isVector(supplyUnits) + ? supplyUnits.adjacent().map((adjacent) => [supplyUnits, adjacent]) + : supplyUnits.flatMap((vector) => + vector.adjacent().flatMap((adjacent) => [[vector, adjacent]]), + ); + + for (const [parent, vector] of list) { + const parentUnit = parent && map.units.get(parent); + const unit = vector && map.units.get(vector); + if ( + unit && + parentUnit?.info.hasAbility(Ability.Supply) && + parentUnit.info.configuration.supplyTypes?.has(unit.info.type) && + map.matchesPlayer(player, unit) && + vision.isVisible(map, vector) && + (unit.fuel < unit.info.configuration.fuel || + [...(unit.info.getAmmunitionSupply() || [])]?.some( + ([weapon, supply]) => supply > (unit.ammo?.get(weapon) || 0), + )) + ) { + unitsWithPosition.set(vector, unit); + } + } + return unitsWithPosition; +} diff --git a/athena/lib/getVectorRadius.tsx b/athena/lib/getVectorRadius.tsx new file mode 100644 index 00000000..bdff4e9f --- /dev/null +++ b/athena/lib/getVectorRadius.tsx @@ -0,0 +1,39 @@ +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; + +export default function getVectorRadius( + map: MapData, + vector: Vector, + radius: number, +) { + const vectors = new Set(); + for (let x = 0; x <= radius; x++) { + for (let y = 0; y <= radius - x; y++) { + const s1 = { x: vector.x + x, y: vector.y + y }; + const v1 = map.contains(s1) && vec(s1.x, s1.y); + + const s2 = { x: vector.x + x, y: vector.y - y }; + const v2 = map.contains(s2) && vec(s2.x, s2.y); + + const s3 = { x: vector.x - x, y: vector.y + y }; + const v3 = map.contains(s3) && vec(s3.x, s3.y); + + const s4 = { x: vector.x - x, y: vector.y - y }; + const v4 = map.contains(s4) && vec(s4.x, s4.y); + if (v1) { + vectors.add(v1); + } + if (v2) { + vectors.add(v2); + } + if (v3) { + vectors.add(v3); + } + if (v4) { + vectors.add(v4); + } + } + } + return [...vectors]; +} diff --git a/athena/lib/hasLeader.tsx b/athena/lib/hasLeader.tsx new file mode 100644 index 00000000..16099520 --- /dev/null +++ b/athena/lib/hasLeader.tsx @@ -0,0 +1,20 @@ +import { UnitInfo } from '../info/Unit.tsx'; +import { PlayerID } from '../map/Player.tsx'; +import Unit, { TransportedUnit } from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; + +export default function hasLeader( + map: MapData, + player: PlayerID, + info: UnitInfo, +) { + const hasLeader = (unit: T): boolean => { + if (unit.id === info.id && unit.player === player && unit.isLeader()) { + return true; + } + + return unit.transports?.length ? unit.transports.some(hasLeader) : false; + }; + + return map.units.some(hasLeader); +} diff --git a/athena/lib/hasLowAmmoSupply.tsx b/athena/lib/hasLowAmmoSupply.tsx new file mode 100644 index 00000000..e69b11ad --- /dev/null +++ b/athena/lib/hasLowAmmoSupply.tsx @@ -0,0 +1,10 @@ +import Unit from '../map/Unit.tsx'; + +export default function hasLowAmmoSupply( + unit: Unit, + weaponId: number, + currentSupply: number, +): boolean { + const supply = unit.info.attack.weapons?.get(weaponId)?.supply || 0; + return !!(supply && currentSupply <= supply * 0.3); +} diff --git a/athena/lib/indexToSpriteVector.tsx b/athena/lib/indexToSpriteVector.tsx new file mode 100644 index 00000000..efcc5501 --- /dev/null +++ b/athena/lib/indexToSpriteVector.tsx @@ -0,0 +1,8 @@ +import SpriteVector from '../map/SpriteVector.tsx'; + +export default function indexToVector( + index: number, + width: number, +): SpriteVector { + return new SpriteVector((index % width) + 1, Math.floor(index / width) + 1); +} diff --git a/athena/lib/indexToVector.tsx b/athena/lib/indexToVector.tsx new file mode 100644 index 00000000..0929ee39 --- /dev/null +++ b/athena/lib/indexToVector.tsx @@ -0,0 +1,6 @@ +import Vector from '../map/Vector.tsx'; +import vec from './../map/vec.tsx'; + +export default function indexToVector(index: number, width: number): Vector { + return vec((index % width) + 1, Math.floor(index / width) + 1); +} diff --git a/athena/lib/isAmphibiousOnLand.tsx b/athena/lib/isAmphibiousOnLand.tsx new file mode 100644 index 00000000..372cadf5 --- /dev/null +++ b/athena/lib/isAmphibiousOnLand.tsx @@ -0,0 +1,6 @@ +import { isSea, TileInfo } from '../info/Tile.tsx'; +import Entity, { EntityType } from '../map/Entity.tsx'; + +export default function isAmphibiousOnLand(entity: Entity, tileInfo: TileInfo) { + return entity.info.type === EntityType.Amphibious && !isSea(tileInfo.id); +} diff --git a/athena/lib/isFuelConsumingUnit.tsx b/athena/lib/isFuelConsumingUnit.tsx new file mode 100644 index 00000000..9316a823 --- /dev/null +++ b/athena/lib/isFuelConsumingUnit.tsx @@ -0,0 +1,15 @@ +import { TileInfo } from '../info/Tile.tsx'; +import Entity, { getEntityGroup } from '../map/Entity.tsx'; +import isAmphibiousOnLand from './isAmphibiousOnLand.tsx'; + +export default function isFuelConsumingUnit( + entity: Entity, + tileInfo: TileInfo, +): boolean { + const group = getEntityGroup(entity); + if (group === 'naval' && isAmphibiousOnLand(entity, tileInfo)) { + return false; + } + + return group === 'air' || group === 'naval'; +} diff --git a/athena/lib/isPvP.tsx b/athena/lib/isPvP.tsx new file mode 100644 index 00000000..a930e3b3 --- /dev/null +++ b/athena/lib/isPvP.tsx @@ -0,0 +1,6 @@ +import { isHumanPlayer } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; + +export default function isPvP(map: MapData) { + return map.getPlayers().filter(isHumanPlayer).length > 1; +} diff --git a/athena/lib/matchesActiveType.tsx b/athena/lib/matchesActiveType.tsx new file mode 100644 index 00000000..67502772 --- /dev/null +++ b/athena/lib/matchesActiveType.tsx @@ -0,0 +1,17 @@ +import { ActiveUnitTypes } from '../info/Skill.tsx'; +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; + +export default function matchesActiveType( + types: ActiveUnitTypes | undefined, + unit: Unit, + vector: Vector | null, +) { + return !!( + types && + (types === 'all' || + types.has(unit.id) || + types.has(unit.info.movementType) || + (vector && types.has(vector))) + ); +} diff --git a/athena/lib/matchesPlayerList.tsx b/athena/lib/matchesPlayerList.tsx new file mode 100644 index 00000000..c256730a --- /dev/null +++ b/athena/lib/matchesPlayerList.tsx @@ -0,0 +1,8 @@ +import { PlayerID, PlayerIDs } from '../map/Player.tsx'; + +export default function matchesPlayerList( + players: PlayerIDs | undefined, + player: PlayerID, +) { + return !players?.length || players.includes(player); +} diff --git a/athena/lib/maybeConvertPlayer.tsx b/athena/lib/maybeConvertPlayer.tsx new file mode 100644 index 00000000..2944f0e1 --- /dev/null +++ b/athena/lib/maybeConvertPlayer.tsx @@ -0,0 +1,12 @@ +import { Zombie } from '../info/Unit.tsx'; +import Unit from '../map/Unit.tsx'; + +export default function maybeConvertPlayer( + unit: Unit, + attackingUnit: Unit | null | undefined, + state: 'recover' | 'complete', +) { + return attackingUnit && attackingUnit.id === Zombie.id + ? unit.setPlayer(attackingUnit.player)[state]() + : unit; +} diff --git a/athena/lib/mergeTeams.tsx b/athena/lib/mergeTeams.tsx new file mode 100644 index 00000000..743a613b --- /dev/null +++ b/athena/lib/mergeTeams.tsx @@ -0,0 +1,30 @@ +import { Teams } from '../map/Team.tsx'; +import MapData from '../MapData.tsx'; + +export default function mergeTeams(map: MapData, newTeams: Teams | undefined) { + let { teams } = map; + if (!newTeams) { + return map; + } + + for (const [id, newTeam] of newTeams) { + let team = teams.get(id) || newTeam; + if (teams.has(id)) { + for (const [playerId, player] of newTeam.players) { + if (player.teamId !== id) { + throw new Error( + `mergeTeams: 'player.teamId' does not match the team's 'id'.`, + ); + } + + if (!teams.get(id)?.players.has(playerId)) { + team = team.copy({ + players: team.players.set(playerId, player), + }); + } + } + } + teams = teams.set(id, team); + } + return map.copy({ teams }); +} diff --git a/athena/lib/refillUnits.tsx b/athena/lib/refillUnits.tsx new file mode 100644 index 00000000..186b4cdb --- /dev/null +++ b/athena/lib/refillUnits.tsx @@ -0,0 +1,15 @@ +import MapData from '../MapData.tsx'; +import { UnitsWithPosition } from './getUnitsByPositions.tsx'; + +export default function refillUnits( + map: MapData, + unitsToRefill: UnitsWithPosition, +): MapData { + return map.copy({ + units: map.units.withMutations((units) => { + for (const [vector, unit] of unitsToRefill) { + units.set(vector, unit.refill()); + } + }), + }); +} diff --git a/athena/lib/removeLeader.tsx b/athena/lib/removeLeader.tsx new file mode 100644 index 00000000..88a504e5 --- /dev/null +++ b/athena/lib/removeLeader.tsx @@ -0,0 +1,28 @@ +import { UnitInfo } from '../info/Unit.tsx'; +import { PlayerID } from '../map/Player.tsx'; +import Unit, { TransportedUnit } from '../map/Unit.tsx'; +import MapData from '../MapData.tsx'; + +export default function removeLeader( + map: MapData, + player: PlayerID, + info: UnitInfo, +): MapData { + const removeLeader = (unit: T): T => { + if (unit.id === info.id && unit.player === player && unit.isLeader()) { + unit = unit.withName(null) as T; + } + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map(removeLeader), + }) as T; + } + + return unit; + }; + + return map.copy({ + units: map.units.map(removeLeader), + }); +} diff --git a/athena/lib/resizeMap.tsx b/athena/lib/resizeMap.tsx new file mode 100644 index 00000000..ad70aab8 --- /dev/null +++ b/athena/lib/resizeMap.tsx @@ -0,0 +1,102 @@ +import { generateRandomMap } from '../generator/MapGenerator.tsx'; +import { Decorator } from '../info/Decorator.tsx'; +import { getTileInfo, TileTypes } from '../info/Tile.tsx'; +import { DecoratorsPerSide } from '../map/Configuration.tsx'; +import { PlainEntitiesList } from '../map/PlainMap.tsx'; +import { decodeDecorators } from '../map/Serialization.tsx'; +import SpriteVector from '../map/SpriteVector.tsx'; +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData, { SizeVector } from '../MapData.tsx'; +import { winConditionHasVectors } from '../WinConditions.tsx'; +import { verifyMap } from './verifyTiles.tsx'; +import withModifiers from './withModifiers.tsx'; + +export type ResizeOrigin = 'top' | 'right' | 'bottom' | 'left'; + +export default function resizeMap( + mapData: MapData, + size: SizeVector, + origin: Set, + fill?: number, +) { + const offsetX = origin.has('left') ? mapData.size.width - size.width : 0; + const offsetY = origin.has('top') ? mapData.size.height - size.height : 0; + const fillTile = fill ? getTileInfo(fill) : null; + const randomMap = generateRandomMap( + size, + fillTile && fillTile.type & TileTypes.Area ? [fillTile] : null, + ); + const map = mapData.reduceEachField((map, vector, index) => { + vector = new SpriteVector(vector.x, vector.y).left(offsetX).up(offsetY); + if (randomMap.contains(vector)) { + map[randomMap.getTileIndex(vector)] = mapData.map[index]; + } + return map; + }, randomMap.map.slice()); + + const decorators = mapData.reduceEachDecorator( + (decorators, decorator, subVector) => { + subVector = subVector + .left(offsetX * DecoratorsPerSide) + .up(offsetY * DecoratorsPerSide); + return randomMap.contains( + new SpriteVector( + Math.floor((subVector.x - 1) / DecoratorsPerSide) + 1, + Math.floor((subVector.y - 1) / DecoratorsPerSide) + 1, + ), + ) + ? [ + ...decorators, + [subVector.x, subVector.y, decorator.id] as [ + number, + number, + number, + ], + ] + : decorators; + }, + [] as PlainEntitiesList, + ); + + const winConditions = mapData.config.winConditions.map((condition) => { + return winConditionHasVectors(condition) + ? { + ...condition, + vectors: new Set( + [...condition.vectors] + .map((vector) => + new SpriteVector(vector.x, vector.y).left(offsetX).up(offsetY), + ) + .filter((vector) => size.contains(vector)) + .map((vector) => vec(vector.x, vector.y)), + ), + } + : condition; + }); + + const contains = (_: unknown, vector: Vector): boolean => + size.contains(vector); + return verifyMap( + withModifiers( + mapData.copy({ + buildings: mapData.buildings + .mapKeys((vector) => + new SpriteVector(vector.x, vector.y).left(offsetX).up(offsetY), + ) + .filter(contains) + .mapKeys((vector) => vec(vector.x, vector.y)), + config: mapData.config.copy({ winConditions }), + decorators: decodeDecorators(size, decorators), + map, + size, + units: mapData.units + .mapKeys((vector) => + new SpriteVector(vector.x, vector.y).left(offsetX).up(offsetY), + ) + .filter(contains) + .mapKeys((vector) => vec(vector.x, vector.y)), + }), + ), + ); +} diff --git a/athena/lib/shouldRemoveUnit.tsx b/athena/lib/shouldRemoveUnit.tsx new file mode 100644 index 00000000..769cca96 --- /dev/null +++ b/athena/lib/shouldRemoveUnit.tsx @@ -0,0 +1,18 @@ +import { PlayerID } from '../map/Player.tsx'; +import Unit from '../map/Unit.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import isFuelConsumingUnit from './isFuelConsumingUnit.tsx'; + +export default function shouldRemoveUnit( + map: MapData, + vector: Vector, + unit: Unit, + player: PlayerID, +) { + return ( + map.matchesPlayer(unit, player) && + !unit.hasFuel() && + isFuelConsumingUnit(unit, map.getTileInfo(vector)) + ); +} diff --git a/athena/lib/singleTilesToModifiers.tsx b/athena/lib/singleTilesToModifiers.tsx new file mode 100644 index 00000000..797f8835 --- /dev/null +++ b/athena/lib/singleTilesToModifiers.tsx @@ -0,0 +1,18 @@ +import { Bridge, getTile, RailBridge } from '../info/Tile.tsx'; +import MapData from '../MapData.tsx'; +import { Modifier } from './Modifier.tsx'; + +export function singleTilesToModifiers(map: MapData) { + return map.copy({ + modifiers: map.mapFields((_, index) => { + const tile = map.map[index]; + if ( + getTile(tile, Bridge.style.layer) === Bridge.id || + getTile(tile, RailBridge.style.layer) === RailBridge.id + ) { + return [Modifier.Vertical, Modifier.HorizontalCrossing]; + } + return map.modifiers[index]; + }), + }); +} diff --git a/athena/lib/startGame.tsx b/athena/lib/startGame.tsx new file mode 100644 index 00000000..1ec5ec69 --- /dev/null +++ b/athena/lib/startGame.tsx @@ -0,0 +1,27 @@ +import MapData from '../MapData.tsx'; +import assignUnitNames from './assignUnitNames.tsx'; +import calculateFunds from './calculateFunds.tsx'; +import updatePlayer from './updatePlayer.tsx'; + +export default function startGame(map: MapData): MapData { + map = map.copy({ + teams: map.teams.map((team) => + team.copy({ + players: team.players.map((player) => + player.setFunds(map.config.seedCapital).resetStatistics(), + ), + }), + ), + }); + const player = map.getCurrentPlayer(); + return assignUnitNames( + map.copy({ + buildings: map.buildings.map((building) => building.recover()), + teams: updatePlayer( + map.teams, + player.modifyFunds(calculateFunds(map, player)), + ), + units: map.units.map((unit) => unit.ensureValidAttributes().recover()), + }), + ); +} diff --git a/athena/lib/updateActivePlayers.tsx b/athena/lib/updateActivePlayers.tsx new file mode 100644 index 00000000..501ecbd2 --- /dev/null +++ b/athena/lib/updateActivePlayers.tsx @@ -0,0 +1,29 @@ +import Player, { Bot, HumanPlayer, PlayerID } from '../map/Player.tsx'; +import MapData from '../MapData.tsx'; +import updatePlayers from './updatePlayers.tsx'; + +export default function updateActivePlayers( + map: MapData, + createBot = (player: Player) => Bot.from(player, `Bot ${player.id}`), + currentPlayerID?: PlayerID, + userId?: string, +) { + const currentPlayer = + currentPlayerID && map.active.includes(currentPlayerID) + ? currentPlayerID + : map.active[0]; + + return map.copy({ + currentPlayer, + teams: updatePlayers( + map.teams, + map + .getPlayers() + .map((player) => + player.id === currentPlayer && userId + ? HumanPlayer.from(player, userId) + : createBot(player), + ), + ), + }); +} diff --git a/athena/lib/updatePlayer.tsx b/athena/lib/updatePlayer.tsx new file mode 100644 index 00000000..68f9f359 --- /dev/null +++ b/athena/lib/updatePlayer.tsx @@ -0,0 +1,12 @@ +import Player from '../map/Player.tsx'; +import { Teams } from '../map/Team.tsx'; + +export default function updatePlayer(teams: Teams, player: Player): Teams { + const team = teams.get(player.teamId); + return team + ? teams.set( + player.teamId, + team.copy({ players: team.players.set(player.id, player) }), + ) + : teams; +} diff --git a/athena/lib/updatePlayers.tsx b/athena/lib/updatePlayers.tsx new file mode 100644 index 00000000..c13ea4ff --- /dev/null +++ b/athena/lib/updatePlayers.tsx @@ -0,0 +1,13 @@ +import Player from '../map/Player.tsx'; +import { Teams } from '../map/Team.tsx'; +import updatePlayer from './updatePlayer.tsx'; + +export default function updatePlayers( + teams: Teams, + players: ReadonlyArray, +): Teams { + return players.reduce( + (teams, player) => (player ? updatePlayer(teams, player) : teams), + teams, + ); +} diff --git a/athena/lib/validateMap.tsx b/athena/lib/validateMap.tsx new file mode 100644 index 00000000..bee8226f --- /dev/null +++ b/athena/lib/validateMap.tsx @@ -0,0 +1,420 @@ +import isPositiveInteger from '@deities/hephaestus/isPositiveInteger.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { + Behavior, + getBuildingInfo, + House, + mapBuildings, + mapBuildingsWithContentRestriction, + MaxSkills, +} from '../info/Building.tsx'; +import { Skill, Skills } from '../info/Skill.tsx'; +import { getTileInfo } from '../info/Tile.tsx'; +import { + getUnitInfo, + mapUnits, + mapUnitsWithContentRestriction, + Pioneer, +} from '../info/Unit.tsx'; +import { Biomes } from '../map/Biome.tsx'; +import Building from '../map/Building.tsx'; +import { + DecoratorsPerSide, + MaxHealth, + MaxSize, +} from '../map/Configuration.tsx'; +import Entity from '../map/Entity.tsx'; +import Player, { + PlaceholderPlayer, + PlayerID, + toPlayerID, +} from '../map/Player.tsx'; +import Team, { toTeamArray } from '../map/Team.tsx'; +import Unit, { TransportedUnit } from '../map/Unit.tsx'; +import vec from '../map/vec.tsx'; +import MapData from '../MapData.tsx'; +import { + dropInactivePlayersFromWinConditions, + validateWinConditions, +} from '../WinConditions.tsx'; +import canBuild from './canBuild.tsx'; +import canDeploy from './canDeploy.tsx'; +import canPlaceDecorator from './canPlaceDecorator.tsx'; +import canPlaceTile from './canPlaceTile.tsx'; +import getActivePlayers from './getActivePlayers.tsx'; +import indexToVector from './indexToVector.tsx'; +import validateTeams, { TeamsList } from './validateTeams.tsx'; +import withModifiers from './withModifiers.tsx'; + +export type ErrorReason = + | 'inactive-players' + | 'invalid-configuration' + | 'invalid-decorators' + | 'invalid-entities' + | 'invalid-funds' + | 'invalid-map' + | 'invalid-size' + | 'invalid-teams' + | 'invalid-tiles' + | 'invalid-win-conditions' + | 'players'; + +const validateMapConfig = (map: MapData) => { + const { config } = map; + const { + blocklistedBuildings, + blocklistedUnits, + fog, + multiplier, + seedCapital, + } = config; + if (typeof fog !== 'boolean') { + return false; + } + if (!isPositiveInteger(multiplier)) { + return false; + } + if (!isPositiveInteger(seedCapital) && seedCapital !== 0) { + return false; + } + + if (!Biomes.includes(config.biome)) { + return false; + } + + for (const buildingId of blocklistedBuildings) { + if (!getBuildingInfo(buildingId)) { + return false; + } + } + + for (const unitId of blocklistedUnits) { + if (!getUnitInfo(unitId)) { + return false; + } + } + + return true; +}; + +const validFuel = (unit: Unit | TransportedUnit) => + unit.fuel >= 0 && unit.fuel <= unit.info.configuration.fuel; + +const invalidSkills = (building: Building) => + !!( + building.skills?.size && + (!building.info.hasBehavior(Behavior.SellSkills) || + building.skills.size > MaxSkills || + ![...building.skills].every((skill) => Skills.has(skill))) + ); + +const validEntity = (entity: Entity | TransportedUnit): boolean => { + toPlayerID(entity.player); + + if (entity.label) { + toPlayerID(entity.label); + } + + return entity.health >= 1 && entity.health <= MaxHealth; +}; + +const validateBuilding = (building: Building) => { + const isNeutral = building.player === 0; + if ( + (building.info.isStructure() && !isNeutral) || + (building.info.isHQ() && isNeutral) + ) { + return false; + } + + return !!( + getBuildingInfo(building.id) && + validEntity(building) && + building.hasValidBehaviors() && + !invalidSkills(building) + ); +}; + +const addLeader = ( + leaders: Map>, + player: PlayerID, + id: number, +) => { + const set = leaders.get(player); + if (set) { + set.add(id); + } else { + leaders.set(player, new Set([id])); + } +}; + +const validateUnit = ( + unit: Unit | TransportedUnit, + leaders: Map>, + player?: PlayerID, +): boolean => { + if (unit.ammo) { + const seenWeapons = new Set(); + const supply = unit.info.getAmmunitionSupply(); + if ( + !supply || + unit.ammo.size !== supply.size || + ![...unit.ammo].every(([weapon, currentSupply]) => { + if (seenWeapons.has(weapon)) { + return false; + } + seenWeapons.add(weapon); + + const supplyAmount = supply?.get(weapon) || Number.POSITIVE_INFINITY; + return currentSupply >= 0 && currentSupply <= supplyAmount; + }) + ) { + return false; + } + } + + if (player != null && unit.player !== player) { + return false; + } + + if (unit.isLeader()) { + if (unit.player === 0 || leaders.get(unit.player)?.has(unit.id)) { + return false; + } + addLeader(leaders, unit.player, unit.id); + } + + return !!( + getUnitInfo(unit.id) && + validEntity(unit) && + validFuel(unit) && + unit.hasValidBehavior() && + unit.hasValidName() && + (!unit.transports || + unit.transports.every((unit) => validateUnit(unit, leaders, unit.player))) + ); +}; + +export default function validateMap( + map: MapData, + newTeams?: TeamsList, + hasContentRestrictions?: boolean, + skills?: ReadonlySet, +): [MapData, null] | [null, ErrorReason?] { + if (!validateMapConfig(map)) { + return [null, 'invalid-configuration']; + } + + const { + size: { height, width }, + } = map; + + try { + const tiles = map.map.filter((id) => getTileInfo(id, 0)); + + if ( + !isPositiveInteger(width) || + !isPositiveInteger(height) || + width > MaxSize || + height > MaxSize || + width * height !== tiles.length + ) { + return [null, 'invalid-size']; + } + + let invalidReason: string | null = null; + map.forEachField((_, index) => { + const field = map.map[index]; + const modifierField = map.modifiers[index]; + if (typeof field === 'number' && typeof modifierField !== 'number') { + invalidReason = 'invalid-map'; + } + + if ( + Array.isArray(field) && + Array.isArray(modifierField) && + (field.length !== modifierField.length || + field.length === 1 * 1 || + modifierField.length === 1 * 1) + ) { + invalidReason = 'invalid-map'; + } + }); + + map.forEachTile((_, tile, layer) => { + if (tile.style.layer !== layer) { + invalidReason = 'invalid-tiles'; + } + }); + + if (invalidReason) { + return [null, invalidReason]; + } + } catch { + // If the above iterators throw it means that one of the tiles is invalid. + return [null, 'invalid-map']; + } + + map = withModifiers( + map.copy({ + units: map.units.map((entity) => entity.ensureValidAttributes()), + }), + ); + + if ( + map.map.some( + (tile, index) => + !canPlaceTile(map, indexToVector(index, width), getTileInfo(tile)), + ) + ) { + return [null, 'invalid-tiles']; + } + + if ( + map.reduceEachDecorator( + (success, decorator, subVector) => + !canPlaceDecorator( + map, + vec( + Math.floor((subVector.x - 1) / DecoratorsPerSide) + 1, + Math.floor((subVector.y - 1) / DecoratorsPerSide) + 1, + ), + decorator.id, + ) + ? true + : success, + false, + ) + ) { + return [null, 'invalid-decorators']; + } + + const availableBuildings = new Set( + (hasContentRestrictions + ? mapBuildingsWithContentRestriction + : mapBuildings)(({ id }) => id, skills || new Set()), + ); + const availableUnits = new Set( + (hasContentRestrictions ? mapUnitsWithContentRestriction : mapUnits)( + ({ id }) => id, + skills || new Set(), + ), + ); + + map = map.copy({ + buildings: map.buildings.filter((building) => + availableBuildings.has(building.id), + ), + units: map.units.filter((unit) => availableUnits.has(unit.id)), + }); + + const config = map.config.copy({ + blocklistedBuildings: new Set(), + blocklistedUnits: new Set(), + }); + const leaders = new Map(); + if ( + map.buildings.some( + (building, vector) => + !map.contains(vector) || + !validateBuilding(building) || + !canBuild( + map.copy({ + buildings: map.buildings.delete(vector), + config, + currentPlayer: + building.player === 0 ? map.currentPlayer : building.player, + }), + building.info, + building.player, + vector, + true, + ), + ) || + map.units.some( + (unit, vector) => + !map.contains(vector) || + !validateUnit(unit, leaders) || + !canDeploy( + map.copy({ config, units: map.units.delete(vector) }), + unit.info, + vector, + true, + ), + ) + ) { + return [null, 'invalid-entities']; + } + + if ( + !map.units.size && + !map.buildings.some( + (building) => + building.player !== 0 && building.info.configuration.funds > 0, + ) && + map.config.seedCapital < Pioneer.getCostFor(null) + House.configuration.cost + ) { + return [null, 'invalid-funds']; + } + + const active = getActivePlayers(map); + if (active.length <= 1) { + return [null, 'players']; + } + + const activePlayers = new Set(active); + if ( + map.buildings.filter( + (building) => + building.player !== 0 && !activePlayers.has(building.player), + ).size || + map.units.filter( + (unit) => unit.player !== 0 && !activePlayers.has(unit.player), + ).size + ) { + return [null, 'inactive-players']; + } + + const teams = ImmutableMap( + active.map( + (id) => + [ + id, + new Team( + id, + '', + ImmutableMap().set( + toPlayerID(id), + new PlaceholderPlayer( + toPlayerID(id), + id, + 0, + map.getPlayer(id).skills, + ), + ), + ), + ] as const, + ), + ); + + const newMap = map.copy({ + active, + buildings: map.buildings.map((entity) => entity.recover()), + config: map.config.copy({ + winConditions: dropInactivePlayersFromWinConditions( + map.config.winConditions, + new Set(active), + ), + }), + currentPlayer: active[0], + round: 1, + teams, + units: map.units.map((entity) => entity.recover()), + }); + + if (!validateWinConditions(newMap)) { + return [null, 'invalid-win-conditions']; + } + + return validateTeams(newMap, newTeams || toTeamArray(map.teams)); +} diff --git a/athena/lib/validateSkills.tsx b/athena/lib/validateSkills.tsx new file mode 100644 index 00000000..545452b6 --- /dev/null +++ b/athena/lib/validateSkills.tsx @@ -0,0 +1,29 @@ +import { Skill, Skills } from '../info/Skill.tsx'; +import MapData from '../MapData.tsx'; + +export default function validateSkills( + { skillSlots, skills }: { skillSlots: number; skills: Iterable }, + playerSkills: Iterable | null | undefined, + map: MapData, + hasSkills: boolean, +): ReadonlySet { + const validatedSkills = new Set(); + const skillSet = new Set(playerSkills); + + if (!hasSkills || !skillSet.size || skillSet.size > skillSlots) { + return validatedSkills; + } + + const existingSkills = new Set(skills); + for (const skill of skillSet) { + if ( + Skills.has(skill) && + existingSkills.has(skill) && + !map.config.blocklistedSkills.has(skill) + ) { + validatedSkills.add(skill); + } + } + + return validatedSkills; +} diff --git a/athena/lib/validateTeams.tsx b/athena/lib/validateTeams.tsx new file mode 100644 index 00000000..86e32708 --- /dev/null +++ b/athena/lib/validateTeams.tsx @@ -0,0 +1,86 @@ +import ImmutableMap from '@nkzw/immutable-map'; +import { Skills } from '../info/Skill.tsx'; +import { DefaultMapSkillSlots } from '../map/Configuration.tsx'; +import { PlaceholderPlayer, PlayerID, toPlayerID } from '../map/Player.tsx'; +import Team from '../map/Team.tsx'; +import MapData from '../MapData.tsx'; +import validateSkills from './validateSkills.tsx'; + +export type TeamsList = ReadonlyArray< + Readonly<{ id: number; players: ReadonlyArray }> +>; + +export default function validateTeams( + map: MapData, + teams: TeamsList, +): [MapData, null] | [null, 'invalid-teams'] { + if (teams.length > map?.getPlayers().length) { + return [null, 'invalid-teams']; + } + + const validatedTeams = teams.map(({ id, players }) => ({ + id: toPlayerID(id), + players: players.map(toPlayerID), + })); + + const uniquePlayers = new Set(); + if ( + validatedTeams.filter(({ players }) => players.length).length < 2 || + !validatedTeams.every(({ players }) => + players.every((player) => { + if (uniquePlayers.has(player)) { + return false; + } + uniquePlayers.add(player); + return true; + }), + ) || + uniquePlayers.size !== map.active.length + ) { + return [null, 'invalid-teams']; + } + + for (const active of map.active) { + uniquePlayers.delete(active); + } + + if (uniquePlayers.size) { + return [null, 'invalid-teams']; + } + + return [ + map.copy({ + teams: ImmutableMap( + validatedTeams + .filter(({ players }) => players.length) + .map(({ id: teamId, players }) => [ + teamId, + new Team( + teamId, + '', + ImmutableMap( + players.map( + (id) => + [ + id, + new PlaceholderPlayer( + id, + teamId, + 0, + validateSkills( + { skillSlots: DefaultMapSkillSlots, skills: Skills }, + map.getPlayer(id).skills, + map, + true, + ), + ), + ] as const, + ), + ).sortBy(({ id }) => id), + ), + ]), + ).sortBy(({ id }) => id), + }), + null, + ]; +} diff --git a/athena/lib/verifyTiles.tsx b/athena/lib/verifyTiles.tsx new file mode 100644 index 00000000..646c2c55 --- /dev/null +++ b/athena/lib/verifyTiles.tsx @@ -0,0 +1,160 @@ +import { getTileInfo, Plain, TileLayer } from '../info/Tile.tsx'; +import { DecoratorsPerSide } from '../map/Configuration.tsx'; +import vec from '../map/vec.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import writeTile from '../mutation/writeTile.tsx'; +import canBuild from './canBuild.tsx'; +import canPlaceDecorator from './canPlaceDecorator.tsx'; +import canPlaceTile from './canPlaceTile.tsx'; +import getDecoratorIndex from './getDecoratorIndex.tsx'; +import getModifier from './getModifier.tsx'; + +export default function verifyTiles(map: MapData, vectors: Set) { + const newMap = map.map.slice(); + const newModifiers = map.modifiers.slice(); + let reconcile = true; + + while (reconcile) { + reconcile = false; + for (let i = 0; i <= 1; i++) { + const layer = i as TileLayer; + // First ensure that the map layout is not problematic. + vectors.forEach((vector) => { + const index = map.getTileIndex(vector); + const tile = map.getTile(vector, layer); + if (!tile) { + return; + } + + const info = getTileInfo(tile); + writeTile( + newMap, + newModifiers, + index, + info, + getModifier(map, vector, info, layer), + ); + + // If the tile has a fallback tile, check if placing this tile on top of the + // fallback tile is still legal. If not, replace it with the fallback. + const canPlaceOnFallbackTile = () => { + const { fallback } = info.style; + if (!fallback) { + return true; + } + + const fallbackMap = newMap.slice(); + const fallbackModifiers = newModifiers.slice(); + writeTile( + fallbackMap, + fallbackModifiers, + index, + fallback, + getModifier(map, vector, fallback, layer), + ); + return canPlaceTile( + map.copy({ + map: fallbackMap, + modifiers: fallbackModifiers.slice(), + }), + vector, + info, + ); + }; + + if ( + !canPlaceTile( + map.copy({ modifiers: newModifiers.slice() }), + vector, + info, + ) || + !canPlaceOnFallbackTile() + ) { + reconcile = true; + writeTile( + newMap, + newModifiers, + index, + layer === 1 ? null : info.style.fallback || Plain, + ); + map = map.copy({ + map: newMap.slice(), + modifiers: newModifiers.slice(), + }); + + const building = map.buildings.get(vector); + const mapWithoutBuilding = map.copy({ + buildings: map.buildings.delete(vector), + }); + if ( + building && + !canBuild( + mapWithoutBuilding, + building.info, + building.player, + vector, + true, + ) + ) { + map = mapWithoutBuilding; + } + } + }); + + // Then recompute all relevant modifiers again. + vectors.forEach((vector) => { + const tile = map.getTile(vector, layer); + if (tile) { + const info = getTileInfo(tile); + writeTile( + newMap, + newModifiers, + map.getTileIndex(vector), + info, + getModifier(map, vector, info, layer), + ); + } + }); + } + } + + map = map.copy({ + map: newMap.slice(), + modifiers: newModifiers.slice(), + }); + + const newDecorators = map.decorators.slice(); + const decoratorSize = map.size.toDecoratorSizeVector(); + map.forEachField((vector) => { + for (let x = 1; x <= DecoratorsPerSide; x++) { + for (let y = 1; y <= DecoratorsPerSide; y++) { + const subVector = vec( + (vector.x - 1) * DecoratorsPerSide + x, + (vector.y - 1) * DecoratorsPerSide + y, + ); + const index = getDecoratorIndex(subVector, decoratorSize); + const decorator = newDecorators[index]; + if ( + decorator && + !canPlaceDecorator( + map, + vector, + decorator, + () => Number.POSITIVE_INFINITY, + ) + ) { + newDecorators[index] = 0; + } + } + } + }); + + return map.copy({ + decorators: newDecorators.slice(), + }); +} + +export function verifyMap(map: MapData) { + return verifyTiles(map, new Set(map.mapFields((vector) => vector))); +} diff --git a/athena/lib/withModifiers.tsx b/athena/lib/withModifiers.tsx new file mode 100644 index 00000000..2556be8f --- /dev/null +++ b/athena/lib/withModifiers.tsx @@ -0,0 +1,6 @@ +import MapData from '../MapData.tsx'; +import { getAllModifiers } from './getModifier.tsx'; + +export default function withModifiers(map: MapData) { + return map.copy({ modifiers: getAllModifiers(map) }); +} diff --git a/athena/map/AIBehavior.tsx b/athena/map/AIBehavior.tsx new file mode 100644 index 00000000..cb310927 --- /dev/null +++ b/athena/map/AIBehavior.tsx @@ -0,0 +1,15 @@ +export enum AIBehavior { + Attack = 0, + Defense = 1, + Stay = 2, + Adaptive = 3, + Passive = 4, +} + +export const AIBehaviors: ReadonlySet = new Set([ + AIBehavior.Attack, + AIBehavior.Defense, + AIBehavior.Stay, + AIBehavior.Adaptive, + AIBehavior.Passive, +]); diff --git a/athena/map/Biome.tsx b/athena/map/Biome.tsx new file mode 100644 index 00000000..053293af --- /dev/null +++ b/athena/map/Biome.tsx @@ -0,0 +1,59 @@ +// Keep in sync with `schema.graphql`. +export enum Biome { + Grassland = 0, + Desert = 1, + Snow = 2, + Swamp = 3, + Spaceship = 4, + Volcano = 5, + Luna = 6, +} + +export type BiomeName = + | 'Grassland' + | 'Desert' + | 'Snow' + | 'Swamp' + | 'Spaceship' + | 'Volcano' + | 'Luna'; + +export const Biomes = [ + Biome.Grassland, + Biome.Desert, + Biome.Snow, + Biome.Swamp, + Biome.Spaceship, + Biome.Volcano, + Biome.Luna, +] as const; + +const BiomeEnum = { + [Biome.Grassland]: 'Grassland', + [Biome.Desert]: 'Desert', + [Biome.Snow]: 'Snow', + [Biome.Swamp]: 'Swamp', + [Biome.Spaceship]: 'Spaceship', + [Biome.Volcano]: 'Volcano', + [Biome.Luna]: 'Luna', +} as const; + +const biomeNameToEnum = { + Desert: Biome.Desert, + Grassland: Biome.Grassland, + Luna: Biome.Luna, + Snow: Biome.Snow, + Spaceship: Biome.Spaceship, + Swamp: Biome.Swamp, + Volcano: Biome.Volcano, +} as const; + +export function getBiomeName(biome: Biome): BiomeName { + return BiomeEnum[biome]; +} + +export function toBiome(biome: string | undefined | null): Biome | undefined { + return biome && biome in biomeNameToEnum + ? biomeNameToEnum[biome as BiomeName] + : undefined; +} diff --git a/athena/map/Building.tsx b/athena/map/Building.tsx new file mode 100644 index 00000000..16afa432 --- /dev/null +++ b/athena/map/Building.tsx @@ -0,0 +1,210 @@ +import getFirst from '@deities/hephaestus/getFirst.tsx'; +import { Barracks, BuildingInfo, getBuildingInfo } from '../info/Building.tsx'; +import { Skill } from '../info/Skill.tsx'; +import filterNullables from '../lib/filterNullables.tsx'; +import { ID } from '../MapData.tsx'; +import { AIBehavior, AIBehaviors } from './AIBehavior.tsx'; +import { Biome } from './Biome.tsx'; +import { MaxHealth } from './Configuration.tsx'; +import Entity, { PlainEntity } from './Entity.tsx'; +import Player, { PlayerID, toPlayerID } from './Player.tsx'; + +export type PlainBuilding = PlainEntity & { + readonly b?: ReadonlyArray | AIBehavior; + readonly s?: ReadonlyArray | Skill; +}; + +export default class Building extends Entity { + public readonly info: BuildingInfo; + + constructor( + id: ID, + health: number, + player: PlayerID, + completed: true | null, + label: PlayerID | null, + private readonly behaviors?: ReadonlySet | null, + public readonly skills?: ReadonlySet | null, + ) { + const buildingInfo = getBuildingInfo(id); + if (!buildingInfo) { + throw new Error(`Invalid building '${id}'.`); + } + super( + id, + health, + buildingInfo.isStructure() ? 0 : player, + completed, + label, + ); + this.info = buildingInfo; + } + + static fromJSON(building: PlainBuilding): Building { + const { b, f, h, i, l, p, s } = building; + return new Building( + i, + h, + toPlayerID(p), + f ? true : null, + l != null ? toPlayerID(l) : null, + Array.isArray(b) ? new Set(b) : b ? new Set([b]) : null, + Array.isArray(s) ? new Set(s) : s ? new Set([s]) : null, + ); + } + + capture(player: Player | PlayerID, biome?: Biome): this { + return this.copy({ + id: + this.info.isHQ() && (!biome || biome !== Biome.Spaceship) + ? Barracks.id + : this.id, + player: typeof player === 'number' ? player : player.id, + }); + } + + neutralize(biome: Biome) { + return this.player === 0 ? this : this.capture(0, biome); + } + + recover(): this { + return this.completed ? this.copy({ completed: null }) : this; + } + + complete(): this { + return !this.completed ? this.copy({ completed: true }) : this; + } + + hide(biome: Biome): this { + return this.recover().setHealth(MaxHealth).neutralize(biome); + } + + matchesBehavior(behavior: AIBehavior) { + return !!this.behaviors?.has(behavior); + } + + canBuildUnits(player: Player) { + return this.getBuildableUnits(player).length > 0; + } + + getBuildableUnits(player: Player) { + return [...this.info.getAllBuildableUnits()].filter( + (unit) => unit.getCostFor(player) < Number.POSITIVE_INFINITY, + ); + } + + getFirstAIBehavior(): AIBehavior | undefined { + return (this.behaviors && getFirst(this.behaviors)) || undefined; + } + + rotateAIBehavior() { + if (!this.behaviors || this.behaviors.size <= 1) { + return this; + } + + const behaviors = [...this.behaviors]; + behaviors.push(behaviors.shift()!); + return this.copy({ + behaviors: new Set(behaviors), + }); + } + + addAIBehavior(behavior: AIBehavior): this { + return this.copy({ + behaviors: new Set([...(this.behaviors || []), behavior]), + }); + } + + removeAIBehavior(behavior: AIBehavior): this { + const behaviors = new Set(this.behaviors); + behaviors.delete(behavior); + return this.copy({ + behaviors, + }); + } + + hasValidBehaviors() { + return ( + !this.behaviors || + ([...this.behaviors].every((behavior) => AIBehaviors.has(behavior)) && + !this.info.getAllBuildableUnits()[Symbol.iterator]().next().done) + ); + } + + withSkills(skills: ReadonlySet | null) { + return this.copy({ + skills, + }); + } + + copy({ + behaviors, + completed, + health, + id, + label, + player, + skills, + }: { + behaviors?: ReadonlySet | null; + completed?: true | null; + health?: number; + id?: ID; + label?: PlayerID | null; + player?: PlayerID; + skills?: ReadonlySet | null; + }): this { + return new Building( + id ?? this.id, + health ?? this.health, + player ?? this.player, + completed !== undefined ? completed : this.completed, + label !== undefined ? label : this.label, + behaviors ?? this.behaviors, + skills ?? this.skills, + ) as this; + } + + override toJSON(): PlainBuilding { + const { + behaviors: b, + completed: f, + health: h, + id: i, + label: l, + player: p, + skills: s, + } = this; + + return { + ...(b?.size + ? { b: b.size === 1 ? this.getFirstAIBehavior() : [...b] } + : null), + ...(s?.size + ? { s: s.size === 1 ? s.values().next().value : [...s] } + : null), + ...(f ? { f: 1 } : null), + h, + i, + ...(l ? { l } : null), + p, + }; + } + + override format(): Record { + const { behaviors, completed, health, id, label, player, skills } = this; + return filterNullables({ + /* eslint-disable sort-keys-fix/sort-keys-fix */ + id, + health, + player, + completed, + label, + behaviors, + skills, + /* eslint-enable sort-keys-fix/sort-keys-fix */ + }); + } +} + +BuildingInfo.setConstructor(Building); diff --git a/athena/map/Configuration.tsx b/athena/map/Configuration.tsx new file mode 100644 index 00000000..a2efd2b7 --- /dev/null +++ b/athena/map/Configuration.tsx @@ -0,0 +1,64 @@ +import { SizeVector } from '../MapData.tsx'; + +export const AnimationSpeed = 180; + +export const AnimationConfig = { + AnimationDuration: AnimationSpeed * 2, + ExplosionStep: AnimationSpeed / 2, + Instant: false, + MessageSpeed: AnimationSpeed * 2, + UnitAnimationStep: (AnimationSpeed * 2) / 3, + UnitMoveDuration: AnimationSpeed * 2, +} as const; + +export type AnimationConfig = Omit & + Readonly<{ Instant: boolean }>; + +export const FastAnimationConfig: AnimationConfig = { + AnimationDuration: AnimationConfig.AnimationDuration / 4, + ExplosionStep: AnimationConfig.ExplosionStep / 4, + Instant: false, + MessageSpeed: AnimationConfig.AnimationDuration / 2, + UnitAnimationStep: AnimationConfig.UnitAnimationStep / 4, + UnitMoveDuration: AnimationConfig.UnitMoveDuration / 4, +}; + +export const SlowAnimationConfig: AnimationConfig = { + AnimationDuration: AnimationConfig.AnimationDuration * 4, + ExplosionStep: AnimationConfig.ExplosionStep * 4, + Instant: false, + MessageSpeed: AnimationConfig.AnimationDuration * 2, + UnitAnimationStep: AnimationConfig.UnitAnimationStep * 4, + UnitMoveDuration: AnimationConfig.UnitMoveDuration * 4, +}; + +export const InstantAnimationConfig: AnimationConfig = { + AnimationDuration: 0, + ExplosionStep: 0, + Instant: true, + MessageSpeed: 0, + UnitAnimationStep: 0, + UnitMoveDuration: 0, +}; + +export const getDecoratorLimit = (size: SizeVector) => + (size.width * size.height * DecoratorsPerSide) / 2; + +export const DecoratorsPerSide = 4; +export const TileSize = 24; +export const DoubleSize = TileSize * 2; +export const MaxHealth = 100; +export const MinDamage = 5; +export const HealAmount = 50; +export const BuildingCover = 10; +export const MinSize = 5; +export const MaxSize = 40; +export const MaxMessageLength = 512; +export const LeaderStatusEffect = 0.05; +export const CounterAttack = 0.75; +export const RaisedCounterAttack = 0.9; +export const CreateTracksCost = 50; +export const Charge = 1750; +export const MaxCharges = 10; +export const AllowedMisses = 2; +export const DefaultMapSkillSlots = 2; diff --git a/athena/map/Entity.tsx b/athena/map/Entity.tsx new file mode 100644 index 00000000..f02e2e77 --- /dev/null +++ b/athena/map/Entity.tsx @@ -0,0 +1,158 @@ +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { UnitInfo } from '../info/Unit.tsx'; +import { ID } from '../MapData.tsx'; +import Building from './Building.tsx'; +import { MaxHealth } from './Configuration.tsx'; +import { PlayerID, PlayerIDSet } from './Player.tsx'; +import Unit from './Unit.tsx'; + +export type PlainEntity = Readonly<{ + f?: 1 | null; + h: number; + i: ID; + l?: number; + p: number; +}>; + +export enum EntityType { + Airplane, + AirInfantry, + Amphibious, + Artillery, + Building, + Ground, + LowAltitude, + Infantry, + Invincible, + Ship, + Structure, + Rail, +} + +export type EntityGroup = 'land' | 'air' | 'naval' | 'building'; + +type Info = Readonly<{ + defense: number; + id: ID; + name: string; + type: EntityType; +}>; + +// h: health, p: player, f: completed +export default abstract class Entity { + public abstract readonly info: Info; + public readonly type = 'entity'; + + constructor( + public readonly id: ID, + public readonly health: number, + public readonly player: PlayerID, + protected readonly completed: true | null, + public readonly label: PlayerID | null, + ) {} + + isDead() { + return this.health <= 0; + } + + isCompleted() { + return !!this.completed; + } + + toJSON(): PlainEntity { + const { completed: f, health: h, id: i, label: l, player: p } = this; + return { ...(f ? { f: 1 } : null), h, i, ...(l ? { l } : null), p }; + } + + format(): Record { + const { completed, health, id, label, player } = this; + return { + /* eslint-disable sort-keys-fix/sort-keys-fix */ + id, + health, + player, + ...(completed ? { completed } : null), + ...(label ? { label } : null), + /* eslint-enable sort-keys-fix/sort-keys-fix */ + }; + } + + modifyHealth(change: number): this { + return this.setHealth(this.health + change); + } + + setHealth(health: number): this { + return this.copy({ health: Math.max(0, Math.min(health, MaxHealth)) }); + } + + setPlayer(player: PlayerID): this { + return this.copy({ player }); + } + + dropLabel(labels: PlayerIDSet): this { + return this.label != null && labels.has(this.label) + ? this.copy({ label: null }) + : this; + } + + abstract complete(): Entity; + abstract copy(_: unknown): this; + abstract recover(): Entity; +} + +export function getEntityInfoGroup( + entity: Readonly<{ type: EntityType }>, +): EntityGroup { + const { type: entityType } = entity; + switch (entityType) { + case EntityType.Artillery: + case EntityType.Infantry: + case EntityType.Ground: + case EntityType.Rail: + return 'land'; + case EntityType.Building: + case EntityType.Invincible: + case EntityType.Structure: + return 'building'; + case EntityType.Airplane: + case EntityType.AirInfantry: + case EntityType.LowAltitude: + return 'air'; + case EntityType.Amphibious: + case EntityType.Ship: + return 'naval'; + default: { + entityType satisfies never; + throw new UnknownTypeError('getEntityGroup', entityType); + } + } +} + +export function getEntityGroup(entity: Entity): EntityGroup { + return getEntityInfoGroup(entity.info); +} + +export function isEntityWithoutCover(entity: Entity) { + const type = getEntityGroup(entity); + return type === 'air' || type === 'building'; +} + +export function isBuilding(entity: Entity): entity is Building { + return getEntityGroup(entity) === 'building'; +} + +export function isUnit(entity: Entity): entity is Unit { + const entityType = getEntityGroup(entity); + return ( + entityType === 'land' || entityType === 'air' || entityType === 'naval' + ); +} + +export function isUnitInfo( + entity: Readonly<{ type: EntityType }>, +): entity is UnitInfo { + const entityType = getEntityInfoGroup(entity); + return ( + entityType === 'land' || entityType === 'air' || entityType === 'naval' + ); +} diff --git a/athena/map/PlainMap.tsx b/athena/map/PlainMap.tsx new file mode 100644 index 00000000..1fcf1a96 --- /dev/null +++ b/athena/map/PlainMap.tsx @@ -0,0 +1,39 @@ +import { Decorator } from '../info/Decorator.tsx'; +import { ModifierMap, TileMap } from '../MapData.tsx'; +import { PlainWinConditions } from '../WinConditions.tsx'; +import { Biome } from './Biome.tsx'; +import { PlainBuilding } from './Building.tsx'; +import { PlainTeams } from './Team.tsx'; +import { PlainUnit } from './Unit.tsx'; + +export type PlainEntitiesList = ReadonlyArray< + readonly [x: number, y: number, entity: T] +>; + +export type PlainMapConfig = Readonly<{ + biome: Biome; + blocklistedBuildings: ReadonlyArray; + blocklistedSkills?: ReadonlyArray; + blocklistedUnits: ReadonlyArray; + fog: boolean; + multiplier: number; + seedCapital: number; + winConditions?: PlainWinConditions; +}>; + +export type PlainMap = Readonly<{ + active: ReadonlyArray; + buildings: PlainEntitiesList; + config: PlainMapConfig; + currentPlayer: number; + decorators: PlainEntitiesList; + map: TileMap; + modifiers: ModifierMap; + round: number; + size: Readonly<{ + height: number; + width: number; + }>; + teams: PlainTeams; + units: PlainEntitiesList; +}>; diff --git a/athena/map/Player.tsx b/athena/map/Player.tsx new file mode 100644 index 00000000..29a26ffd --- /dev/null +++ b/athena/map/Player.tsx @@ -0,0 +1,497 @@ +import { Skill } from '../info/Skill.tsx'; +import MapData from '../MapData.tsx'; +import { Charge, MaxCharges } from './Configuration.tsx'; +import { + encodePlayerStatistics, + InitialPlayerStatistics, + PlainPlayerStatistics, + PlayerStatistics, +} from './Statistics.tsx'; + +export type PlainPlayerID = number; +export type PlayerID = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +export type PlayerIDs = ReadonlyArray; +export type PlayerIDSet = ReadonlySet; +export type DynamicPlayerID = PlayerID | 'self' | 'team' | 'opponent'; +export type PlainDynamicPlayerID = PlayerID | -1 | -2 | -3; + +// This tuple must start with `0`. +export const PlayerIDs = [0, 1, 2, 3, 4, 5, 6, 7] as const; +export const DynamicPlayerIDs = new Set([ + 'self', + 'team', + 'opponent', + ...PlayerIDs, +] as const); + +type BasePlainPlayerType = Readonly<{ + activeSkills: ReadonlyArray; + charge: number | undefined; + funds: number; + id: PlayerID; + misses: number | undefined; + skills: ReadonlyArray | undefined; + stats: PlainPlayerStatistics | null; +}>; + +export type PlainPlayerType = BasePlainPlayerType & + Readonly<{ + userId: string; + }>; + +type PlainBotType = BasePlainPlayerType & + Readonly<{ + name: string; + }>; + +type PlaceholderPlayerType = Readonly<{ + activeSkills?: undefined; + funds: number; + id: PlayerID; + skills?: ReadonlyArray; +}>; + +export type PlainPlayer = + | PlainBotType + | PlainPlayerType + | PlaceholderPlayerType; + +export default abstract class Player { + public readonly stats: PlayerStatistics; + public abstract readonly type: 'human' | 'bot' | 'placeholder'; + + constructor( + public readonly id: PlayerID, + public readonly teamId: PlayerID, + public readonly funds: number, + public readonly skills: ReadonlySet, + public readonly activeSkills: ReadonlySet, + public readonly charge: number, + stats: PlayerStatistics | null, + public readonly misses: number, + ) { + this.stats = stats || InitialPlayerStatistics; + } + + modifyFunds(change: number): this { + return this.copy({ funds: Math.max(0, this.funds + change) }); + } + + setFunds(funds: number): this { + return this.copy({ funds }); + } + + setCharge(charge: number): this { + return this.copy({ + charge: Math.max(0, Math.min(MaxCharges * Charge, charge)), + }); + } + + maybeSetCharge(charge: number | undefined): this { + return charge != null ? this.setCharge(charge) : this; + } + + activateSkill(skill: Skill): this { + return this.skills.has(skill) + ? this.copy({ + activeSkills: new Set([...this.activeSkills, skill]), + }) + : this; + } + + disableActiveSkills() { + return this.copy({ activeSkills: new Set() }); + } + + modifyStatistic(key: keyof PlayerStatistics, change: number): this { + return this.copy({ + stats: { ...this.stats, [key]: Math.max(0, this.stats[key] + change) }, + }); + } + + modifyStatistics(stats: Partial): this { + return this.copy({ + stats: { + captured: Math.max(0, this.stats.captured + (stats.captured || 0)), + createdBuildings: Math.max( + 0, + this.stats.createdBuildings + (stats.createdBuildings || 0), + ), + createdUnits: Math.max( + 0, + this.stats.createdUnits + (stats.createdUnits || 0), + ), + damage: Math.max(0, this.stats.damage + (stats.damage || 0)), + destroyedBuildings: Math.max( + 0, + this.stats.destroyedBuildings + (stats.destroyedBuildings || 0), + ), + destroyedUnits: Math.max( + 0, + this.stats.destroyedUnits + (stats.destroyedUnits || 0), + ), + lostBuildings: Math.max( + 0, + this.stats.lostBuildings + (stats.lostBuildings || 0), + ), + lostUnits: Math.max(0, this.stats.lostUnits + (stats.lostUnits || 0)), + }, + }); + } + + resetStatistics(): this { + return this.copy({ stats: InitialPlayerStatistics }); + } + + isHumanPlayer(): this is HumanPlayer { + return this.type === 'human'; + } + + isBot(): this is Bot { + return this.type === 'bot'; + } + + isPlaceholder(): this is PlaceholderPlayer { + return this.type === 'placeholder'; + } + + abstract copy(_: { + activeSkills?: ReadonlySet; + charge?: number; + funds?: number; + id?: PlayerID; + misses?: number; + skills?: ReadonlySet; + stats?: PlayerStatistics; + teamId?: PlayerID; + }): this; + abstract toJSON(): PlainPlayer; +} + +export class PlaceholderPlayer extends Player { + public readonly type = 'placeholder'; + + constructor( + id: PlayerID, + teamId: PlayerID, + funds: number, + skills: ReadonlySet, + ) { + super(id, teamId, funds, skills, new Set(), 0, null, 0); + } + + copy({ + funds, + id, + skills, + teamId, + }: { + funds?: number; + id?: PlayerID; + skills?: ReadonlySet; + teamId?: PlayerID; + }): this { + return new PlaceholderPlayer( + id ?? this.id, + teamId ?? this.teamId, + funds ?? this.funds, + skills ?? this.skills, + ) as this; + } + + toJSON(): PlaceholderPlayerType { + const { funds, id, skills } = this; + return { funds, id, skills: [...skills] }; + } + + static from(player: Player): PlaceholderPlayer { + return player.isPlaceholder() + ? player + : new PlaceholderPlayer( + player.id, + player.teamId, + player.funds, + player.skills, + ); + } +} + +export class Bot extends Player { + public readonly type = 'bot'; + + constructor( + id: PlayerID, + public readonly name: string, + teamId: PlayerID, + funds: number, + skills: ReadonlySet, + activeSkills: ReadonlySet, + charge: number, + stats: PlayerStatistics | null, + misses: number, + ) { + super(id, teamId, funds, skills, activeSkills, charge, stats, misses); + } + + copy({ + activeSkills, + charge, + funds, + id, + misses, + name, + skills, + stats, + teamId, + }: { + activeSkills?: ReadonlySet; + charge?: number; + funds?: number; + id?: PlayerID; + misses?: number; + name?: string; + skills?: ReadonlySet; + stats?: PlayerStatistics; + teamId?: PlayerID; + }): this { + return new Bot( + id ?? this.id, + name ?? this.name, + teamId ?? this.teamId, + funds ?? this.funds, + skills ?? this.skills, + activeSkills ?? this.activeSkills, + charge ?? this.charge, + stats ?? this.stats, + misses ?? this.misses, + ) as this; + } + + toJSON(): PlainBotType { + const { activeSkills, charge, funds, id, misses, name, skills, stats } = + this; + return { + activeSkills: [...activeSkills], + charge, + funds, + id, + misses, + name, + skills: [...skills], + stats: encodePlayerStatistics(stats), + }; + } + + static from(player: Player, name: string): Bot { + return player.isBot() && player.name === name + ? player + : new Bot( + player.id, + name, + player.teamId, + player.funds, + player.skills, + player.activeSkills, + player.charge, + player.stats, + player.misses, + ); + } +} + +export class HumanPlayer extends Player { + public readonly type = 'human'; + + constructor( + id: PlayerID, + public readonly userId: string, + teamId: PlayerID, + funds: number, + skills: ReadonlySet, + activeSkills: ReadonlySet, + charge: number, + stats: PlayerStatistics | null, + misses: number, + ) { + super(id, teamId, funds, skills, activeSkills, charge, stats, misses); + } + + copy({ + activeSkills, + charge, + funds, + id, + misses, + skills, + stats, + teamId, + userId, + }: { + activeSkills?: ReadonlySet; + charge?: number; + funds?: number; + id?: PlayerID; + misses?: number; + skills?: ReadonlySet; + stats?: PlayerStatistics; + teamId?: PlayerID; + userId?: string; + }): this { + return new HumanPlayer( + id ?? this.id, + userId ?? this.userId, + teamId ?? this.teamId, + funds ?? this.funds, + skills ?? this.skills, + activeSkills ?? this.activeSkills, + charge ?? this.charge, + stats ?? this.stats, + misses ?? this.misses, + ) as this; + } + + toJSON(): PlainPlayerType { + const { activeSkills, charge, funds, id, misses, skills, stats, userId } = + this; + return { + activeSkills: [...activeSkills], + charge, + funds, + id, + misses, + skills: [...skills], + stats: encodePlayerStatistics(stats), + userId, + }; + } + + static from(player: Player, userId: string): HumanPlayer { + return player.isHumanPlayer() && player.userId === userId + ? player + : new HumanPlayer( + player.id, + userId, + player.teamId, + player.funds, + player.skills, + player.activeSkills, + player.charge, + player.stats, + player.misses, + ); + } +} + +export function toPlayerID(id: number): PlayerID { + switch (id) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + return id; + default: { + throw new Error(`Invalid PlayerID '${id}'.`); + } + } +} + +export function encodeDynamicPlayerID( + id: DynamicPlayerID, +): PlainDynamicPlayerID { + switch (id) { + case 'self': + return -1; + case 'team': + return -2; + case 'opponent': + return -3; + default: + return id; + } +} + +export function decodeDynamicPlayerID( + id: PlainDynamicPlayerID, +): DynamicPlayerID { + switch (id) { + case -1: + return 'self'; + case -2: + return 'team'; + case -3: + return 'opponent'; + default: + return toPlayerID(id); + } +} + +export function toDynamicPlayerID(id: number | string): DynamicPlayerID { + switch (id) { + case 'self': + case 'team': + case 'opponent': + return id; + default: { + if (typeof id === 'string') { + throw new Error(`Invalid PlayerID '${id}'.`); + } + return toPlayerID(id); + } + } +} + +export function isDynamicPlayerID(id: number | string): id is DynamicPlayerID { + return DynamicPlayerIDs.has(id as DynamicPlayerID); +} + +const preferHumanPlayers = (players: ReadonlyArray) => + players.find((player) => isHumanPlayer(player)) || players.at(0)!; + +export function resolveDynamicPlayerID( + map: MapData, + id: DynamicPlayerID, + player = map.getCurrentPlayer().id, +): PlayerID { + switch (id) { + case 'self': + return player; + case 'team': { + return ( + preferHumanPlayers( + [...(map.maybeGetTeam(player)?.players.values() || [])].filter( + ({ id }) => id > 0 && id !== player, + ), + )?.id || player + ); + } + case 'opponent': { + const team = map.maybeGetTeam(player); + return preferHumanPlayers( + map + .getPlayers() + .filter(({ id, teamId }) => id > 0 && teamId !== team?.id), + )!.id; + } + default: + return id; + } +} + +export function toPlayerIDs(ids: ReadonlyArray): PlayerIDs { + return ids.map(toPlayerID); +} + +export function numberToPlayerID(number: number): PlayerID { + return toPlayerID((number % (PlayerIDs.length - 1)) + 1); +} + +export function isHumanPlayer(player: Player): player is HumanPlayer { + return player.isHumanPlayer(); +} +export function isBot(player: Player): player is Bot { + return player.isBot(); +} diff --git a/athena/map/Reward.tsx b/athena/map/Reward.tsx new file mode 100644 index 00000000..b993ac0c --- /dev/null +++ b/athena/map/Reward.tsx @@ -0,0 +1,68 @@ +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { Skill, Skills } from '../info/Skill.tsx'; + +type SkillReward = Readonly<{ + skill: Skill; + type: 'skill'; +}>; + +type EncodedSkillReward = readonly [type: 0, skill: Skill]; + +export type Reward = SkillReward; +export type EncodedReward = EncodedSkillReward; +export type PlainReward = EncodedReward; + +export function isSkillReward(reward: Reward): reward is SkillReward { + return reward.type === 'skill'; +} + +export function encodeReward(reward: Reward): EncodedReward { + switch (reward.type) { + case 'skill': + return [0, reward.skill]; + default: + reward.type satisfies never; + throw new UnknownTypeError('encodeReward', reward.type); + } +} + +export function decodeReward([rewardType, ...rest]: EncodedReward): Reward { + switch (rewardType) { + case 0: + return { skill: rest[0], type: 'skill' }; + default: + rewardType satisfies never; + throw new UnknownTypeError('decodeReward', rewardType); + } +} + +export function maybeEncodeReward( + reward: Reward | null | undefined, +): EncodedReward | null { + return reward ? encodeReward(reward) : null; +} + +export function maybeDecodeReward( + reward: EncodedReward | null | undefined, +): Reward | null { + return reward ? decodeReward(reward) : null; +} + +export function formatReward(reward: Reward): string { + switch (reward.type) { + case 'skill': + return `Reward { skill: ${reward.skill} }`; + default: + reward.type satisfies never; + throw new UnknownTypeError('formatReward', reward.type); + } +} + +export function validateReward(reward: Reward): boolean { + switch (reward.type) { + case 'skill': + return Skills.has(reward.skill); + default: + return false; + } +} diff --git a/athena/map/Serialization.tsx b/athena/map/Serialization.tsx new file mode 100644 index 00000000..75e77a9e --- /dev/null +++ b/athena/map/Serialization.tsx @@ -0,0 +1,133 @@ +import ImmutableMap from '@nkzw/immutable-map'; +import { Decorator } from '../info/Decorator.tsx'; +import getDecoratorIndex from '../lib/getDecoratorIndex.tsx'; +import MapData, { DecoratorMap, SizeVector } from '../MapData.tsx'; +import Building, { PlainBuilding } from './Building.tsx'; +import { DecoratorsPerSide } from './Configuration.tsx'; +import Entity from './Entity.tsx'; +import { PlainEntitiesList } from './PlainMap.tsx'; +import Player, { + Bot, + HumanPlayer, + PlaceholderPlayer, + PlainPlayer, + PlayerID, + toPlayerID, +} from './Player.tsx'; +import SpriteVector from './SpriteVector.tsx'; +import { decodePlayerStatistics } from './Statistics.tsx'; +import Team, { PlainTeam, Teams } from './Team.tsx'; +import Unit, { PlainUnit } from './Unit.tsx'; +import vec from './vec.tsx'; +import Vector from './Vector.tsx'; + +export function decodeDecorators( + size: SizeVector, + decorators: PlainEntitiesList | undefined, +): DecoratorMap { + const decoratorMap: Array = Array( + size.width * size.height * DecoratorsPerSide * DecoratorsPerSide, + ).fill(0); + const decoratorSize = size.toDecoratorSizeVector(); + decorators?.forEach(([x, y, decorator]) => { + decoratorMap[getDecoratorIndex(new SpriteVector(x, y), decoratorSize)] = + decorator; + }); + return decoratorMap; +} + +export function decodePlayers( + players: ReadonlyArray, + teamId: PlayerID, +): ImmutableMap { + return ImmutableMap().withMutations((playerMap) => + players.forEach((player) => { + const playerID = toPlayerID(player.id); + const skills = new Set(player.skills); + const activeSkills = new Set(player.activeSkills); + return playerMap.set( + playerID, + 'userId' in player + ? new HumanPlayer( + playerID, + player.userId, + teamId, + player.funds, + skills, + activeSkills, + player.charge || 0, + decodePlayerStatistics(player.stats), + player.misses || 0, + ) + : 'name' in player + ? new Bot( + playerID, + player.name, + teamId, + player.funds, + skills, + activeSkills, + player.charge || 0, + decodePlayerStatistics(player.stats), + player.misses || 0, + ) + : new PlaceholderPlayer(playerID, teamId, player.funds, skills), + ); + }), + ); +} + +export function decodeTeams(teams: ReadonlyArray): Teams { + return ImmutableMap().withMutations((map) => + teams.forEach(({ id, name, players }) => + map.set(id, new Team(id, name, decodePlayers(players, id))), + ), + ); +} + +export function decodeEntities< + T extends Entity, + S extends PlainBuilding | PlainUnit, +>( + list: PlainEntitiesList, + entityCreator: (entity: S) => T, +): ImmutableMap { + return ImmutableMap().withMutations((map) => + list.forEach(([x, y, entity]) => map.set(vec(x, y), entityCreator(entity))), + ); +} + +export function decodeBuildings( + buildings: PlainEntitiesList, +): ImmutableMap { + return decodeEntities(buildings, Building.fromJSON); +} + +export function decodeUnits( + units: PlainEntitiesList, +): ImmutableMap { + return decodeEntities(units, Unit.fromJSON); +} + +export function encodeTeams(teams: Teams): ReadonlyArray { + return [...teams].map(([, team]) => team.toJSON()); +} + +export const formatTeams = encodeTeams; + +export function encodeDecorators(map: MapData): PlainEntitiesList { + return map.reduceEachDecorator( + (list, decorator, vector) => [...list, [vector.x, vector.y, decorator.id]], + [] as PlainEntitiesList, + ); +} + +export function encodeEntities< + T extends Unit | Building, + S extends T extends Unit ? PlainUnit : PlainBuilding, +>(entities: ImmutableMap): PlainEntitiesList { + return entities + .map((entity, { x, y }) => [x, y, entity.toJSON()]) + .toSetSeq() + .toJS() as PlainEntitiesList; +} diff --git a/athena/map/SpriteVector.tsx b/athena/map/SpriteVector.tsx new file mode 100644 index 00000000..b717c9e1 --- /dev/null +++ b/athena/map/SpriteVector.tsx @@ -0,0 +1,19 @@ +import Vector from './Vector.tsx'; + +export default class SpriteVector extends Vector { + override up(n = 1): SpriteVector { + return new SpriteVector(this.x, this.y - n); + } + override right(n = 1): SpriteVector { + return new SpriteVector(this.x + n, this.y); + } + override down(n = 1): SpriteVector { + return new SpriteVector(this.x, this.y + n); + } + override left(n = 1): SpriteVector { + return new SpriteVector(this.x - n, this.y); + } + override hashCode() { + return this.id; + } +} diff --git a/athena/map/Statistics.tsx b/athena/map/Statistics.tsx new file mode 100644 index 00000000..f14e7791 --- /dev/null +++ b/athena/map/Statistics.tsx @@ -0,0 +1,76 @@ +export type PlayerStatistics = Readonly<{ + captured: number; + createdBuildings: number; + createdUnits: number; + damage: number; + destroyedBuildings: number; + destroyedUnits: number; + lostBuildings: number; + lostUnits: number; +}>; + +export type PlainPlayerStatistics = [ + captured: number, + createdBuildings: number, + createdUnits: number, + damage: number, + destroyedBuildings: number, + destroyedUnits: number, + lostBuildings: number, + lostUnits: number, +]; + +export const InitialPlayerStatistics = { + captured: 0, + createdBuildings: 0, + createdUnits: 0, + damage: 0, + destroyedBuildings: 0, + destroyedUnits: 0, + lostBuildings: 0, + lostUnits: 0, +} as const; + +export const PlayerStatisticsEntries = [ + 'captured', + 'createdBuildings', + 'createdUnits', + 'damage', + 'destroyedBuildings', + 'destroyedUnits', + 'lostBuildings', + 'lostUnits', +] as const; + +export function decodePlayerStatistics( + stats: PlainPlayerStatistics | null, +): PlayerStatistics { + return { + captured: stats?.[0] || InitialPlayerStatistics.captured, + createdBuildings: stats?.[1] || InitialPlayerStatistics.createdBuildings, + createdUnits: stats?.[2] || InitialPlayerStatistics.createdUnits, + damage: stats?.[3] || InitialPlayerStatistics.damage, + destroyedBuildings: + stats?.[4] || InitialPlayerStatistics.destroyedBuildings, + destroyedUnits: stats?.[5] || InitialPlayerStatistics.destroyedUnits, + lostBuildings: stats?.[6] || InitialPlayerStatistics.lostBuildings, + lostUnits: stats?.[7] || InitialPlayerStatistics.lostUnits, + }; +} + +export function encodePlayerStatistics( + stats: PlayerStatistics | null, +): PlainPlayerStatistics | null { + return stats + ? [ + stats.captured, + stats.createdBuildings, + stats.createdUnits, + stats.damage, + stats.destroyedBuildings, + stats.destroyedUnits, + stats.lostBuildings, + stats.lostUnits, + ] + : null; +} diff --git a/athena/map/Team.tsx b/athena/map/Team.tsx new file mode 100644 index 00000000..eaa0a53f --- /dev/null +++ b/athena/map/Team.tsx @@ -0,0 +1,50 @@ +import ImmutableMap from '@nkzw/immutable-map'; +import Player, { PlainPlayer, PlayerID } from './Player.tsx'; + +export type PlainTeam = Readonly<{ + id: PlayerID; + name: string; + players: ReadonlyArray; +}>; + +export type Teams = ImmutableMap; +export type PlainTeams = ReadonlyArray; + +export function toTeamArray(teams: Teams) { + return teams + .map(({ id, players }) => ({ + id, + players: [...players.map(({ id }) => id).valueSeq()], + })) + .valueSeq() + .toArray(); +} + +export default class Team { + constructor( + public readonly id: PlayerID, + public readonly name: string, + public readonly players: ImmutableMap, + ) {} + + toJSON(): PlainTeam { + const { id, name, players } = this; + return { + id, + name, + players: [...players].map(([, player]) => player.toJSON()), + }; + } + + copy({ + id, + name, + players, + }: { + id?: PlayerID; + name?: string; + players?: ImmutableMap; + }): Team { + return new Team(id ?? this.id, name ?? this.name, players ?? this.players); + } +} diff --git a/athena/map/Unit.tsx b/athena/map/Unit.tsx new file mode 100644 index 00000000..c0829175 --- /dev/null +++ b/athena/map/Unit.tsx @@ -0,0 +1,876 @@ +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import { Skill } from '../info/Skill.tsx'; +import { + Ability, + getUnitInfo, + Sniper, + Supply, + UnitInfo, + Weapon, + WeaponID, +} from '../info/Unit.tsx'; +import getUnitName from '../info/UnitNames.tsx'; +import filterNullables from '../lib/filterNullables.tsx'; +import { ID } from '../MapData.tsx'; +import { AIBehavior, AIBehaviors } from './AIBehavior.tsx'; +import Entity, { PlainEntity } from './Entity.tsx'; +import Player, { PlayerID, PlayerIDSet, toPlayerID } from './Player.tsx'; + +type PlainAmmo = ReadonlyArray<[number, number]>; + +export type PlainDryUnit = number | [number, PlainAmmo]; + +type Ammo = ReadonlyMap; + +export type PlainTransportedUnit = Readonly< + Omit & { + a?: PlainAmmo | null; + b?: AIBehavior | null; + g: number; + m?: 1 | null; + n?: number | null; + t?: ReadonlyArray | null; + } +>; + +export type PlainUnit = Readonly< + PlainEntity & { + a?: PlainAmmo | null; + b?: AIBehavior | null; + c?: 1 | null; + g: number; + l?: number; + m?: 1 | null; + n?: number | null; + r?: PlayerID | null; + t?: ReadonlyArray | null; + u?: 1 | null; + } +>; + +export class DryUnit { + // This is only used for formatting. + public readonly info = { name: 'DryUnit' }; + + constructor( + public readonly health: number, + public readonly ammo: Ammo | null, + ) {} + + static fromJSON(dryUnit: PlainDryUnit): DryUnit { + if (typeof dryUnit === 'number') { + return new DryUnit(dryUnit, null); + } + const [health, ammo] = dryUnit; + return new DryUnit(health, ammo ? new Map(ammo) : null); + } + + toJSON(): PlainDryUnit { + const { ammo: a, health: h } = this; + const ammo = a?.size ? [...a] : null; + return ammo ? [h, ammo] : h; + } + + format() { + const { ammo: a, health } = this; + const ammo = a?.size ? [...a] : null; + // eslint-disable-next-line sort-keys-fix/sort-keys-fix + return ammo ? { health, ammo } : { health }; + } +} + +export class TransportedUnit { + public readonly info: UnitInfo; + + constructor( + public readonly id: ID, + public readonly health: number, + public readonly player: PlayerID, + public readonly fuel: number, + public readonly ammo: Ammo | null, + public readonly transports: ReadonlyArray | null, + private readonly moved: true | null, + public readonly label: PlayerID | null, + private readonly name: number | null, + private readonly behavior: AIBehavior | null, + ) { + const unitInfo = getUnitInfo(id); + if (!unitInfo) { + throw new Error(`Invalid unit '${id}'.`); + } + this.info = unitInfo; + } + + static fromJSON(unit: PlainTransportedUnit): TransportedUnit { + const { a, b, g, h, i, l, m, n, p, t } = unit; + return new TransportedUnit( + i, + h, + toPlayerID(p), + g, + a ? new Map(a) : null, + t ? t.map(TransportedUnit.fromJSON) : null, + m === 1 ? true : null, + l != null ? toPlayerID(l) : null, + n ?? null, + b || null, + ); + } + + deploy(): Unit { + return new Unit( + this.id, + this.health, + this.player, + this.fuel, + this.ammo, + this.transports, + true, + null, + null, + null, + this.moved ? true : null, + this.label, + this.name, + this.behavior, + ); + } + + hasValidBehavior() { + return !this.behavior || AIBehaviors.has(this.behavior); + } + + withName(name: number | null): TransportedUnit { + return name !== this.name ? this.copy({ name }) : this; + } + + isLeader() { + return this.name != null && this.name < 0; + } + + removeLeader(): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.removeLeader()), + }); + } + + return unit.isLeader() ? unit.copy({ name: null }) : unit; + } + + hasName() { + return !!(this.name != null && getUnitName(this.info.gender, this.name)); + } + + hasValidName() { + return this.name == null || getUnitName(this.info.gender, this.name); + } + + getName(viewer?: PlayerID | null) { + if (this.isLeader() && viewer === this.player) { + return this.info.characterName; + } + return ( + (this.name != null && getUnitName(this.info.gender, this.name)) || null + ); + } + + isTransportingUnits(): this is { + transports: ReadonlyArray; + } { + return (this.transports?.length || 0) > 0; + } + + recover(): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.recover()), + }); + } + + return unit.moved ? unit.copy({ moved: null }) : unit; + } + + setPlayer(player: PlayerID): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.setPlayer(player)), + }); + } + + return unit.player !== player ? unit.copy({ player }) : unit; + } + + dropLabel(labels: PlayerIDSet): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.dropLabel(labels)), + }); + } + + return unit.label != null && labels.has(unit.label) + ? unit.copy({ label: null }) + : unit; + } + + toJSON(): PlainTransportedUnit { + const { + ammo, + behavior: b, + fuel: g, + health: h, + id: i, + label: l, + moved, + name: n, + player: p, + transports, + } = this; + const a = ammo?.size ? [...ammo] : null; + const t = transports ? transports.map((unit) => unit.toJSON()) : null; + return filterNullables({ + a, + b, + g, + h, + i, + l: l != null ? l : undefined, + m: moved ? 1 : null, + n, + p, + t, + }); + } + + format(): Record { + const { + ammo: a, + behavior, + fuel, + health, + id, + label, + moved, + player, + transports: t, + } = this; + const ammo = a?.size ? [...a] : null; + const transports = t ? t.map((unit) => unit.format()) : null; + return filterNullables({ + ammo, + behavior, + fuel, + health, + id, + label, + moved, + name: this.getName(player), + player, + transports, + }); + } + + copy({ + ammo, + behavior, + fuel, + health, + id, + label, + moved, + name, + player, + transports, + }: { + ammo?: Ammo | null; + behavior?: AIBehavior | null; + fuel?: number; + health?: number; + id?: ID; + label?: PlayerID | null; + moved?: true | null; + name?: number | null; + player?: PlayerID; + transports?: ReadonlyArray | null; + }): this { + return new TransportedUnit( + id ?? this.id, + health ?? this.health, + player ?? this.player, + fuel ?? this.fuel, + ammo !== undefined ? ammo : this.ammo, + transports !== undefined ? transports : this.transports, + moved !== undefined ? moved : this.moved, + label !== undefined ? label : this.label, + name !== undefined ? name : this.name, + behavior !== undefined ? behavior : this.behavior, + ) as this; + } +} + +export default class Unit extends Entity { + public readonly info: UnitInfo; + + constructor( + id: ID, + health: number, + player: PlayerID, + public readonly fuel: number, + public readonly ammo: Ammo | null, + public readonly transports: ReadonlyArray | null, + private readonly moved: true | null, + private readonly capturing: true | null, + private readonly rescuing: PlayerID | null, + private readonly unfolded: true | null, + completed: true | null, + label: PlayerID | null, + private readonly name: number | null, + private readonly behavior: AIBehavior | null, + ) { + super(id, health, player, completed, label); + const unitInfo = getUnitInfo(id); + if (!unitInfo) { + throw new Error(`Invalid unit '${id}'.`); + } + this.info = unitInfo; + + // If a unit's definition changes and it receives a weapon, + // it should be given a supply of ammo. + if (!this.ammo && this.info.hasAttack()) { + this.ammo = this.info.getAmmunitionSupply(); + } + } + + static fromJSON(unit: PlainUnit): Unit { + const { a, b, c, f, g, h, i, l, m, n, p, r, t, u } = unit; + return new Unit( + i, + h, + toPlayerID(p), + g, + a ? new Map(a) : null, + t ? t.map(TransportedUnit.fromJSON) : null, + m === 1 ? true : null, + c === 1 ? true : null, + r != null ? r : null, + u === 1 ? true : null, + f === 1 ? true : null, + l != null ? toPlayerID(l) : null, + n ?? null, + b || null, + ); + } + + matchesBehavior(behavior: AIBehavior) { + return behavior === AIBehavior.Attack + ? this.behavior == null || this.behavior === AIBehavior.Attack + : this.behavior === behavior; + } + + hasMoved() { + return !!this.moved; + } + + hasFuel() { + return this.fuel > 0; + } + + canMove() { + return ( + !this.hasMoved() && + this.hasFuel() && + (!this.isUnfolded() || !this.info.hasAbility(Ability.Unfold)) + ); + } + + canAttack(player: Player) { + return ( + this.info.hasAttack() && + (!this.info.hasAbility(Ability.Unfold) || + this.isUnfolded() || + (this.info === Sniper && + player.skills.has(Skill.UnitAbilitySniperImmediateAction))) + ); + } + + canAttackAt(distance: number, player: Player) { + return ( + this.canAttack(player) && + this.info.canAttackAt(distance, this.info.getRangeFor(player)) + ); + } + + getAttackWeapon(entity: Entity) { + const { primaryWeapon, weapons } = this.info.attack; + if (weapons) { + if (weapons.size === 1 && primaryWeapon) { + return weaponCanAttack(primaryWeapon, this.ammo, entity) + ? primaryWeapon + : null; + } + + for (const weapon of sortBy( + [...weapons.values()], + (weapon) => -weapon.getDamage(entity), + )) { + if (weaponCanAttack(weapon, this.ammo, entity)) { + return weapon; + } + } + } + return null; + } + + isOutOfAmmo() { + return !!(this.ammo && [...this.ammo].every(([, s]) => s === 0)); + } + + isCapturing() { + return !!this.capturing; + } + + isBeingRescued() { + return !!this.rescuing; + } + + getRescuer(): PlayerID | null { + return this.rescuing; + } + + isBeingRescuedBy(player: PlayerID) { + return this.rescuing === player; + } + + isTransportingUnits(): this is { + transports: ReadonlyArray; + } { + return (this.transports?.length || 0) > 0; + } + + getTransportedUnit(index: number) { + return this.transports?.[index] || null; + } + + isFull() { + const info = this.info; + return !!( + info.canTransportUnits() && + (this.transports?.length || 0) >= info.transports.limit + ); + } + + isUnfolded() { + return !!this.unfolded; + } + + move(): this { + return !this.moved || this.capturing + ? this.copy({ + capturing: null, + moved: true, + }) + : this; + } + + capture(): this { + return !this.capturing && this.info.abilities.has(Ability.Capture) + ? this.copy({ capturing: true }) + : this; + } + + stopCapture(): this { + return this.capturing ? this.copy({ capturing: null }) : this; + } + + rescue(rescuing: PlayerID): this { + return this.rescuing !== rescuing ? this.copy({ rescuing }) : this; + } + + stopBeingRescued(): this { + return this.rescuing ? this.copy({ rescuing: null }) : this; + } + + unfold(): this { + return !this.unfolded && this.info.abilities.has(Ability.Unfold) + ? this.copy({ unfolded: true }) + : this; + } + + fold(): this { + return this.capturing || this.unfolded + ? this.copy({ capturing: null, unfolded: null }) + : this; + } + + setAmmo(ammo: Ammo | null): this { + return this.copy({ ammo }); + } + + modifyFuel(change: number): this { + return this.setFuel(this.fuel + change); + } + + setFuel(fuel: number): this { + return this.copy({ + fuel: Math.max(0, Math.min(fuel, this.info.configuration.fuel)), + }); + } + + refill(): this { + const info = this.info; + return this.copy({ + ammo: info.getAmmunitionSupply(), + fuel: info.configuration.fuel, + }); + } + + recover(): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.recover()), + }); + } + + return unit.moved || unit.completed + ? unit.copy({ completed: null, moved: null }) + : unit; + } + + complete(): this { + return !this.completed || !this.moved + ? this.copy({ + completed: true, + moved: true, + }) + : this; + } + + sabotage(): this { + return this.copy({ + ammo: sabotageAmmo(this.ammo, this.info), + fuel: Math.max( + 0, + this.fuel - Math.ceil(this.info.configuration.fuel * 0.33), + ), + }); + } + + subtractAmmo(weapon: Weapon, amount = 1): this { + const { ammo } = this; + const supply = ammo?.get(weapon.id); + return supply != null + ? this.copy({ + ammo: new Map(ammo).set(weapon.id, Math.max(0, supply - amount)), + }) + : this; + } + + setAIBehavior(behavior: AIBehavior | null): this { + return this.copy({ behavior }); + } + + maybeUpdateAIBehavior(): this { + return this.behavior === AIBehavior.Adaptive + ? this.setAIBehavior(AIBehavior.Attack) + : this; + } + + load(unit: TransportedUnit): this { + const { transports } = this; + return this.copy({ + transports: transports ? [...transports, unit] : [unit], + }); + } + + drop(unit: TransportedUnit): this { + let { transports } = this; + if (transports) { + transports = transports.filter( + (transportedUnit) => transportedUnit !== unit, + ); + } + return this.copy({ + transports: transports == null || !transports.length ? null : transports, + }); + } + + transport(): TransportedUnit { + return new TransportedUnit( + this.id, + this.health, + this.player, + this.fuel, + this.ammo, + this.transports, + true, + this.label, + this.name, + this.behavior, + ); + } + + dry(): DryUnit { + return new DryUnit(this.health, this.ammo); + } + + hasValidBehavior() { + return !this.behavior || AIBehaviors.has(this.behavior); + } + + withName(name: number | null): Unit { + return name !== this.name ? this.copy({ name }) : this; + } + + isLeader() { + return this.name != null && this.name < 0; + } + + removeLeader(): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.removeLeader()), + }); + } + + return unit.isLeader() ? unit.copy({ name: null }) : unit; + } + + hasName() { + return !!(this.name != null && getUnitName(this.info.gender, this.name)); + } + + hasValidName() { + return this.name == null || getUnitName(this.info.gender, this.name); + } + + getName(viewer?: PlayerID | null) { + if (this.isLeader() && viewer === this.player) { + return this.info.characterName; + } + return ( + (this.name != null && getUnitName(this.info.gender, this.name)) || null + ); + } + + override setPlayer(player: PlayerID): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.setPlayer(player)), + }); + } + + return unit.player !== player ? unit.copy({ player }) : unit; + } + + maybeSetPlayer( + player: PlayerID | null | undefined, + state: 'complete' | 'recover', + ): this { + return player != null && player !== this.player + ? this.setPlayer(player)[state]() + : this; + } + + ensureValidAttributes() { + const { info } = this; + let unit = this; + if (unit.fuel > info.configuration.fuel) { + unit = unit.setFuel(info.configuration.fuel); + } + if (this.ammo) { + let ammo: Map | null = null; + for (const [id, currentSupply] of this.ammo) { + const supply = info.attack.weapons?.get(id)?.supply; + if (supply != null && supply < currentSupply) { + if (!ammo) { + ammo = new Map(this.ammo); + unit = unit.setAmmo(ammo); + } + ammo.set(id, supply); + } + } + } + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports?.map((transportedUnit) => + transportedUnit.deploy().ensureValidAttributes().transport(), + ), + }); + } + + return unit; + } + + override dropLabel(labels: PlayerIDSet): this { + let unit = this; + + if (unit.transports?.length) { + unit = unit.copy({ + transports: unit.transports.map((unit) => unit.dropLabel(labels)), + }); + } + + return unit.label != null && labels.has(unit.label) + ? unit.copy({ label: null }) + : unit; + } + + override toJSON(): PlainUnit { + const { + ammo, + behavior: b, + capturing, + completed, + fuel: g, + health: h, + id: i, + label: l, + moved, + name: n, + player: p, + rescuing: r, + transports, + unfolded, + } = this; + return filterNullables({ + a: ammo?.size ? [...ammo] : null, + b, + c: capturing ? 1 : null, + f: completed ? 1 : null, + g, + h, + i, + l: l != null ? l : undefined, + m: moved ? 1 : null, + n, + p, + r: r != null ? r : null, + t: transports ? transports.map((unit) => unit.toJSON()) : null, + u: unfolded ? 1 : null, + }); + } + + override format(): Record { + const { + ammo, + behavior, + capturing, + completed, + fuel, + health, + id, + label, + moved, + player, + rescuing, + transports, + unfolded, + } = this; + return filterNullables({ + /* eslint-disable sort-keys-fix/sort-keys-fix */ + id, + health, + player, + fuel, + ammo: ammo?.size ? [...ammo] : null, + transports: transports ? transports.map((unit) => unit.format()) : null, + moved, + name: this.getName(player), + capturing, + rescuing, + unfolded, + completed, + label, + behavior, + /* eslint-enable sort-keys-fix/sort-keys-fix */ + }); + } + + copy({ + ammo, + behavior, + capturing, + completed, + fuel, + health, + id, + label, + moved, + name, + player, + rescuing, + transports, + unfolded, + }: { + ammo?: Ammo | null; + behavior?: AIBehavior | null; + capturing?: true | null; + completed?: true | null; + fuel?: number; + health?: number; + id?: ID; + label?: PlayerID | null; + moved?: true | null; + name?: number | null; + player?: PlayerID; + rescuing?: PlayerID | null; + transports?: ReadonlyArray | null; + unfolded?: true | null; + }): this { + return new Unit( + id ?? this.id, + health ?? this.health, + player ?? this.player, + fuel ?? this.fuel, + ammo !== undefined ? ammo : this.ammo, + transports !== undefined ? transports : this.transports, + moved !== undefined ? moved : this.moved, + capturing !== undefined ? capturing : this.capturing, + rescuing !== undefined ? rescuing : this.rescuing, + unfolded !== undefined ? unfolded : this.unfolded, + completed !== undefined ? completed : this.completed, + label !== undefined ? label : this.label, + name !== undefined ? name : this.name, + behavior !== undefined ? behavior : this.behavior, + ) as this; + } +} + +UnitInfo.setConstructor(Unit); + +const sabotageAmmo = (ammo: Ammo | null, info: UnitInfo) => { + if (!ammo) { + return null; + } + const newAmmo = new Map(); + for (const [id, supply] of ammo) { + newAmmo.set( + id, + Math.max( + 0, + supply - Math.ceil((info.attack.weapons?.get(id)?.supply || 3) * 0.33), + ), + ); + } + return newAmmo; +}; + +const weaponCanAttack = (weapon: Weapon, ammo: Ammo | null, entity: Entity) => + weapon && + weapon.getDamage(entity) > 0 && + (!weapon.supply || (ammo?.get(weapon.id) || 0) > 0); diff --git a/athena/map/Vector.tsx b/athena/map/Vector.tsx new file mode 100644 index 00000000..80667b2b --- /dev/null +++ b/athena/map/Vector.tsx @@ -0,0 +1,145 @@ +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import vec from './vec.tsx'; + +export type VectorLike = Readonly<{ + x: number; + y: number; +}>; + +export const szudzik = (x: number, y: number) => + x >= y ? x * x + x + y : y * y + x; + +export default abstract class Vector { + protected id: string; + private vectors: readonly [Vector, Vector, Vector, Vector] | null = null; + + constructor( + public readonly x: number, + public readonly y: number, + ) { + this.id = `${x},${y}`; + } + + up(n = 1) { + return vec(this.x, this.y - n); + } + right(n = 1) { + return vec(this.x + n, this.y); + } + down(n = 1) { + return vec(this.x, this.y + n); + } + left(n = 1) { + return vec(this.x - n, this.y); + } + + // Do not change the order of these expansions. + expand() { + return [this, this.up(), this.right(), this.down(), this.left()] as const; + } + + adjacent() { + return ( + this.vectors || + (this.vectors = [ + this.up(), + this.right(), + this.down(), + this.left(), + ] as const) + ); + } + + adjacentWithDiagonals() { + const { x, y } = this; + return [ + ...this.adjacent(), + vec(x - 1, y - 1), + vec(x + 1, y - 1), + vec(x - 1, y + 1), + vec(x + 1, y + 1), + ] as const; + } + + adjacentStar() { + return [ + ...this.adjacentWithDiagonals(), + this.up(2), + this.right(2), + this.down(2), + this.left(2), + ] as const; + } + + expandWithDiagonals() { + const { x, y } = this; + return [ + ...this.expand(), + vec(x - 1, y - 1), + vec(x + 1, y - 1), + vec(x - 1, y + 1), + vec(x + 1, y + 1), + ] as const; + } + + expandStar() { + return [ + ...this.expandWithDiagonals(), + this.up(2), + this.right(2), + this.down(2), + this.left(2), + ] as const; + } + + distance(v: Vector) { + return Math.abs(this.x - v.x) + Math.abs(this.y - v.y); + } + + equals(vector: VectorLike | null) { + return !!vector && this.x == vector.x && this.y == vector.y; + } + + hashCode(): string | number { + return szudzik(this.x + 1, this.y + 1); + } + + valueOf() { + return this.id; + } + + toString() { + return this.id; + } + + toJSON() { + return [this.x, this.y] as const; + } +} + +export function isVector(value: unknown): value is Vector { + // eslint-disable-next-line @nkzw/no-instanceof + return value instanceof Vector; +} + +export function encodeVectorArray(vectors: ReadonlyArray) { + return vectors.flatMap((vector) => [vector.x, vector.y]); +} + +export function decodeVectorArray( + array: ReadonlyArray, +): ReadonlyArray { + const result = []; + for (let i = 0; i < array.length; i += 2) { + result.push(vec(array[i], array[i + 1])); + } + return result; +} + +export function sortVectors(vectors: Array) { + return sortBy(vectors, (vector) => szudzik(vector.x + 1, vector.y + 1)); +} + +export function sortByVectorKey(list: Iterable<[Vector, T]>) { + return sortBy([...list], (item) => szudzik(item[0].x + 1, item[0].y + 1)); +} diff --git a/athena/map/isPlayable.tsx b/athena/map/isPlayable.tsx new file mode 100644 index 00000000..2a1b184a --- /dev/null +++ b/athena/map/isPlayable.tsx @@ -0,0 +1,14 @@ +import MapData, { AnyEntity } from '../MapData.tsx'; +import Player, { PlayerID } from './Player.tsx'; + +export default function isPlayable( + map: MapData, + currentViewer: Player | PlayerID | null, + object?: AnyEntity, +) { + return !!( + currentViewer && + map.isCurrentPlayer(currentViewer) && + (!object || map.matchesPlayer(currentViewer, object)) + ); +} diff --git a/athena/map/vec.tsx b/athena/map/vec.tsx new file mode 100644 index 00000000..805523f2 --- /dev/null +++ b/athena/map/vec.tsx @@ -0,0 +1,23 @@ +import Vector, { szudzik } from './Vector.tsx'; + +const vectors = Array(30_000); + +export default function vec(x: number, y: number): Vector { + if (process.env.NODE_ENV === 'development') { + if (!Number.isInteger(x) || !Number.isInteger(y)) { + throw new Error( + `Vector { x: ${x}, y: ${y} } is invalid. 'x' and 'y' must be integers.`, + ); + } + if (x < -2 || y < -2) { + throw new Error( + `Vector { x: ${x}, y: ${y} } is invalid. 'x' and 'y' must be positive integers.`, + ); + } + } + + // Acceptable vectors may be up to -2 in each dimension, based on `Vector.expandStar`. + const id = szudzik(x + 2, y + 2); + // @ts-expect-error + return vectors[id] || (vectors[id] = new Vector(x, y)); +} diff --git a/athena/mutation/toggleLightningTile.tsx b/athena/mutation/toggleLightningTile.tsx new file mode 100644 index 00000000..0eb0f79f --- /dev/null +++ b/athena/mutation/toggleLightningTile.tsx @@ -0,0 +1,23 @@ +import { Lightning } from '../info/Tile.tsx'; +import getModifier from '../lib/getModifier.tsx'; +import Vector from '../map/Vector.tsx'; +import MapData from '../MapData.tsx'; +import writeTile from './writeTile.tsx'; + +export default function toggleLightningTile(map: MapData, vector: Vector) { + const newMap = map.map.slice(); + const newModifiers = map.modifiers.slice(); + + writeTile( + newMap, + newModifiers, + map.getTileIndex(vector), + map.getTileInfo(vector) === Lightning ? null : Lightning, + getModifier(map, vector, Lightning, Lightning.style.layer), + ); + + return map.copy({ + map: newMap, + modifiers: newModifiers, + }); +} diff --git a/athena/mutation/writeTile.tsx b/athena/mutation/writeTile.tsx new file mode 100644 index 00000000..0e09f56f --- /dev/null +++ b/athena/mutation/writeTile.tsx @@ -0,0 +1,53 @@ +import { + getTile, + getTileInfo, + TileField, + TileInfo, + TileLayer, +} from '../info/Tile.tsx'; +import { ModifierField } from '../MapData.tsx'; + +const getModifier = (modifier: ModifierField, layer: TileLayer): number => { + const isNumber = typeof modifier === 'number'; + return (isNumber ? (layer === 0 ? modifier : 0) : modifier[layer]) || 0; +}; + +export default function writeTile( + newMap: Array, + newModifiers: Array, + index: number, + info: TileInfo | null, + modifier = 0, +) { + if (!info) { + eraseLayer1Tile(newMap, newModifiers, index); + return; + } + + const field = newMap[index]; + const modifierField = newModifiers[index]; + if (info.style.layer === 1) { + const modifier0 = getModifier(modifierField, 0); + newMap[index] = [getTileInfo(field, 0).id, info.id]; + newModifiers[index] = [modifier0, modifier]; + } else { + const tile = getTile(field, 1); + const modifier1 = getModifier(modifierField, 1); + newMap[index] = tile ? [info.id, tile] : info.id; + newModifiers[index] = tile && modifier1 ? [modifier, modifier1] : modifier; + } +} + +function eraseLayer1Tile( + newMap: Array, + newModifiers: Array, + index: number, +) { + const tile = getTileInfo(newMap[index], 1); + if (tile.style.fallback?.style.layer === 1) { + writeTile(newMap, newModifiers, index, tile.style.fallback); + } else { + newMap[index] = getTileInfo(newMap[index], 0).id; + newModifiers[index] = getModifier(newModifiers[index], 0); + } +} diff --git a/athena/package.json b/athena/package.json new file mode 100644 index 00000000..d91d7f76 --- /dev/null +++ b/athena/package.json @@ -0,0 +1,22 @@ +{ + "name": "@deities/athena", + "version": "0.0.1", + "private": true, + "description": "Athena, goddess of wisdom, courage and war strategy.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@deities/hephaestus": "workspace:*", + "@nkzw/immutable-map": "^1.2.2", + "array-shuffle": "^3.0.0", + "fastpriorityqueue": "^0.7.5", + "skmeans": "^0.11.3" + }, + "devDependencies": { + "@types/skmeans": "^0.11.7" + } +} diff --git a/codegen/generate-actions.tsx b/codegen/generate-actions.tsx new file mode 100755 index 00000000..bf887d61 --- /dev/null +++ b/codegen/generate-actions.tsx @@ -0,0 +1,1018 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +import { NodePath } from '@babel/traverse'; +import { + TSType, + TSTypeAliasDeclaration, + TSTypeElement, + TSTypeReference, +} from '@babel/types'; +import groupBy from '@deities/hephaestus/groupBy.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import chalk from 'chalk'; +import { format } from 'prettier'; +import sign from './lib/sign.tsx'; +import traverse from './lib/traverse.tsx'; + +console.log(chalk.bold('› Generating actions...')); + +const root = resolve(fileURLToPath(new URL('.', import.meta.url)), '../'); +const encodedActionsFileName = join(root, './apollo/EncodedActions.tsx'); +const formatActionsFileName = join(root, './apollo/FormatActions.tsx'); +const stableActionMapFileName = join(root, './apollo/ActionMap.json'); +const stableConditionMapFileName = join(root, './apollo/ConditionMap.json'); +const files = [ + './apollo/Action.tsx', + './apollo/ActionResponse.tsx', + './apollo/Condition.tsx', + './apollo/GameOver.tsx', + './apollo/HiddenAction.tsx', +]; + +const customEncoderReferences = new Set([ + 'DynamicPlayerID', + 'Reward', + 'Teams', + 'WinCondition', + 'WinConditionID', +]); +const scalarReferences = new Set(['PlayerID', 'Skill']); +const allowedReferences = new Set([ + 'AttackDirection', + 'Building', + 'DryUnit', + 'Unit', + 'Vector', + ...customEncoderReferences, + ...scalarReferences, +]); + +type ActionType = 'action' | 'condition'; +type ExtractedType = Readonly<{ + id: number; + name: string; + props: ReadonlyArray; + type: ActionType; +}>; + +type Prop = Readonly<{ + name: string; + optional: boolean; + value: ValueType; +}>; + +type ValueType = Readonly< + | { type: 'number'; value: 'number' } + | { type: 'boolean'; value: 'boolean' } + | { type: 'literal'; value: string } + | { type: 'string'; value: 'string' } + | { type: 'reference'; value: string } + | { type: 'entities'; value: string } + | { + readonly: boolean; + type: 'array'; + value: string; + } + | { type: 'object'; value: ReadonlyArray } +>; + +const getShortName = (name: string) => + name.replace(/Action(Response)?|Condition$/, ''); + +const actionMap = new Map]>( + JSON.parse(readFileSync(stableActionMapFileName, 'utf8')), +); +const conditionMap = new Map]>( + JSON.parse(readFileSync(stableConditionMapFileName, 'utf8')), +); +const getStableTypeID = (() => { + let actionCounter = (Array.from(actionMap.values()).pop()?.[0] ?? -1) + 1; + let conditionCounter = + (Array.from(conditionMap.values()).pop()?.[0] ?? -1) + 1; + + return (type: ActionType, name: string) => { + const map = type === 'action' ? actionMap : conditionMap; + const shortName = getShortName(name); + if (!map.has(shortName)) { + map.set(shortName, [ + type === 'action' ? actionCounter++ : conditionCounter++, + [], + ]); + } + return map.get(shortName)![0]; + }; +})(); + +const getStableTypeProps = (type: ActionType, name: string) => + (type === 'action' ? actionMap : conditionMap).get(getShortName(name))?.[1]; + +const isAllowedReference = (node: TSType): node is TSTypeReference => + node.type === 'TSTypeReference' && + node.typeName.type === 'Identifier' && + allowedReferences.has(node.typeName.name); + +const getTypeAnnotation = (typeAnnotation: TSType) => { + return typeAnnotation.type === 'TSTypeReference' && + typeAnnotation.typeName.type === 'Identifier' && + typeAnnotation.typeName.name === 'Readonly' && + typeAnnotation.typeParameters?.params.length + ? typeAnnotation.typeParameters.params[0] + : typeAnnotation; +}; + +const resolveValueType = (node: TSType): ValueType => { + if (node.type === 'TSNumberKeyword') { + return { type: 'number', value: 'number' }; + } + + if (node.type === 'TSStringKeyword') { + return { type: 'string', value: 'string' }; + } + + if (node.type === 'TSBooleanKeyword') { + return { type: 'boolean', value: 'boolean' }; + } + + if (node.type === 'TSLiteralType' && node.literal.type === 'StringLiteral') { + return { type: 'literal', value: node.literal.value }; + } + + if (node.type === 'TSTypeReference' && node.typeName.type === 'Identifier') { + if ( + node.typeName.name === 'ImmutableMap' && + node.typeParameters?.params.every(isAllowedReference) && + node.typeParameters.params[0].typeName.type === 'Identifier' && + node.typeParameters.params[0].typeName.name === 'Vector' && + node.typeParameters.params[1].typeName.type === 'Identifier' + ) { + return { + type: 'entities', + value: node.typeParameters?.params[1].typeName.name, + }; + } + + if (allowedReferences.has(node.typeName.name)) { + return { type: 'reference', value: node.typeName.name }; + } + + if ( + node.typeParameters && + node.typeParameters.type === 'TSTypeParameterInstantiation' && + node.typeParameters.params.length === 1 + ) { + const parameter = node.typeParameters.params[0]; + const value = + isAllowedReference(parameter) && + parameter.typeName.type === 'Identifier' + ? parameter.typeName.name + : parameter.type === 'TSNumberKeyword' + ? 'number' + : null; + if (value) { + return { + readonly: + node.typeName.type === 'Identifier' && + node.typeName.name === 'ReadonlyArray', + type: 'array', + value, + }; + } + } + } + + const objectNode = getTypeAnnotation(node); + if (objectNode.type === 'TSTypeLiteral') { + return { type: 'object', value: objectNode.members.map(extractProp) }; + } + + throw new Error( + `generate-actions: Invalid node type '${ + node.type + }' with node '${JSON.stringify(node, null, 2)}'.`, + ); +}; + +const extractProp = (node: TSTypeElement): Prop => { + if ( + node.type === 'TSPropertySignature' && + node.key.type === 'Identifier' && + node.typeAnnotation + ) { + const { typeAnnotation } = node.typeAnnotation; + return { + name: node.key.name, + optional: !!node.optional, + value: resolveValueType(typeAnnotation), + }; + } + + throw new Error( + `generate-actions: Could not extract member information from '${node.type}'.`, + ); +}; + +const extract = ( + files: ReadonlyArray, +): ReadonlyArray => { + const types: Array = []; + files.map((file) => { + const ast = parse(readFileSync(join(root, file), 'utf8'), { + plugins: ['typescript', 'jsx'], + sourceType: 'module', + }); + traverse(ast, { + TSTypeAliasDeclaration(path: NodePath) { + const typeAnnotation = getTypeAnnotation(path.node.typeAnnotation); + if (typeAnnotation.type === 'TSTypeLiteral') { + const name = path.node.id.name; + const type = name.endsWith('Condition') ? 'condition' : 'action'; + const existingProps = getStableTypeProps(type, name); + const unsortedProps = typeAnnotation.members.flatMap(extractProp); + const props = existingProps?.length + ? sortBy(unsortedProps, ({ name, optional }) => { + const index = existingProps.indexOf(name); + if (!optional && index === -1) { + throw new Error( + `generate-actions: Cannot add new non-optional prop '${name}' to '${path.node.id.name}' with props '${JSON.stringify( + unsortedProps, + null, + 2, + )}'.`, + ); + } + return index === -1 ? Number.POSITIVE_INFINITY : index; + }) + : unsortedProps.sort( + ( + { name: nameA, optional: optionalA }, + { name: nameB, optional: optionalB }, + ) => { + if (nameA === 'type') { + return -1; + } else if (nameB === 'type') { + return 1; + } + + return optionalA === optionalB ? 0 : optionalA ? 1 : -1; + }, + ); + + if ( + !props[0] || + props[0].name !== 'type' || + props[0].value.value !== getShortName(name) + ) { + throw new Error( + `generate-actions: Invalid type definition for '${name}' with props '${JSON.stringify( + props, + null, + 2, + )}'.`, + ); + } + + types.push({ + id: getStableTypeID(type, name), + name, + props, + type, + }); + } + }, + }); + }); + return types; +}; + +const encodePropType = ( + actionName: string, + actionType: ActionType, + props: ReadonlyArray, +): ReadonlyArray => + props.reduce>((list, { name, optional, value: valueType }) => { + const { type, value } = valueType; + const suffix = optional ? ' | null' : ''; + if (name === 'type' && type === 'literal') { + return [ + ...list, + `${name}: ${getStableTypeID(actionType, actionName) + suffix}`, + ]; + } + + if (type === 'boolean') { + return [...list, `${name}: 0 | 1 ${suffix}`]; + } + + if (type === 'entities') { + return [...list, `${name}: PlainEntitiesList`]; + } + + if (type === 'array') { + return [ + ...list, + `${name}: ${valueType.readonly ? 'Readonly' : ''}Array<${ + value === 'Vector' ? 'number' : value + }>${suffix}`, + ]; + } + if (type === 'object' && typeof value !== 'string') { + return [...list, ...encodePropType(actionName, actionType, value)]; + } + + if (type === 'reference' && value === 'Vector') { + return [ + ...list, + `${name}X: number ${suffix}`, + `${name}Y: number ${suffix}`, + ]; + } + + if (type === 'reference') { + return [...list, `${name}: Plain${value}${suffix}`]; + } + + return [...list, `${name}: ${value}${suffix}`]; + }, []); + +const encodeProps = ( + props: ReadonlyArray, + actionType: ActionType, + prefix?: string, +): ReadonlyArray => + props.reduce>( + (list, { name, optional, value: { type, value } }) => { + const identifier = `${prefix ? prefix + '.' : `${actionType}.`}${name}`; + if (name === 'type' && type === 'literal' && typeof value === 'string') { + return [...list, String(getStableTypeID(actionType, value))]; + } + + if (type === 'boolean') { + return [...list, `${identifier} ? 1 : ${optional ? `null` : `0`}`]; + } + + if (type === 'entities') { + return [...list, `encodeEntities(${identifier})`]; + } + + if (type === 'array') { + if (value === 'Vector') { + return [ + ...list, + `${ + optional ? `${identifier}?.length ? ` : '' + }${identifier}.flatMap(vector => [vector.x, vector.y])${ + optional ? ` : null` : '' + }`, + ]; + } + return [...list, identifier]; + } + + if (type === 'object' && typeof value !== 'string') { + return [...list, ...encodeProps(value, actionType, identifier)]; + } + + if (type === 'reference' && value === 'Vector') { + if (optional) { + return [ + ...list, + `${identifier} != null ? ${identifier}.x : null`, + `${identifier} != null ? ${identifier}.y : null`, + ]; + } + + return [...list, `${identifier}.x`, `${identifier}.y`]; + } + + if (type === 'reference' && typeof value === 'string') { + const encodeCall = scalarReferences.has(value) + ? identifier + : customEncoderReferences.has(value) + ? `encode${value}(${identifier})` + : `${identifier}.toJSON()`; + if (optional) { + return [...list, `${identifier} != null ? ${encodeCall} : null`]; + } + return [...list, encodeCall]; + } + + return [ + ...list, + optional ? `${identifier} != null ? ${identifier} : null` : identifier, + ]; + }, + [], + ); + +const decodeProps = ( + actionName: string, + actionType: ActionType, + props: ReadonlyArray, + counter = 0, +): ReadonlyArray => + props + .reduce<{ counter: number; list: Array }>( + ({ counter, list }, { name, optional, value: { type, value } }) => { + if (name === 'type' && type === 'literal') { + return { + counter: counter + 1, + list: [...list, `${name}: "${getShortName(actionName)}"`], + }; + } + + if (type === 'boolean') { + return { + counter: counter + 1, + list: [...list, `${name}: !!${actionType}[${counter}]`], + }; + } + + if (type === 'entities' && typeof value === 'string') { + return { + counter: counter + 1, + list: [ + ...list, + `${name}: decodeEntities(action[${counter}], ${ + value.slice(0, 1).toUpperCase() + value.slice(1) + }.fromJSON)`, + ], + }; + } + + if (type === 'array' && value === 'Vector') { + return { + counter: counter + 1, + list: [ + ...list, + `${name}: ${ + optional ? `action[${counter}] ? ` : '' + }decodeVectorArray(action[${counter}])${ + optional ? ` : undefined` : '' + }`, + ], + }; + } + + if (type === 'object' && typeof value !== 'string') { + return { + counter: counter + value.length, + list: [ + ...list, + `${name}: {${decodeProps( + actionName, + actionType, + value, + counter, + ).join(',')}}`, + ], + }; + } + + if (type === 'reference' && value === 'Vector') { + return { + counter: counter + 2, + list: [ + ...list, + optional + ? `${name}: ${actionType}[${counter}] && ${actionType}[${ + counter + 1 + }] ? vec(${actionType}[${counter}], ${actionType}[${ + counter + 1 + }]) : undefined` + : `${name}: vec(${actionType}[${counter}], ${actionType}[${ + counter + 1 + }])`, + ], + }; + } + + if (type === 'reference' && typeof value === 'string') { + const decodeCall = scalarReferences.has(value) + ? `to${value}(${actionType}[${counter}])` + : customEncoderReferences.has(value) + ? `decode${value}(${actionType}[${counter}])` + : `${value}.fromJSON(${actionType}[${counter}])`; + return { + counter: counter + 1, + list: [ + ...list, + optional + ? `${name}: ${actionType}[${counter}] ? ${decodeCall} : undefined` + : `${name}: ${decodeCall}`, + ], + }; + } + + return { + counter: counter + 1, + list: [ + ...list, + `${name}: ${actionType}[${counter}]${ + optional ? ` ?? undefined` : `` + }`, + ], + }; + }, + { counter, list: [] }, + ) + .list.sort((a, b) => a.localeCompare(b)); + +const directionMapper = new Map([ + ['direction', "direction: '${c.green(action.direction.direction)}'"], +]); + +const fieldMappers = new Map([ + [ + 'CreateBuilding', + new Map([['id', "name: '${c.green(getBuildingInfo(action.id)?.name)}'"]]), + ], + [ + 'CreateUnit', + new Map([['id', "name: '${c.green(getUnitInfo(action.id)?.name)}'"]]), + ], + ['HiddenSourceAttackBuilding', directionMapper], + ['HiddenSourceAttackUnit', directionMapper], + ['HiddenTargetAttackBuilding', directionMapper], + ['HiddenTargetAttackUnit', directionMapper], +]); + +const formatCall = (value: string, name: string) => + scalarReferences.has(value) + ? `util.inspect(${name}, formatOptions)` + : customEncoderReferences.has(value) + ? `util.inspect(format${value}(${name}), formatOptions)` + : `${name}.info.name + ' ' + util.inspect(${name}.format ? ${name}.format() : ${name}.toJSON(), formatOptions)`; + +const formatValue = ( + name: string, + optional: boolean, + { type, value }: ValueType, + prefix?: string, +): string => { + prefix = prefix ? prefix + '.' : ''; + if (type === 'entities' && typeof value === 'string') { + return `${ + optional ? `\${action.${prefix}${name} ? \`` : '' + }[\${action.${prefix}${name}.map((unit, vector) => c.red(vector.x) + ',' + c.red(vector.y) + ' → ' + ${formatCall( + value, + `unit`, + )}).join(', ')}]${optional ? `\` : c.dim('null')}` : ''}`; + } + + if (type === 'array' && value === 'Vector') { + return `${ + optional ? `\${action.${prefix}${name} ? \`` : '' + }[\${action.${prefix}${name}.map(vector => c.red(vector.x) + ',' + c.red(vector.y)).join(' → ')}]${ + optional ? `\` : c.dim('null')}` : '' + }`; + } + + if (type === 'object' && typeof value !== 'string') { + return `{ ${value + .map((prop) => formatProp(name, prop, prefix + name)) + .join(', ')} }`; + } + + if (type === 'reference' && value === 'Vector') { + return `\${c.red(action.${prefix}${name}.x) + ',' + c.red(action.${prefix}${name}.y)}`; + } + + if (type === 'reference' && typeof value === 'string') { + return `\${${optional ? `action.${prefix}${name} ? ` : ''}${formatCall( + value, + `action.${prefix}${name}`, + )}${optional ? ` : c.dim('null')` : ''}}`; + } + + let formattedValue = `action.${prefix}${name}`; + if (value === 'number') { + formattedValue = `c.red(action.${prefix}${name})`; + } else if (value === 'boolean') { + formattedValue = `c.blue(!!action.${prefix}${name})`; + } else if (value === 'string') { + formattedValue = `c.green("'" + action.${prefix}${name} + "'")`; + } + + return optional + ? `\${action.${prefix}${name} != null ? ${formattedValue} : c.dim('null')}` + : `\${${formattedValue}}`; +}; + +const formatProp = ( + shortActionName: string, + { name, optional, value }: Prop, + prefix?: string, +): string => { + return fieldMappers.has(shortActionName) && + fieldMappers.get(shortActionName)!.has(name) + ? fieldMappers.get(shortActionName)!.get(name)! + : `${name}: ${formatValue(name, optional, value, prefix)}`; +}; + +const formatAction = ({ name: actionName, props }: ExtractedType): string => { + const shortName = getShortName(actionName); + const from = props.find(({ name }) => name === 'from'); + const to = props.find(({ name }) => name === 'to'); + props = props.filter( + ({ name }) => name !== 'type' && name !== 'from' && name !== 'to', + ); + const content = ` \${c.dim('{')} ${props + .map((prop) => formatProp(shortName, prop)) + .join(', ')} \${c.dim('}')}`; + const allAreOptional = props.every(({ optional }) => optional); + return ` + case '${shortName}': + return \`\${c.bold('${shortName}')}${ + from || to + ? ` (${ + from + ? from.optional + ? '${action.from ? `${c.red(action.from.x)},${c.red(action.from.y)}` : ""}' + : '${c.red(action.from.x)},${c.red(action.from.y)}' + : '' + }${ + from && to + ? to.optional || from.optional + ? '${action.from && action.to ? " " : ""}' + : ' ' + : '' + }${ + to + ? to.optional + ? '${action.to ? `→ ${c.red(action.to.x)},${c.red(action.to.y)}` : ""}' + : '→ ${c.red(action.to.x)},${c.red(action.to.y)}' + : '' + })` + : '' + }${ + props.length + ? allAreOptional + ? `\${${props + .map(({ name }) => `action.${name}`) + .join(' || ')} ? \`${content}\` : ''}` + : content + : '' + }\`;`; +}; + +const write = async (extractedTypes: ReadonlyArray) => { + const group = groupBy(extractedTypes, ({ name }) => + name.endsWith('Action') + ? 'action' + : name.endsWith('ActionResponse') + ? 'action-response' + : 'condition', + ); + + const actions = group.get('action'); + const conditions = group.get('condition'); + const actionResponses = group.get('action-response'); + + if (!actions || !conditions || !actionResponses) { + throw new Error(`generate-actions: Some action types are missing.`); + } + + const code = [ + ` + import { Action, Actions } from './Action.tsx'; + import { ActionResponse, ActionResponses, ActionResponseType } from './ActionResponse.tsx'; + import { + Condition, + Conditions, + PlainWinConditionID, + decodeWinConditionID, + encodeWinConditionID, + } from './Condition.tsx'; + import { + AttackDirection, + PlainAttackDirection, + } from './attack-direction/getAttackDirection.tsx'; + import { + PlainWinCondition, + decodeWinCondition, + encodeWinCondition, + } from '@deities/athena/WinConditions.tsx'; + import { Skill as PlainSkill } from '@deities/athena/info/Skill.tsx'; + import Building, { PlainBuilding } from '@deities/athena/map/Building.tsx'; + import { PlainEntitiesList } from '@deities/athena/map/PlainMap.tsx'; + import { + decodeDynamicPlayerID, + encodeDynamicPlayerID, + PlainDynamicPlayerID, + PlainPlayerID, + toPlayerID + } from '@deities/athena/map/Player.tsx'; + import { + decodeReward, + encodeReward, + PlainReward, + } from '@deities/athena/map/Reward.tsx'; + import { + decodeEntities, + decodeTeams, + encodeEntities, + encodeTeams, + } from '@deities/athena/map/Serialization.tsx'; + import { PlainTeams } from '@deities/athena/map/Team.tsx'; + import Unit, { + DryUnit, + PlainDryUnit, + PlainUnit, + } from '@deities/athena/map/Unit.tsx'; + import { decodeVectorArray } from '@deities/athena/map/Vector.tsx'; + import vec from '@deities/athena/map/vec.tsx'; + `, + ...extractedTypes.map(({ name, props, type }) => { + return ` + type Encoded${name} = [ + ${encodePropType(name, type, props).join(',')} + ]`; + }), + ` + export type EncodedAction = ${actions + .map(({ name }) => `Encoded${name}`) + .join(' | ')}`, + `export type EncodedCondition = ${conditions + .map(({ name }) => `Encoded${name}`) + .join(' | ')}`, + `export type EncodedActionResponse = ${actionResponses + .map(({ name }) => `Encoded${name}`) + .join(' | ')}`, + ` + export type EncodedActions = ReadonlyArray; + export type EncodedConditions = ReadonlyArray; + export type EncodedActionResponses = ReadonlyArray; + export type EncodedActionResponseType = EncodedActionResponse[0]; + + const toSkill = (skill: PlainSkill) => skill; + + const removeNull = (array: T): T => { + let index = array.length - 1; + while (array[index as number] == null) { + index--; + } + array.length = (index + 1) as typeof array.length; + return array; + }; + + export function encodeAction(action: Action): EncodedAction { + switch (action.type) { + `, + ...actions.map(({ name, props, type }) => { + const hasOptional = props.at(-1)?.optional; + const value = `[${encodeProps(props, type).join(',')}]`; + return ` + case '${getShortName(name)}': + return ${hasOptional ? `removeNull(${value})` : value};`; + }), + ` + }} + + export function encodeActions(actions: Actions): EncodedActions { + return actions.map(encodeAction); + } + + export function encodeCondition(condition: Condition): EncodedCondition { + switch (condition.type) { + `, + ...conditions.map(({ name, props, type }) => { + const hasOptional = props.at(-1)?.optional; + const value = `[${encodeProps(props, type).join(',')}]`; + return ` + case '${getShortName(name)}': + return ${hasOptional ? `removeNull(${value})` : value};`; + }), + ` + }} + export function encodeConditions(conditions: Conditions): EncodedConditions { + return conditions.map(encodeCondition); + } + + export function encodeActionResponse(action: ActionResponse): EncodedActionResponse { + switch (action.type) { + `, + ...actionResponses.map(({ name, props, type }) => { + const hasOptional = props.at(-1)?.optional; + const value = `[${encodeProps(props, type).join(',')}]`; + return ` + case '${getShortName(name)}': + return ${hasOptional ? `removeNull(${value})` : value};`; + }), + ` + }} + export function encodeActionResponses(actions: ActionResponses): EncodedActionResponses { + return actions.map(encodeActionResponse); + } + + export function encodeActionID(action: ActionResponseType): EncodedActionResponseType { + switch (action) { + `, + ...actionResponses.map( + ({ name, type }) => + `case '${getShortName(name)}': + return ${getStableTypeID(type, name)};`, + ), + `default: { + throw new Error('encodeActionID: Invalid Action ID.'); + } + }} + export function decodeAction(action: EncodedAction): Action { + switch (action[0]) { + `, + ...actions.map( + ({ name, props, type }) => ` + case ${getStableTypeID(type, name)}: + return {${decodeProps(name, type, props).join(',')}};`, + ), + `}} + export function decodeActions(actions: EncodedActions): Actions { + return actions.map(decodeAction); + } + + export function decodeCondition(condition: EncodedCondition): Condition { + switch (condition[0]) { + `, + ...conditions.map( + ({ name, props, type }) => ` + case ${getStableTypeID(type, name)}: + return {${decodeProps(name, type, props).join(',')}};`, + ), + ` + }} + export function decodeConditions(conditions: EncodedConditions): Conditions { + return conditions.map(decodeCondition); + } + + export function decodeActionResponse(action: EncodedActionResponse): ActionResponse { + switch (action[0]) { + `, + ...actionResponses.map( + ({ name, props, type }) => + `case ${getStableTypeID(type, name)}: + return {${decodeProps(name, type, props).join(',')}};`, + ), + ` + }} + export function decodeActionResponses(action: EncodedActionResponses): ActionResponses { + return action.map(decodeActionResponse); + } + + export function decodeActionID(encodedAction: EncodedActionResponseType): ActionResponseType { + switch (encodedAction) { + `, + ...actionResponses.map( + ({ name, type }) => + `case ${getStableTypeID(type, name)}: + return '${getShortName(name)}';`, + ), + `default: { + throw new Error('decodeActionID: Invalid Action ID.'); + } + }}`, + ]; + + const formatCode = [ + ` + import { Action } from './Action.tsx'; + import { ActionResponse, ActionResponses } from './ActionResponse.tsx'; + import { formatWinCondition } from '@deities/athena/WinConditions.tsx'; + import { getBuildingInfo } from '@deities/athena/info/Building.tsx'; + import { getUnitInfo } from '@deities/athena/info/Unit.tsx'; + import { formatReward } from '@deities/athena/map/Reward.tsx'; + import { formatTeams } from '@deities/athena/map/Serialization.tsx'; + import chalk, { Chalk } from 'chalk'; + import util from 'node:util'; + + const formatDynamicPlayerID = String; + + const fakeChalk = new Chalk({ level: 0 }); + util.inspect.styles.number = 'red'; + util.inspect.styles.boolean = 'blue'; + + export function formatAction( + action: Action, + { colors }: { colors: boolean } = { colors: true }, + ): string { + const c = colors ? chalk : fakeChalk; + const formatOptions = {breakLength: Number.POSITIVE_INFINITY, colors, compact: true, depth: Number.POSITIVE_INFINITY}; + switch (action.type) { + `, + ...actions.map(formatAction), + ` + }} + export function formatActions(actions: ReadonlyArray, {colors}: {colors: boolean} = {colors: true}): ReadonlyArray { + return actions.map(action => formatAction(action, {colors})); + }`, + ` + export function formatActionResponse(action: ActionResponse, {colors}: {colors: boolean} = {colors: true}): string { + const c = colors ? chalk : fakeChalk; + const formatOptions = {breakLength: Number.POSITIVE_INFINITY, colors, compact: true, depth: Number.POSITIVE_INFINITY}; + switch (action.type) { + `, + ...actionResponses.map(formatAction), + ` + }} + export function formatActionResponses(actions: ActionResponses, {colors}: {colors: boolean} = {colors: true}): ReadonlyArray { + return actions.map(action => formatActionResponse(action, {colors})); + }`, + ]; + + const getPropNames = (props: ReadonlyArray): ReadonlyArray => + props.flatMap(({ name, value }) => [ + name, + ...(value.type === 'object' ? getPropNames(value.value) : []), + ]); + + const getOptionalProps = ( + props: ReadonlyArray, + ): ReadonlyArray => + props.flatMap(({ name, optional, value }) => + // Do not reorder to/from Vectors since it changes existing encoded actions. + optional && name !== 'to' && name !== 'from' + ? [name, ...(value.type === 'object' ? getPropNames(value.value) : [])] + : [], + ); + + const newActionMap = new Map]>(); + for (const action of actions) { + newActionMap.set(getShortName(action.name), [ + action.id, + new Set(getPropNames(action.props)), + ]); + } + + for (const action of actionResponses) { + const name = getShortName(action.name); + const optionalProps = new Set(getOptionalProps(action.props)); + newActionMap.set(name, [ + action.id, + new Set([ + ...Array.from(newActionMap.get(name)?.[1] || []).filter( + (propName) => !optionalProps.has(propName), + ), + ...getPropNames(action.props), + ]), + ]); + } + const actionMapOutput = await format( + JSON.stringify( + sortBy( + Array.from(newActionMap).map( + ([name, [id, props]]) => [name, [id, Array.from(props)]] as const, + ), + ([, [id]]) => id, + ), + ), + { + filepath: stableActionMapFileName, + parser: 'json', + }, + ); + + const newConditionMap = new Map]>(); + for (const condition of conditions) { + newConditionMap.set(getShortName(condition.name), [ + condition.id, + new Set(getPropNames(condition.props)), + ]); + } + + const conditionMapOutput = await format( + JSON.stringify( + sortBy( + Array.from(newConditionMap).map( + ([name, [id, props]]) => [name, [id, Array.from(props)]] as const, + ), + ([, [id]]) => id, + ), + ), + { + filepath: stableConditionMapFileName, + parser: 'json', + }, + ); + const encodedActionsOutput = sign( + await format(code.join('\n'), { + filepath: encodedActionsFileName, + singleQuote: true, + }), + ); + const formatActionsOutput = sign( + await format(formatCode.join('\n'), { + filepath: formatActionsFileName, + singleQuote: true, + }), + ); + + writeFileSync(stableActionMapFileName, actionMapOutput); + writeFileSync(stableConditionMapFileName, conditionMapOutput); + writeFileSync(encodedActionsFileName, encodedActionsOutput); + writeFileSync(formatActionsFileName, formatActionsOutput); +}; + +await write(extract(files)); + +console.log(chalk.bold.green('✓ Done generating actions.')); diff --git a/codegen/generate-all.tsx b/codegen/generate-all.tsx new file mode 100755 index 00000000..e71275d3 --- /dev/null +++ b/codegen/generate-all.tsx @@ -0,0 +1,7 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm + +import('./generate-actions.tsx'); +import('./generate-campaign-names.tsx'); +import('./generate-translations.tsx'); +import('./generate-graphql.tsx'); +import('./generate-routes.tsx'); diff --git a/codegen/generate-campaign-names.tsx b/codegen/generate-campaign-names.tsx new file mode 100755 index 00000000..b92eb99b --- /dev/null +++ b/codegen/generate-campaign-names.tsx @@ -0,0 +1,46 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm +import { writeFileSync } from 'node:fs'; +import { join, posix, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import toSlug from '@deities/hephaestus/toSlug.tsx'; +import chalk from 'chalk'; +import { globSync } from 'glob'; +import { format } from 'prettier'; +import sign from './lib/sign.tsx'; + +console.log(chalk.bold('› Generating campaign names...')); + +const root = process.cwd(); +const globs: ReadonlyArray = [ + './hermes/map-fixtures/*.tsx', + './fixtures/map/*.tsx', +]; +const outputFile = join(root, './hermes/CampaignMapName.tsx'); + +const maps = ( + await Promise.all( + globs + .flatMap((path) => globSync(join(root, path).split(sep).join(posix.sep))) + .map((file) => import(pathToFileURL(file).toString())), + ) +) + .filter((module) => module.metadata.tags?.includes('campaign')) + .map((module) => toSlug(module.metadata.name)) + .sort((a, b) => String(a).localeCompare(String(b))); + +writeFileSync( + outputFile, + sign( + await format( + `export type CampaignMapName = ${maps + .map((name) => `'${name}'`) + .join(' | ')};`, + { + filepath: outputFile, + singleQuote: true, + }, + ), + ), +); + +console.log(chalk.bold.green('✓ Done generating campaign names.')); diff --git a/codegen/generate-graphql.tsx b/codegen/generate-graphql.tsx new file mode 100755 index 00000000..93e4cdc5 --- /dev/null +++ b/codegen/generate-graphql.tsx @@ -0,0 +1,43 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm +import { writeFileSync } from 'node:fs'; +import { join, posix, relative, sep } from 'node:path'; +import chalk from 'chalk'; +import { globSync } from 'glob'; +import { format } from 'prettier'; +import isOpenSource from '../infra/isOpenSource.tsx'; +import sign from './lib/sign.tsx'; + +console.log(chalk.bold('› Generating GraphQL schema import map...')); + +const root = process.cwd(); +const path = join(root, 'artemis/graphql'); +const outputFile = join(path, 'schemaImportMap.tsx'); + +const files = ( + await Promise.all( + globSync(`${path}/{nodes,mutations}/*.tsx`.split(sep).join(posix.sep)), + ) +) + .map((file) => relative(path, file.slice(0, file.lastIndexOf('.')))) + .sort((a, b) => String(a).localeCompare(String(b))); + +if (files.length) { + writeFileSync( + outputFile, + sign( + await format(`${files.map((name) => `import './${name}';`).join('\n')}`, { + filepath: outputFile, + singleQuote: true, + }), + ), + ); +} else { + const message = `generate-graphql: No GraphQL schema files found.`; + if (!isOpenSource()) { + throw new Error(message); + } + + console.warn(' ' + chalk.yellow(message)); +} + +console.log(chalk.bold.green('✓ Done generating GraphQL schema import map.')); diff --git a/codegen/generate-routes.tsx b/codegen/generate-routes.tsx new file mode 100755 index 00000000..54964f40 --- /dev/null +++ b/codegen/generate-routes.tsx @@ -0,0 +1,167 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from '@babel/parser'; +import { NodePath } from '@babel/traverse'; +import { JSXIdentifier } from '@babel/types'; +import chalk from 'chalk'; +import { format } from 'prettier'; +import isOpenSource from '../infra/isOpenSource.tsx'; +import sign from './lib/sign.tsx'; +import traverse from './lib/traverse.tsx'; + +console.log(chalk.bold('› Generating routes...')); + +const root = process.cwd(); +const routesFileName = join(root, './apollo/Routes.tsx'); +const files = ['./ares/src/ui/Main.tsx']; + +type Route = string; + +const extract = (files: Array): ReadonlySet => { + const routes = new Set(); + for (const file of files) { + const filePath = join(root, file); + if (!existsSync(filePath)) { + const message = `generate-routes: File '${filePath}' is not present.`; + if (!isOpenSource()) { + throw new Error(message); + } + + console.warn(' ' + chalk.yellow(message)); + continue; + } + + const ast = parse(readFileSync(filePath, 'utf8'), { + plugins: ['typescript', 'jsx'], + sourceType: 'module', + }); + traverse(ast, { + JSXIdentifier(path: NodePath) { + if ( + path.node.name === 'Route' && + path.parentPath?.node?.type === 'JSXOpeningElement' + ) { + const attribute = path.parentPath + .get('attributes') + .find( + (prop) => + prop.node?.type === 'JSXAttribute' && + prop.node.name.type === 'JSXIdentifier' && + prop.node.name.name === 'path', + ); + if ( + attribute?.node && + attribute.node.type === 'JSXAttribute' && + attribute.node.value?.type === 'StringLiteral' + ) { + const routePath = attribute.node.value.value; + if (routePath === '*') { + return; + } + if (!routePath.startsWith('/')) { + throw new Error( + `generate-routes: Route path '${routePath}' must start with '/'.`, + ); + } + + routes.add(routePath); + } + } + }, + }); + } + return routes; +}; + +const encodeRoute = (route: string) => + '`' + route.replaceAll(/:([^/]*?)+/gi, '${string}') + '${`?${string}` | ``}`'; + +const encodeRouteType = (routes: ReadonlySet): ReadonlySet => { + const encodedRoutes = new Set(); + for (const route of routes) { + if (!route.startsWith('/:')) { + encodedRoutes.add(encodeRoute(route)); + } + } + return encodedRoutes; +}; + +const encodeUserRouteType = ( + routes: ReadonlySet, +): [ + userRoutes: ReadonlySet, + mapRoutes: ReadonlySet, + campaignRoutes: ReadonlySet, +] => { + const userRoutes = new Set(); + const mapRoutes = new Set(); + const campaignRoutes = new Set(); + for (const route of routes) { + if (route.startsWith('/:username')) { + const userRoute = route.split('/').slice(2).join('/'); + if (userRoute?.length) { + if (userRoute.startsWith(':slug')) { + const mapRoute = userRoute.split('/').slice(1).join('/'); + mapRoutes.add(encodeRoute(mapRoute)); + } else if (userRoute.startsWith('campaign/')) { + const campaignRoute = userRoute.split('/').slice(2).join('/'); + campaignRoutes.add(encodeRoute(campaignRoute)); + } else { + userRoutes.add(encodeRoute(userRoute)); + } + } + } + } + return [userRoutes, mapRoutes, campaignRoutes]; +}; + +const encodeTopLevelRouteNames = ( + routes: ReadonlySet, +): ReadonlySet => { + const topLevelRoutes = new Set(); + for (const route of routes) { + const topLevelRoute = + route.split('/')[route.startsWith('/:username') ? 2 : 1]; + if (topLevelRoute?.length && !topLevelRoute.startsWith(':')) { + topLevelRoutes.add(topLevelRoute); + } + } + return topLevelRoutes; +}; + +const writeRoutesFile = async (routes: ReadonlySet) => { + const [userRoutes, mapRoutes, campaignRoutes] = encodeUserRouteType(routes); + const code = routes.size + ? [ + `export type Route = ${[...encodeRouteType(routes)].join('|')};`, + `export type UserCampaignRoute = ${[...campaignRoutes].join('|')};`, + `export type UserMapRoute = ${[...mapRoutes].join('|')};`, + `export type UserRoute = \`\${string}/\${UserMapRoute}\` | \`campaign/\${string}/\${UserCampaignRoute}\`${ + userRoutes.size ? ` | ${[...userRoutes].join('|')}` : '' + };`, + `export const Route = new Set(${JSON.stringify([ + ...encodeTopLevelRouteNames(routes), + ])});`, + ] + : [ + `export type Route = string;`, + `export type UserCampaignRoute = string;`, + `export type UserMapRoute = string;`, + `export type UserRoute = string;`, + `export const Route = new Set();`, + ]; + writeFileSync( + routesFileName, + sign( + await format(code.join('\n\n'), { + filepath: routesFileName, + singleQuote: true, + }), + ), + ); +}; + +await writeRoutesFile(extract(files)); + +console.log(chalk.bold.green('✓ Done generating routes.')); diff --git a/codegen/generate-translations.tsx b/codegen/generate-translations.tsx new file mode 100755 index 00000000..e6c5b29b --- /dev/null +++ b/codegen/generate-translations.tsx @@ -0,0 +1,372 @@ +#!/usr/bin/env node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm +import { writeFileSync } from 'node:fs'; +import { basename, extname, join, posix, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import getMessageKey from '@deities/apollo/lib/getMessageKey.tsx'; +import { mapBuildings } from '@deities/athena/info/Building.tsx'; +import { mapDecorators } from '@deities/athena/info/Decorator.tsx'; +import { mapTiles } from '@deities/athena/info/Tile.tsx'; +import { + getUnitInfo, + getUnitInfoOrThrow, + mapMovementTypes, + mapUnits, + mapWeapons, +} from '@deities/athena/info/Unit.tsx'; +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import parseInteger from '@deities/hephaestus/parseInteger.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import { CampaignModule, MapModule } from '@deities/hermes/Types.tsx'; +import unrollCampaign from '@deities/hermes/unrollCampaign.tsx'; +import chalk from 'chalk'; +import { globSync } from 'glob'; +import { format } from 'prettier'; +import isOpenSource from '../infra/isOpenSource.tsx'; +import sign from './lib/sign.tsx'; + +type EntityDescription = Readonly<{ + description: string; + detail?: string; + hasNameSubstitution?: boolean; + id: number; +}>; + +const root = process.cwd(); +const publishedCampaigns = new Map([ + ['proto-campaign', Number.POSITIVE_INFINITY], + ['the-athena-crisis', Number.POSITIVE_INFINITY], +]); + +const COMMON_OUTPUT_FILE = join(root, 'i18n/Entities.cjs'); +const FBT_ENTITY_MAP_OUTPUT_FILE = join(root, 'hera/i18n/EntityMap.tsx'); +const FBT_CAMPAIGN_MAP_OUTPUT_FILE = join(root, 'hera/i18n/CampaignMap.tsx'); + +console.log(chalk.bold('› Generating translations...')); + +const globs: ReadonlyArray = [ + './hermes/map-fixtures/*.tsx', + './fixtures/map/*.tsx', +]; +const maps = await Promise.all( + globs.flatMap((path) => + globSync(join(root, path).split(sep).join(posix.sep)) + .sort() + .map((filename) => + (import(pathToFileURL(filename).toString()) as Promise).then( + (module) => ({ + id: basename(filename, extname(filename)), + module, + }), + ), + ), + ), +); +const mapsById = new Map(maps.map(({ id, module }) => [id, module])); + +const campaigns = await Promise.all( + globSync(join(root, './fixtures/campaign/*.tsx').split(sep).join(posix.sep)) + .sort() + .map((filename) => + ( + import(pathToFileURL(filename).toString()) as Promise + ).then((module) => ({ + id: basename(filename, extname(filename)), + module, + })), + ), +); + +const campaignMaps = isOpenSource() + ? maps.map((map) => ({ + campaignName: 'placeholder-campaign', + map: map.module, + })) + : campaigns + .filter(({ id }) => publishedCampaigns.has(id)) + .flatMap(({ id, module }) => { + const publishedLevels = publishedCampaigns.get(id)!; + const maps = []; + for (const [level] of unrollCampaign(module.default)) { + const map = mapsById.get(level); + if (!map) { + throw new Error( + `Could not find map ${level} for campaign ${module.default.name}.`, + ); + } + const maybeMapNumber = map.metadata.tags?.find((tag) => + parseInteger(tag), + ); + const mapNumber = maybeMapNumber + ? parseInteger(maybeMapNumber) + : null; + + if ( + publishedLevels === Number.POSITIVE_INFINITY || + (mapNumber && mapNumber > 0 && mapNumber <= publishedLevels) + ) { + maps.push({ campaignName: module.default.name, map }); + } + } + return maps; + }); + +const characterMessages = campaignMaps.flatMap( + ({ + campaignName, + map: { + metadata: { effects, name, tags }, + }, + }) => { + if (!effects?.size) { + return []; + } + + const mapNumber = tags?.find((tag) => parseInteger(tag)) || ''; + const actions: Array<{ + description: string; + key: string; + message: string; + unitId: number; + }> = []; + for (const [trigger, effectList] of effects) { + for (const effect of effectList) { + const gameEnd = effect.conditions?.find( + (condition) => condition.type === 'GameEnd', + ); + const winCondition = gameEnd?.type === 'GameEnd' ? gameEnd.value : null; + let count = 1; + for (const action of effect.actions) { + if (action.type === 'CharacterMessageEffect') { + actions.push({ + description: `Campaign ${campaignName}, Map${ + mapNumber ? ` ${mapNumber}` : '' + } "${name}", ${trigger}${ + winCondition ? ` ${winCondition}` : '' + }, #${count++}: Player ${action.player}, Character ${ + getUnitInfoOrThrow(action.unitId).characterName + }`, + key: getMessageKey(action), + message: action.message, + unitId: action.unitId, + }); + } + } + } + } + + return actions; + }, +); + +const extractName = ({ name }: { name: string }) => name; +const sort = (a: string, b: string) => a.localeCompare(b); + +const entityMap = new Map([ + ['Building', mapBuildings(extractName).sort(sort)], + ['Character', mapUnits(({ characterName }) => characterName).sort(sort)], + ['Decorator', mapDecorators(extractName).sort(sort)], + ['MovementType', mapMovementTypes(extractName).sort(sort)], + ['Tile', mapTiles(extractName).sort(sort)], + ['Unit', mapUnits(extractName).sort(sort)], + ['Weapon', mapWeapons(extractName).sort(sort)], + [ + 'Map', + maps.map(({ module: { metadata } }) => metadata.name || '').sort(sort), + ], +]); + +const descriptionMap = new Map>([ + [ + 'BuildingDescription', + sortBy( + mapBuildings(({ description, id, name }) => ({ + description, + detail: `name: ${name}`, + id, + })), + ({ id }) => id, + ), + ], + [ + 'TileDescription', + sortBy( + mapTiles(({ description, id, name }) => ({ + description, + detail: `name: ${name}`, + id, + })), + ({ id }) => id, + ), + ], + [ + 'UnitCharacterDescription', + sortBy( + mapUnits((unit) => ({ + description: unit.getOriginalCharacterDescription(), + detail: `name: ${unit.characterName}, gender: ${unit.gender}`, + hasNameSubstitution: true, + id: unit.id, + })), + ({ id }) => id, + ), + ], + [ + 'UnitDescription', + sortBy( + mapUnits(({ description, id, name }) => ({ + description, + detail: `name: ${name}`, + id, + })), + ({ id }) => id, + ), + ], +]); + +const replaceSubstitutions = (text: string, currentId: number) => { + const existingSubstitutions = new Set(); + return text.replaceAll(/{(?:(\d+)\.)?name}/g, (_, maybeId) => { + const maybeUnitID = maybeId?.length && parseInteger(maybeId); + const unit = + (maybeUnitID && getUnitInfo(maybeUnitID)) || getUnitInfo(currentId); + if (!unit) { + throw new Error( + `generate-entity-translations: Could not find unit for name substitution for unit with id '${currentId}'.`, + ); + } + if (existingSubstitutions.has(unit.id)) { + return `\${fbt.sameParam('${unit.id}.name')}`; + } + + existingSubstitutions.add(unit.id); + return `\${fbt.param('${unit.id}.name', getUnitInfoOrThrow(${unit.id}).characterName)}`; + }); +}; + +const commonNames = new Set(); +const toCommon = (typeName: string, list: ReadonlyArray) => + list + .map((name) => { + if (commonNames.has(name)) { + return null; + } + + commonNames.add(name); + return `${JSON.stringify(name)}: ${JSON.stringify( + `${typeName} name${ + typeName === 'Map' + ? ` (Only translate map names if it helps with understanding)` + : '' + }`, + )}`; + }) + .filter(isPresent); + +const nameMapToCode = (list: ReadonlyArray) => + Array.from(new Set(list)) + .map( + (field) => + `${JSON.stringify(field)}: () => String(fbt.c(${JSON.stringify( + field, + )}))`, + ) + .join(',\n'); + +const descriptionMapToCode = ( + typeName: string, + list: ReadonlyArray, +) => + Array.from(new Set(list)) + .map( + ({ description, detail, hasNameSubstitution, id }) => + `${JSON.stringify(String(id))}: () => String(fbt(\`${ + hasNameSubstitution + ? replaceSubstitutions(description, id) + : description + }\`, ${JSON.stringify(detail ? `${typeName} - ${detail}` : typeName)}))`, + ) + .join(',\n'); + +const common = []; +const entities = []; + +for (const [typeName, names] of entityMap) { + common.push(...toCommon(typeName, names)); + entities.push( + `export const ${typeName}Map = {${nameMapToCode(names)}} as const;`, + ); +} + +for (const [typeName, descriptions] of descriptionMap) { + entities.push( + `export const ${typeName}Map = {${descriptionMapToCode( + typeName, + descriptions, + )}} as const;`, + ); +} + +const campaign = []; +const messages = new Set(); +for (const { description, key, message, unitId } of characterMessages) { + if (messages.has(key)) { + continue; + } + messages.add(key); + campaign.push( + `"${key}": () => String(fbt(\`${replaceSubstitutions( + message, + unitId, + )}\`, ${JSON.stringify(description)})),`, + ); +} + +const plugins = ['@ianvs/prettier-plugin-sort-imports']; +writeFileSync( + COMMON_OUTPUT_FILE, + await format(sign(`module.exports = {${common.join(',\n')}};`), { + filepath: COMMON_OUTPUT_FILE, + plugins, + singleQuote: true, + }), +); + +writeFileSync( + FBT_ENTITY_MAP_OUTPUT_FILE, + await format( + sign( + [ + `import { getUnitInfoOrThrow } from '@deities/athena/info/Unit.tsx';`, + `import { fbt } from 'fbt';`, + ...entities, + ].join('\n'), + ), + { + filepath: FBT_ENTITY_MAP_OUTPUT_FILE, + plugins, + singleQuote: true, + }, + ), +); + +writeFileSync( + FBT_CAMPAIGN_MAP_OUTPUT_FILE, + await format( + sign( + [ + `import { getUnitInfoOrThrow } from '@deities/athena/info/Unit.tsx';`, + `import { fbt } from 'fbt';`, + `export default {`, + campaign.join('\n'), + `}`, + ].join('\n'), + ), + { + filepath: FBT_CAMPAIGN_MAP_OUTPUT_FILE, + plugins, + singleQuote: true, + }, + ), +); + +console.log(chalk.bold.green('✓ Done generating translations.')); diff --git a/codegen/lib/sign.tsx b/codegen/lib/sign.tsx new file mode 100644 index 00000000..cd813919 --- /dev/null +++ b/codegen/lib/sign.tsx @@ -0,0 +1,7 @@ +import { createHash } from 'node:crypto'; + +export default function sign(code: string) { + return `/* @generated(${createHash('sha256') + .update(code) + .digest('hex')}) */\n${code}`; +} diff --git a/codegen/lib/traverse.tsx b/codegen/lib/traverse.tsx new file mode 100644 index 00000000..759d50da --- /dev/null +++ b/codegen/lib/traverse.tsx @@ -0,0 +1,17 @@ +import traverse, { + Node, + NodePath, + Scope, + TraverseOptions, +} from '@babel/traverse'; + +type TraverseFn = ( + parent: Node, + opts?: TraverseOptions, + scope?: Scope, + state?: unknown, + parentPath?: NodePath, +) => void; + +export default ((traverse as unknown as { default: typeof traverse }).default || + traverse) as unknown as TraverseFn; diff --git a/codegen/package.json b/codegen/package.json new file mode 100644 index 00000000..35f88ac8 --- /dev/null +++ b/codegen/package.json @@ -0,0 +1,25 @@ +{ + "name": "@deities/codegen", + "version": "0.0.1", + "private": true, + "description": "Athena Crisis codegen.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "devDependencies": { + "@babel/parser": "^7.24.5", + "@babel/traverse": "^7.24.5", + "@babel/types": "^7.24.5", + "@deities/apollo": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@deities/hermes": "workspace:*", + "@types/babel__traverse": "^7.20.5", + "chalk": "^5.3.0", + "glob": "10.3.14", + "prettier": "4.0.0-alpha.8" + } +} diff --git a/deimos/package.json b/deimos/package.json new file mode 100644 index 00000000..3ddf0ce7 --- /dev/null +++ b/deimos/package.json @@ -0,0 +1,32 @@ +{ + "name": "@deities/deimos", + "version": "0.0.1", + "private": true, + "description": "Deimos is the personification of fear. He is the son of Ares and Aphrodite. Deimos served to represent the feelings of dread and terror that befell those before a battle.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@deities/apollo": "workspace:*", + "@deities/art": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hera": "workspace:*", + "@deities/hermes": "workspace:*", + "@deities/ui": "workspace:*", + "@emotion/css": "^11.11.2", + "array-shuffle": "^3.0.0", + "fbt": "^1.0.2", + "framer-motion": "^11.1.9", + "react": "19.0.0-canary-fd0da3eef-20240404", + "react-dom": "19.0.0-canary-fd0da3eef-20240404", + "react-error-boundary": "^4.0.13" + }, + "devDependencies": { + "@emotion/babel-plugin": "^11.11.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0" + } +} diff --git a/dionysus/BaseAI.tsx b/dionysus/BaseAI.tsx new file mode 100644 index 00000000..d2ec98e5 --- /dev/null +++ b/dionysus/BaseAI.tsx @@ -0,0 +1,124 @@ +import { EndTurnAction } from '@deities/apollo/action-mutators/ActionMutators.tsx'; +import { Action, execute, MoveAction } from '@deities/apollo/Action.tsx'; +import { ActionResponse } from '@deities/apollo/ActionResponse.tsx'; +import { Effects } from '@deities/apollo/Effects.tsx'; +import applyConditions from '@deities/apollo/lib/applyConditions.tsx'; +import gameHasEnded from '@deities/apollo/lib/gameHasEnded.tsx'; +import { GameState } from '@deities/apollo/Types.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; + +class AIInterruptException { + public readonly name = 'AIInterruptException'; +} + +function isAIInterruptException( + exception: unknown, +): exception is AIInterruptException { + const error = exception as { name?: string }; + return 'name' in error && error.name === 'AIInterruptException'; +} + +export default abstract class BaseAI { + protected vision: VisionT | null = null; + private gameState: GameState = []; + + public constructor(private effects: Effects) {} + + protected getVision(map: MapData) { + return ( + this.vision || (this.vision = map.createVisionObject(map.currentPlayer)) + ); + } + + protected applyVision(map: MapData): MapData { + return this.getVision(map).apply(map); + } + + protected execute(map: MapData, action: Action): MapData | null { + const response = execute(map, this.getVision(map), action); + if (response) { + this.appendActionResponse(response[0], map, response[1]); + return this.gameState.at(-1)?.[1] || null; + } + return null; + } + + protected appendActionResponse( + actionResponse: ActionResponse, + previousMap: MapData, + currentMap: MapData, + ): void { + this.gameState = this.gameState.concat([[actionResponse, currentMap]]); + + const [gameState, effects] = applyConditions( + previousMap, + currentMap, + this.effects, + actionResponse, + ); + this.effects = effects; + if (gameState?.length) { + this.gameState = this.gameState.concat(gameState); + if ( + gameHasEnded(gameState) || + gameState.some(([actionResponse]) => actionResponse.type === 'EndTurn') + ) { + throw new AIInterruptException(); + } + } + } + + protected executeMove( + map: MapData, + action: MoveAction, + ): [MapData | null, boolean] { + const currentMap = this.execute(map, action); + const state = this.gameState.at(-1); + + if (currentMap && state) { + const [actionResponse, activeMap] = state; + if ( + action.type === 'Move' && + actionResponse.type === 'Move' && + !action.to.equals(actionResponse.to) + ) { + return [activeMap, true]; + } + } + + return [currentMap, false]; + } + + public retrieveGameState(): GameState { + const gameState = this.gameState; + this.gameState = []; + return gameState; + } + + public retrieveEffects(): Effects { + return this.effects; + } + + public act(map: MapData): MapData | null { + try { + return this.action(map); + } catch (error) { + if (isAIInterruptException(error)) { + return null; + } + throw error; + } + } + + protected endTurn(map: MapData): MapData | null { + const currentMap = this.execute(map, EndTurnAction()); + if (!currentMap) { + throw new Error('Error executing end turn action.'); + } + // Return `null` to indicate that the turn ended. + return null; + } + + protected abstract action(map: MapData): MapData | null; +} diff --git a/dionysus/DionysusAlpha.tsx b/dionysus/DionysusAlpha.tsx new file mode 100644 index 00000000..417fb3fb --- /dev/null +++ b/dionysus/DionysusAlpha.tsx @@ -0,0 +1,1272 @@ +import { + ActivatePowerAction, + AttackBuildingAction, + AttackUnitAction, + BuySkillAction, + CaptureAction, + CompleteBuildingAction, + CompleteUnitAction, + CreateBuildingAction, + CreateUnitAction, + DropUnitAction, + FoldAction, + MoveAction, + RescueAction, + SabotageAction, + SupplyAction, + ToggleLightningAction, + UnfoldAction, +} from '@deities/apollo/action-mutators/ActionMutators.tsx'; +import { Behavior, filterBuildings } from '@deities/athena/info/Building.tsx'; +import { getSkillConfig, Skill } from '@deities/athena/info/Skill.tsx'; +import { Lightning } from '@deities/athena/info/Tile.tsx'; +import { Ability, UnitInfo } from '@deities/athena/info/Unit.tsx'; +import calculateClusters from '@deities/athena/lib/calculateClusters.tsx'; +import calculateFunds from '@deities/athena/lib/calculateFunds.tsx'; +import canBuild from '@deities/athena/lib/canBuild.tsx'; +import canDeploy from '@deities/athena/lib/canDeploy.tsx'; +import determineUnitsToCreate from '@deities/athena/lib/determineUnitsToCreate.tsx'; +import getDeployableVectors from '@deities/athena/lib/getDeployableVectors.tsx'; +import getRescuableVectors from '@deities/athena/lib/getRescuableVectors.tsx'; +import getUnitsToRefill from '@deities/athena/lib/getUnitsToRefill.tsx'; +import { AIBehavior } from '@deities/athena/map/AIBehavior.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { Charge } from '@deities/athena/map/Configuration.tsx'; +import { + EntityType, + getEntityGroup, + getEntityInfoGroup, +} from '@deities/athena/map/Entity.tsx'; +import Player, { PlayerID } from '@deities/athena/map/Player.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { moveable } from '@deities/athena/Radius.tsx'; +import { getOpponentPriorityLabels } from '@deities/athena/WinConditions.tsx'; +import groupBy from '@deities/hephaestus/groupBy.tsx'; +import maxBy from '@deities/hephaestus/maxBy.tsx'; +import minBy from '@deities/hephaestus/minBy.tsx'; +import randomEntry from '@deities/hephaestus/randomEntry.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import BaseAI from './BaseAI.tsx'; +import estimateClosestTarget from './lib/estimateClosestTarget.tsx'; +import findPathToTarget from './lib/findPathToTarget.tsx'; +import getAttackableUnitsWithinRadius from './lib/getAttackableUnitsWithinRadius.tsx'; +import getBuildingWeight from './lib/getBuildingWeight.tsx'; +import getInterestingVectors from './lib/getInterestingVectors.tsx'; +import getInterestingVectorsByAbilities from './lib/getInterestingVectorsByAbilities.tsx'; +import getPossibleAttacks from './lib/getPossibleAttacks.tsx'; +import getPossibleUnitAbilities, { + getPossibleUnitAbilitiesForBuildings, +} from './lib/getPossibleUnitAbilities.tsx'; +import getUnitInfosWithMaxVision from './lib/getUnitInfosWithMaxVision.tsx'; +import needsSupply from './lib/needsSupply.tsx'; +import shouldAttack from './lib/shouldAttack.tsx'; +import shouldCaptureBuilding from './lib/shouldCaptureBuilding.tsx'; +import sortByDamage from './lib/sortByDamage.tsx'; +import sortPossibleAttacks from './lib/sortPossibleAttacks.tsx'; + +type CreateUnitCombination = { + from: Vector; + to: Vector; + unitInfo: UnitInfo; + weight: number; +}; + +// DionysusAlpha is the first AI, and it is the most aggressive one. +// At each turn, it checks which unit can do the most damage to another unit and then attacks. +export default class DionysusAlpha extends BaseAI { + protected action(map: MapData): MapData | null { + return ( + this.activatePower(map) || + this.finishCapture(map) || + this.finishRescue(map) || + this.toggleLightning(map) || + this.rescue(map) || + this.attack(map) || + this.capture(map) || + this.fold(map) || + this.createBuilding(map) || + this.move(map) || + this.unfold(map) || + this.buySkills(map) || + this.createUnit(map) || + this.endTurn(map) + ); + } + + private attacksDone = false; + private tryAttacking() { + // Unset the `attacksDone` state to attempt an attack in the next loop. + this.attacksDone = false; + } + + private activatePower(map: MapData): MapData | null { + const { activeSkills, charge, skills } = map.getCurrentPlayer(); + + if (!skills.size || charge < Charge) { + return null; + } + + const potentialSkills = []; + for (const skill of skills) { + if (activeSkills.has(skill)) { + continue; + } + + const { charges } = getSkillConfig(skill); + if (charges && charges * Charge <= charge) { + potentialSkills.push([skill, charges]); + } + } + + if (potentialSkills.length) { + const [skill] = randomEntry(potentialSkills); + const currentMap = this.execute(map, ActivatePowerAction(skill)); + if (currentMap) { + this.tryAttacking(); + } + return currentMap; + } + + return null; + } + + private attack(map: MapData): MapData | null { + if (this.attacksDone) { + return null; + } + + const currentPlayer = map.getCurrentPlayer(); + const vision = this.getVision(map); + const labelsToPrioritize = getOpponentPriorityLabels( + map.config.winConditions, + currentPlayer.id, + ); + let possibleAttacks = getPossibleAttacks( + map, + vision, + map.units + .filter( + (unit) => + !unit.isCompleted() && + unit.info.hasAttack() && + !unit.isCapturing() && + map.matchesPlayer(currentPlayer, unit), + ) + .toArray(), + labelsToPrioritize, + ).sort(sortPossibleAttacks); + + if (!possibleAttacks.length) { + this.attacksDone = true; + return null; + } + + let currentMap: MapData | null = map; + while (possibleAttacks.length) { + const attackOption = possibleAttacks.pop(); + if (!currentMap || !attackOption) { + return null; + } + + let hasMoved = false; + let { from } = attackOption; + const { entityB, parent, sabotage, to, unitA } = attackOption; + if ( + (unitA.info.isShortRange() || + unitA.info.canAct(map.getPlayer(unitA))) && + from.distance(parent) >= 1 + ) { + let isBlocked; + [currentMap, isBlocked] = this.executeMove( + currentMap, + MoveAction(from, parent), + ); + if (isBlocked) { + return currentMap; + } + + if (!currentMap) { + throw new Error('Error executing unit move.'); + } + from = parent; + hasMoved = true; + } + + // If the target becomes hidden after a move, skip the current attack and attempt to target another unit. + // This might happen when X-Fighters move away from a target hidden in a forest. + if (!vision.isVisible(currentMap, to.vector)) { + return currentMap; + } else { + currentMap = this.execute( + currentMap, + sabotage + ? SabotageAction(from, to.vector) + : entityB.info.type === EntityType.Building || + entityB.info.type === EntityType.Structure + ? AttackBuildingAction(from, to.vector) + : AttackUnitAction(from, to.vector), + ); + } + + if (!currentMap) { + throw new Error( + 'Error executing unit attack. ' + + JSON.stringify({ entityB, from, to: to.vector, unitA }, null, 2), + ); + } + + const dirtyUnits = new Set(); + possibleAttacks = possibleAttacks.filter((item) => { + if (item.from.equals(attackOption.from)) { + return false; + } + + if ( + // Consider original position via `attackOption.from`. + (hasMoved && item.attackable.has(attackOption.from)) || + item.attackable.has(from) || + item.attackable.has(to.vector) + ) { + dirtyUnits.add(item.from); + return false; + } + return true; + }); + + if (dirtyUnits.size) { + possibleAttacks = [ + ...possibleAttacks, + ...getPossibleAttacks( + currentMap, + vision, + [...dirtyUnits].map((vector) => [vector, map.units.get(vector)!]), + labelsToPrioritize, + ), + ].sort(sortPossibleAttacks); + } + } + return currentMap; + } + + private finishCapture(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + const [from, unit] = + map.units.findEntry( + (unit) => + unit.isCapturing() && + !unit.isCompleted() && + map.matchesPlayer(currentPlayer, unit), + ) || []; + + return from && unit ? this.execute(map, CaptureAction(from)) : null; + } + + private capture(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + const units = map.units.filter( + (unit) => + !unit.isCompleted() && + !unit.isCapturing() && + map.matchesPlayer(currentPlayer, unit) && + unit.info.hasAbility(Ability.Capture), + ); + + if (!units.size) { + return null; + } + + // Find a unit that can capture a building based on weight. + for (const [from, unit] of units) { + const { to } = + (shouldMove(unit) && + maxBy( + filterMap( + moveable( + this.applyVision(map), + unit, + from, + undefined, + undefined, + true, + ), + ({ cost, vector }) => { + const building = map.buildings.get(vector); + const info = building?.info; + return info && + map.isOpponent(unit, building) && + (vector.equals(from) || !map.units.has(vector)) + ? { + to: vector, + weight: + getBuildingWeight(info) - + cost + + (map.isNeutral(building) ? 0 : 5), + } + : null; + }, + ), + (item) => item?.weight || Number.NEGATIVE_INFINITY, + )) || + {}; + + if (!to || to.equals(from)) { + const building = map.buildings.get(from); + if (building && map.isOpponent(unit, building)) { + return this.execute(map, CaptureAction(from)); + } + + continue; + } + + const [currentMap, isBlocked] = this.executeMove( + map, + MoveAction(from, to), + ); + if (isBlocked) { + return currentMap; + } + if (!currentMap) { + throw new Error('Error executing unit move.'); + } + return this.execute(currentMap, CaptureAction(to)); + } + + return null; + } + + private finishRescue(map: MapData): MapData | null { + if (!map.hasNeutralUnits()) { + return null; + } + + const currentPlayer = map.getCurrentPlayer(); + const [from, unit] = + map.units.findEntry( + (unit) => + !unit.isCompleted() && + unit.info.hasAbility(Ability.Rescue) && + map.matchesPlayer(currentPlayer, unit), + ) || []; + + if (from && unit) { + const rescuable = getRescuableVectors(map, from); + const to = [...rescuable].find( + (vector) => map.units.get(vector)?.isBeingRescuedBy(unit.player), + ); + if (to) { + this.tryAttacking(); + return this.execute(map, RescueAction(from, to)); + } + } + + return null; + } + + private rescue(map: MapData): MapData | null { + if (!map.hasNeutralUnits()) { + return null; + } + + const currentPlayer = map.getCurrentPlayer(); + const entry = map.units.findEntry( + (unit) => + !unit.isCompleted() && + unit.info.hasAbility(Ability.Rescue) && + map.matchesPlayer(currentPlayer, unit), + ); + + if (!entry) { + return null; + } + + const [from, unit] = entry; + const { parent, to } = + (shouldMove(unit) && + maxBy( + [ + ...moveable( + this.applyVision(map), + unit, + from, + undefined, + undefined, + true, + ), + ].flatMap(([, { cost, vector }]) => { + const vectors: Array< + Readonly<{ parent: Vector; to: Vector; weight: number }> + > = []; + if (vector.equals(from) || !map.units.has(vector)) { + for (const adjacent of vector.adjacent()) { + const unit = map.units.get(adjacent); + if (unit?.player === 0) { + const { info } = unit; + vectors.push({ + parent: vector, + to: adjacent, + weight: + Math.max( + info.defense + + [...(info.attack?.weapons || [])] + .flatMap(([, weapon]) => weapon.damage.values()) + .reduce((sum, [, damage]) => sum + damage, 0) - + cost, + 0, + ) * + (unit.isBeingRescuedBy(currentPlayer.id) + ? 100 + : unit.isBeingRescued() && + map.isOpponent(currentPlayer, unit.getRescuer()!) + ? 10 + : 1), + }); + } + } + } + return vectors; + }), + (item) => item?.weight || Number.NEGATIVE_INFINITY, + )) || + {}; + + if (!to || !parent) { + return null; + } + + if (parent.equals(from)) { + if (map.units.get(to)?.isBeingRescuedBy(currentPlayer.id)) { + this.tryAttacking(); + } + return this.execute(map, RescueAction(from, to)); + } + + const [currentMap, isBlocked] = this.executeMove( + map, + MoveAction(from, parent), + ); + if (isBlocked) { + return currentMap; + } + if (!currentMap) { + throw new Error('Error executing unit move.'); + } + + if (map.units.get(to)?.isBeingRescuedBy(currentPlayer.id)) { + this.tryAttacking(); + } + return this.execute(currentMap, RescueAction(parent, to)); + } + + private createBuilding(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + const [from, unit] = + map.units.findEntry( + (unit) => + !unit.isCompleted() && + unit.info.hasAbility(Ability.CreateBuildings) && + map.matchesPlayer(currentPlayer, unit), + ) || []; + + if (!from || !unit) { + return null; + } + + let _funds: number | null = null; + const getFunds = () => + _funds == null ? (_funds = calculateFunds(map, currentPlayer)) : _funds; + + // Find a building to construct based on importance. + const { info, to } = + maxBy( + filterMap( + moveable( + this.applyVision(map), + unit, + from, + undefined, + undefined, + true, + ), + (item) => { + const currentUnit = map.units.get(item.vector); + if ( + map.buildings.has(item.vector) || + (currentUnit && !from.equals(item.vector)) + ) { + return null; + } + const buildingInfos = filterBuildings( + (info) => + info.configuration.cost <= currentPlayer.funds && + canBuild(map, info, unit.player, item.vector) && + (getFunds() > 0 || info.configuration.funds > 0), + ); + if (!buildingInfos.length) { + return null; + } + + // Determine which buildings to build. This algorithm usually + // prioritizes buildings that generate funds over production + // by keeping a 3:1 ratio between fund generating buildings and + // production buildings. Note that production facilities that generate + // funds are considered as resource buildings in this algorithm. + // + // Depending on the map configuration, the algorithm might choose + // to build map-specific buildings like Radar Stations. + const userBuildings = map.buildings.filter((building) => + map.matchesPlayer(building, currentPlayer), + ); + + const buildingPartition = groupBy(userBuildings, ([, building]) => { + const canBuildUnits = building.canBuildUnits(currentPlayer); + return building.info.configuration.funds > 0 && !canBuildUnits + ? 'funds' + : canBuildUnits + ? 'production' + : null; + }); + + const productionBuildings = + buildingPartition.get('production') || []; + const fundBuildings = buildingPartition.get('funds') || []; + + const shouldBuildFundsBuilding = + productionBuildings.length && + fundBuildings.length / 3 < productionBuildings.length; + + const radarBuilding = buildingInfos.find((info) => + info.hasBehavior(Behavior.Radar), + ); + const shouldBuildRadar = + radarBuilding && + !userBuildings.some((building) => + building.info.hasBehavior(Behavior.Radar), + ) && + map.reduceEachField( + (hasLightning, vector) => + map.getTileInfo(vector) === Lightning ? true : hasLightning, + false, + ); + const info = shouldBuildRadar + ? radarBuilding + : randomEntry( + buildingInfos.filter((info) => + shouldBuildFundsBuilding + ? info.configuration.funds > 0 + : info.configuration.funds === 0 && + !info.getAllBuildableUnits()[Symbol.iterator]().next() + .done, + ), + ) || buildingInfos[0]; + + if (info) { + return { + info, + to: item.vector, + weight: + -item.vector.distance(from) * + (userBuildings.some((building) => building.info === info) + ? 0.5 + : 1), + }; + } + return null; + }, + ), + (item) => item?.weight || 0, + ) || {}; + + if (!to || !info) { + return null; + } + let currentMap: MapData | null = map; + if (!from.equals(to) && from.distance(to) !== 0) { + if (!shouldMove(unit)) { + return null; + } + + let isBlocked; + [currentMap, isBlocked] = this.executeMove( + currentMap, + MoveAction(from, to), + ); + if (isBlocked) { + return currentMap; + } + + if (!currentMap) { + throw new Error('Error executing unit move.'); + } + } + + return this.execute(currentMap, CreateBuildingAction(to, info.id)); + } + + private buySkills(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + if (currentPlayer.funds <= 0) { + return null; + } + + const [from, building] = + map.buildings.findEntry( + (building) => + !!( + !building.isCompleted() && + map.matchesPlayer(currentPlayer, building) && + building.skills?.size + ), + ) || []; + + if (!from || !building || !building.skills?.size) { + return null; + } + + const buyableSkills = [...building.skills].filter((skill) => { + const { cost } = getSkillConfig(skill); + return ( + cost != null && + cost > 0 && + cost <= currentPlayer.funds && + !currentPlayer.skills.has(skill) + ); + }); + + if (buyableSkills.length) { + const skill = randomEntry(buyableSkills); + return this.execute(map, BuySkillAction(from, skill)); + } + + return null; + } + + private createUnit(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + if (currentPlayer.funds <= 0) { + return null; + } + + const buildings = map.buildings.filter((building, vector) => { + if ( + building.isCompleted() || + !map.matchesPlayer(currentPlayer, building) || + !building.canBuildUnits(currentPlayer) + ) { + return false; + } + + const unit = map.units.get(vector); + return !unit || map.matchesTeam(unit, building); + }); + + if (!buildings.size) { + return null; + } + + const buildCapabilities = getPossibleUnitAbilitiesForBuildings( + [...buildings.values()], + currentPlayer, + ); + + const playerUnits = [ + ...map.units + .filter((unit) => map.matchesPlayer(unit, currentPlayer)) + .values(), + ]; + + const avoidNavalUnits = + map.round >= 5 && !shouldBuildNavalUnits(map, currentPlayer, playerUnits); + + const getBestUnit = (unitInfos: ReadonlyArray): UnitInfo => + unitInfos.length > 1 && + hasTooManyOfType(playerUnits, unitInfos[0]) && + !hasTooManyOfType(playerUnits, unitInfos[1]) + ? unitInfos[1] + : unitInfos[0]; + + const clusterMap = new Map>(); + const getClusters = ( + building: Building, + unitInfos: ReadonlyArray, + ) => { + if (!clusterMap.has(building.label)) { + const clusters = calculateClusters( + map.size, + getInterestingVectorsByAbilities( + map, + currentPlayer, + building.label, + getPossibleUnitAbilities(unitInfos), + ), + 4, + ); + clusterMap.set(building.label, clusters); + return clusters; + } + return clusterMap.get(building.label)!; + }; + + const unitInfoMap = new Map>(); + const getUnitInfos = (building: Building, vector: Vector) => { + if (!unitInfoMap.has(building.id)) { + const unitInfos = determineUnitsToCreate( + map, + currentPlayer, + playerUnits, + [...building.getBuildableUnits(currentPlayer)].filter( + (info) => + info.getCostFor(currentPlayer) <= + currentPlayer.funds / + (map.round > 3 && !((map.round + 3) % 6) + ? Math.min(buildings.size - 1, 3) + : 1) && + getDeployableVectors(map, info, vector, currentPlayer.id).length, + ), + buildCapabilities, + ); + unitInfoMap.set(building.id, unitInfos); + return unitInfos; + } + + return unitInfoMap.get(building.id)!; + }; + + const combinations: Array = []; + for (const [from, building] of buildings) { + const unitInfos = getUnitInfos(building, from); + if ( + !unitInfos.length || + // If the player only generates enough funds to build the same unit each turn, + // and there are already enough of the same unit on the map, don't build any more. + (unitInfos.length === 1 && + unitInfos[0].getCostFor(currentPlayer) === + calculateFunds(map, currentPlayer) && + playerUnits.filter((unit) => unit.id === unitInfos[0].id).length >= + Math.ceil((map.size.width * map.size.height) / 100)) + ) { + continue; + } + + const clusters = getClusters(building, unitInfos); + if (!clusters.length) { + const unitInfo = minBy(unitInfos, (info) => + info.getCostFor(currentPlayer), + ); + if (!unitInfo) { + continue; + } + + const newCluster = map.buildings + .filter((buildingB, vector) => + shouldCaptureBuilding(map, currentPlayer.id, buildingB, vector), + ) + .keySeq() + .minBy((vector) => vector.distance(from)); + const to = + newCluster && + minBy( + getDeployableVectors(map, unitInfo, from, currentPlayer.id), + (vector) => vector.distance(newCluster), + ); + if (to) { + const [item] = estimateClosestTarget( + map, + unitInfo.create(currentPlayer), + newCluster, + to, + true, + ); + combinations.push({ + from, + to, + unitInfo, + weight: item?.cost || 0, + }); + } + continue; + } + + const cluster = + minBy(clusters, (cluster) => from.distance(cluster)) || clusters[0]; + const unitInfo = getBestUnit( + map.config.fog && + (map.round === 3 || (map.round > 4 && !(map.round % 4))) + ? getUnitInfosWithMaxVision(unitInfos) + : sortByDamage( + map, + getAttackableUnitsWithinRadius( + map, + cluster, + // Look at a radius roughly correlated with map size. + Math.max(3, Math.ceil((map.size.width + map.size.height) / 6)), + ), + unitInfos, + currentPlayer, + ), + ); + const to = minBy( + getDeployableVectors(map, unitInfo, from, currentPlayer.id), + (vector) => vector.distance(cluster), + ); + if (to) { + const [item, , isObstructed] = estimateClosestTarget( + map, + unitInfo.create(currentPlayer), + to, + cluster, + true, + ); + // Only build units without attacks if they can reach their target + // without obstruction. + if ( + unitInfo.hasAttack() || + map.round <= 4 || + !isObstructed || + (isNaval(unitInfo) && + (unitInfo.canTransportUnits() || + unitInfo.hasAbility(Ability.Supply))) + ) { + combinations.push({ + from, + to, + unitInfo, + weight: + (item?.cost || 0) + + (avoidNavalUnits && isNaval(unitInfo) ? 10 : 0), + }); + } + } + } + + const bestCombination = sortBy(combinations, ({ weight }) => weight)[0]; + if (bestCombination) { + const { from, to, unitInfo } = bestCombination; + + if (map.config.fog) { + this.tryAttacking(); + } + + return this.execute(map, CreateUnitAction(from, unitInfo.id, to)); + } + + return null; + } + + private fold(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + + const [from, unit] = + map.units.findEntry( + (unit) => + !unit.isCompleted() && + unit.isUnfolded() && + map.matchesPlayer(currentPlayer, unit) && + !unit.matchesBehavior(AIBehavior.Stay), + ) || []; + + if (!from || !unit) { + return null; + } + + const maxRange = unit.info.getRangeFor(currentPlayer)?.[1] || 0; + const closestCluster = minBy( + calculateClusters( + map.size, + Array.from( + new Set([ + ...map.units + .filter((unit) => map.isOpponent(unit, currentPlayer)) + .keys(), + ...map.buildings + .filter((building) => map.isOpponent(building, currentPlayer)) + .keys(), + ]), + ), + ), + (cluster) => from.distance(cluster), + ); + if (!closestCluster || from.distance(closestCluster) > maxRange * 1.5) { + return this.execute(map, FoldAction(from)); + } + + return null; + } + + private unfold(map: MapData): MapData | null { + const from = map.units.findKey(canUnfold.bind(null, map)); + return from ? this.unfoldUnit(map, from) : null; + } + + private unfoldUnit(map: MapData, from: Vector) { + const unit = map.units.get(from); + if (unit && canUnfold(map, unit)) { + const currentPlayer = map.getCurrentPlayer(); + const vision = this.getVision(map); + const unfoldedUnit = unit.unfold(); + const unfoldedMap = map.copy({ + units: map.units.set(from, unfoldedUnit), + }); + const attackableUnits = getAttackableUnitsWithinRadius( + map, + from, + (unit.info.getRangeFor(currentPlayer)?.[1] || + Math.max(2, Math.ceil(map.size.width + map.size.height) / 6)) + 2, + ); + const target = attackableUnits.filter(([vector]) => + shouldAttack(unfoldedMap, vision, unfoldedUnit, from, vector), + )?.[0]?.[0]; + + if ( + target && + unit.info.canAttackAt( + from.distance(target), + unit.info.getRangeFor(currentPlayer), + ) + ) { + const currentMap = this.execute(map, UnfoldAction(from)); + if (!currentMap) { + throw new Error('Error executing unit unfold.'); + } + // Unfolding may increase visibility of the unit. + this.tryAttacking(); + return currentMap; + } + } + return null; + } + + private move(map: MapData): MapData | null { + const currentPlayer = map.getCurrentPlayer(); + const units = map.units.filter( + (unit) => + shouldMove(unit) && + !unit.isCapturing() && + map.matchesPlayer(currentPlayer, unit), + ); + + const supplyEntry = units.findEntry((unit) => + unit.info.abilities.has(Ability.Supply), + ); + const [from, unit] = + // Prioritize units with supply ability. + (supplyEntry && + map.units.filter( + (unit) => map.matchesPlayer(currentPlayer, unit) && needsSupply(unit), + ).size && + supplyEntry) || + // In fog, move units with the highest vision first. + (map.config.fog && + maxBy(units.toArray(), ([, { info }]) => + !info.canTransportUnits() ? info.configuration.vision : 1, + )) || + // Prioritize any unit that cannot transport other units. + units.findEntry((unit) => !unit.info.canTransportUnits()) || + // Make sure units that aren't transporting units move out of the way if necessary. + units.findEntry((unit) => !unit.isTransportingUnits()) || + // Pick any remaining unit. + units.findEntry(() => true) || + []; + + if (!from || !unit) { + return null; + } + + const mapWithVision = this.applyVision(map); + const clusters = calculateClusters( + map.size, + getInterestingVectors(map, from, unit), + ); + + const [target, radiusToTarget, isObstructed, realTarget] = + estimateClosestTarget(mapWithVision, unit, from, clusters); + const moveableRadius = moveable(mapWithVision, unit, from); + let to = + target && + findPathToTarget( + mapWithVision, + unit, + target, + moveableRadius, + radiusToTarget, + true, + ); + let currentMap: MapData | null = map, + isBlocked; + + if ( + !to || + isObstructed || + clusters.every( + (vector) => + vector.distance(from!) > + unit.info.getRadiusFor(map.getPlayer(unit)) * 2.5, + ) + ) { + for (const [vector] of moveableRadius) { + const unitB = map.units.get(vector); + if ( + unitB && + !unitB.isFull() && + unitB.info.canTransport(unit.info, map.getTileInfo(vector)) && + map.matchesPlayer(unit, unitB) + ) { + to = vector; + } + } + } + + if (to) { + [currentMap, isBlocked] = this.executeMove( + currentMap, + MoveAction(from, to), + ); + if (isBlocked) { + // Wipe away attack cache as the radius might have changed. + this.tryAttacking(); + return currentMap; + } + } else { + const maybeMap = this.unfoldUnit(map, from); + if (maybeMap) { + return maybeMap; + } + + currentMap = this.maybeDropUnit(currentMap, from, realTarget); + if (!currentMap) { + return null; + } + + currentMap = this.execute(currentMap, CompleteUnitAction(from)); + if (!currentMap) { + throw new Error('Error executing unit completion.'); + } + return currentMap; + } + + if (!currentMap) { + throw new Error('Error executing unit move.'); + } + + // If there was previously a unit at this location it means that the acted + // upon unit was loaded into the existing unit. We can't keep actioning on the + // loaded unit so we return here. + const previousUnit = to && map.units.get(to); + if (previousUnit) { + return currentMap; + } + + return this.actionsAfterMove(currentMap, to, realTarget); + } + + private maybeDropUnit( + currentMap: MapData | null, + position: Vector, + target?: Vector | null, + ) { + if (!currentMap) { + return null; + } + + const unit = currentMap.units.get(position); + if ( + unit && + target && + unit.isTransportingUnits() && + unit.info.canDropFrom(currentMap.getTileInfo(position)) + ) { + const player = currentMap.getCurrentPlayer(); + const isNaval = isNavalUnit(unit); + for (let index = unit.transports.length - 1; index >= 0; index--) { + const transportedUnit = unit.transports[index]; + const multiplier = isNaval ? 2.5 : 1.5; + if ( + target.distance(position) > + transportedUnit.info.getRadiusFor( + currentMap.getPlayer(transportedUnit), + ) * + multiplier + ) { + continue; + } + + const dropTo = minBy( + position + .adjacent() + .filter((vector) => + canDeploy( + currentMap!, + transportedUnit.info, + vector, + player.skills.has(Skill.NoUnitRestrictions), + ), + ), + (vector) => vector.distance(target), + ); + + if (dropTo) { + currentMap = this.execute( + currentMap, + DropUnitAction(position, index, dropTo), + ); + if (!currentMap) { + throw new Error('Error executing unit drop.'); + } + } + } + } + + return currentMap; + } + + private actionsAfterMove( + currentMap: MapData | null, + position: Vector, + target?: Vector | null, + ) { + if (!currentMap) { + return null; + } + + if (currentMap.config.fog) { + this.tryAttacking(); + } + + let unit = position && currentMap.units.get(position); + if (!unit || unit.isCompleted()) { + return currentMap; + } + + if (unit.info.isLongRange()) { + this.tryAttacking(); + return currentMap; + } + + currentMap = this.maybeDropUnit(currentMap, position, target); + if (!currentMap) { + return null; + } + + // While not currently possible, a unit may be able to transport and/or supply all in the same turn. + // Here we retrieve the unit again after each action to ensure its state is fresh. + unit = currentMap.units.get(position); + if ( + unit && + unit.info.hasAbility(Ability.Supply) && + getUnitsToRefill( + currentMap, + this.getVision(currentMap), + currentMap.getPlayer(unit), + position, + ).size > 0 + ) { + this.tryAttacking(); + + currentMap = this.execute(currentMap, SupplyAction(position)); + if (!currentMap) { + throw new Error('Error executing unit supply.'); + } + } + + unit = currentMap.units.get(position); + if ( + unit && + !unit.isCompleted() && + unit.info.hasAbility(Ability.Capture) && + !unit.info.hasAttack() + ) { + // Mark units with the capture ability as completed so that `beginCapture` + // will not attempt to process the same unit on every action. + currentMap = this.execute(currentMap, CompleteUnitAction(position)); + if (!currentMap) { + throw new Error('Error executing unit complete.'); + } + } + + return currentMap; + } + + private toggleLightning(map: MapData) { + const currentPlayer = map.getCurrentPlayer(); + const buildings = map.buildings.filter( + (building) => + !building.isCompleted() && + building.info.hasBehavior(Behavior.Radar) && + map.matchesPlayer(currentPlayer, building), + ); + + const from = buildings.keys().next().value as Vector; + if (!from) { + return null; + } + + const fields = map.reduceEachField>((fields, vector) => { + return map.getTileInfo(vector) === Lightning + ? [...fields, vector] + : fields; + }, []); + + if (!fields.length) { + return this.execute(map, CompleteBuildingAction(from)); + } + + return this.execute(map, ToggleLightningAction(from, fields[0])); + } +} + +const canUnfold = (map: MapData, unit: Unit) => + unit.info.hasAbility(Ability.Unfold) && + !unit.isCompleted() && + !unit.isUnfolded() && + map.matchesPlayer(map.getCurrentPlayer(), unit); + +const shouldMove = (unit: Unit) => + unit.canMove() && !unit.matchesBehavior(AIBehavior.Stay); + +const filterMap = ( + initialMap: ReadonlyMap, + fn: (item: V, key: K) => T | null, +): ReadonlyArray => { + const array = []; + for (const [key, value] of initialMap) { + const result = fn(value, key); + if (result) { + array.push(result); + } + } + return array; +}; + +const isNavalUnit = (unit: Unit) => getEntityGroup(unit) === 'naval'; +const isNaval = (entity: Readonly<{ type: EntityType }>) => + getEntityInfoGroup(entity) === 'naval'; + +const shouldBuildNavalUnits = ( + map: MapData, + currentPlayer: Player, + playerUnits: ReadonlyArray, +) => { + if (!playerUnits.length) { + return true; + } + + const playerNavalUnits = playerUnits.filter(isNavalUnit).length; + const playerNavalRatio = playerNavalUnits / playerUnits.length; + if (playerNavalRatio < 0.3) { + return true; + } + + const opposingUnits = map.units.filter((unit) => + map.isOpponent(unit, currentPlayer), + ); + const opposingNavalUnits = opposingUnits.filter(isNavalUnit).size; + return ( + !opposingUnits.size || + opposingNavalUnits / opposingUnits.size > playerNavalRatio - 0.15 + ); +}; + +const hasTooManyOfType = (units: ReadonlyArray, unitInfo: UnitInfo) => + units.length > 10 && + units.filter(({ id }) => id === unitInfo.id).length / units.length > 0.2; diff --git a/dionysus/lib/estimateClosestTarget.tsx b/dionysus/lib/estimateClosestTarget.tsx new file mode 100644 index 00000000..2ae19538 --- /dev/null +++ b/dionysus/lib/estimateClosestTarget.tsx @@ -0,0 +1,246 @@ +import { Lightning } from '@deities/athena/info/Tile.tsx'; +import { Ability } from '@deities/athena/info/Unit.tsx'; +import getVectorRadius from '@deities/athena/lib/getVectorRadius.tsx'; +import { getEntityGroup } from '@deities/athena/map/Entity.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector, { isVector } from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { + moveable, + MoveConfiguration, + RadiusItem, +} from '@deities/athena/Radius.tsx'; +import isPresent from '@deities/hephaestus/isPresent.tsx'; +import maxBy from '@deities/hephaestus/maxBy.tsx'; +import minBy from '@deities/hephaestus/minBy.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; +import getBuildingWeight from './getBuildingWeight.tsx'; +import shouldCaptureBuilding from './shouldCaptureBuilding.tsx'; + +const minByCost = ({ cost }: RadiusItem) => cost; + +const config = { + ...MoveConfiguration, + getResourceValue: () => Number.POSITIVE_INFINITY, +}; + +const getPrimaryTarget = ( + targets: ReadonlyArray, + radius: ReadonlyMap, + adjacent = true, +) => + minBy( + targets.map((vector) => radius.get(vector)).filter(isPresent), + minByCost, + ) || + // If the target tile is not accessible (mountain, sea, etc.), + // check the surrounding vectors which might be accessible. + (adjacent && + minBy( + targets + .flatMap((target) => + target.adjacent().map((vector) => radius.get(vector)), + ) + .filter(isPresent), + minByCost, + )) || + null; + +const maybeOptimizeTargets = ( + map: MapData, + unit: Unit, + radius: ReadonlyMap, + targets: ReadonlyArray, +) => { + if (targets.length <= 1) { + return targets; + } + + if (unit.info.hasAbility(Ability.Capture)) { + const sortedTargets = sortBy( + targets.map((vector) => radius.get(vector)).filter(isPresent), + minByCost, + ); + + if (sortedTargets.length <= 1) { + return targets; + } + + const lowestCost = sortedTargets[0].cost; + let bestWeight = Number.NEGATIVE_INFINITY; + let bestOption: Vector | null = null; + for (const currentTarget of sortedTargets) { + if (currentTarget.cost > lowestCost) { + break; + } + + const building = map.buildings.get(currentTarget.vector); + if ( + shouldCaptureBuilding(map, unit.player, building, currentTarget.vector) + ) { + const weight = + getBuildingWeight(building.info) - + currentTarget.cost + + (map.isNeutral(building) ? 0 : 5); + + if (weight > bestWeight) { + bestWeight = weight; + bestOption = currentTarget.vector; + } + } + } + + if (bestOption) { + return [bestOption]; + } + } + + return targets; +}; + +export default function estimateClosestTarget( + map: MapData, + unit: Unit, + from: Vector, + _targets: ReadonlyArray | Vector, + isPendingUnit = false, +): [ + RadiusItem | null, + ReadonlyMap, + isObstructed: boolean, + realTarget?: Vector | null, + isPendingUnit?: boolean, +] { + const targets = isVector(_targets) ? [_targets] : _targets; + let isObstructed = false; + let realTarget; + if (!targets.length) { + return [null, new Map(), isObstructed]; + } + + // Find the closest cluster in terms of travel cost, + // assuming that no other units are in the way. + const maxDistance = Math.min( + vec(1, 1).distance(vec(map.size.width, map.size.height)) * 2, + (maxBy(targets, (target) => target.distance(from)) || targets[0]).distance( + from, + ) * 4, + ); + let radius = moveable( + map, + unit, + from, + maxDistance, + isPendingUnit + ? { + ...config, + isAccessible(map: MapData, unit: Unit, vector: Vector) { + if (!map.contains(vector)) { + return false; + } + + const building = map.buildings.get(vector); + if (building && !building.info.isAccessibleBy(unit.info)) { + return false; + } + + return true; + }, + } + : config, + ); + + const navalIsTransportingUnits = + getEntityGroup(unit) === 'naval' && unit.isTransportingUnits(); + + const dropTargets = new Set(); + if (navalIsTransportingUnits) { + for (const target of targets) { + for (const vector of sortBy( + getVectorRadius(map, target, 4).filter( + (vector) => + unit.info.canDropFrom(map.getTileInfo(vector)) && + MoveConfiguration.isAccessible(map, unit, vector) && + !map.units.has(vector), + ), + (vector) => vector.distance(target), + ).slice(0, 2)) { + dropTargets.add(vector); + } + } + } + + let target = + (dropTargets.size && getPrimaryTarget([...dropTargets], radius, false)) || + getPrimaryTarget(maybeOptimizeTargets(map, unit, radius, targets), radius); + + if (!target || !radius.size) { + isObstructed = true; + if (isPendingUnit) { + return [null, new Map(), isObstructed]; + } + + // Try again without obstacles so we can at least get closer to the target. + radius = moveable(map, unit, from, maxDistance, { + ...config, + getCost: (map, unit, vector) => + map.getTile(vector, 1) === Lightning.id + ? map.getTileInfo(vector, 0).getMovementCost(unit.info) + : MoveConfiguration.getCost(map, unit, vector), + isAccessible: (map, _, vector) => { + if (!map.contains(vector)) { + return false; + } + + if (!navalIsTransportingUnits) { + return true; + } + + const building = map.buildings.get(vector); + return ( + !building || + building.info.isStructure() || + building.info.isAccessibleBy(unit.info) + ); + }, + }); + + target = getPrimaryTarget(targets, radius); + + if (!target && navalIsTransportingUnits) { + // The goal of the current unit is to find a way for units it is transporting to reach + // one of its targets. We'll estimate the closest deployable field by assuming the unit is + // at the target location and inversing its movement radius. + const transportedUnit = unit.transports[0].deploy(); + const inverseFrom = minBy(targets, (target) => target.distance(from)); + if (inverseFrom) { + const inverseRadius = moveable( + map, + transportedUnit, + inverseFrom, + maxDistance, + { + ...MoveConfiguration, + getResourceValue: () => Number.POSITIVE_INFINITY, + }, + ); + const inverseTargets = []; + for (const [vector] of inverseRadius) { + if ( + unit.info.canDropFrom(map.getTileInfo(vector)) && + MoveConfiguration.isAccessible(map, unit, vector) && + !map.units.has(vector) + ) { + inverseTargets.push(vector); + } + } + const inverseTarget = getPrimaryTarget(inverseTargets, radius, false); + target = (inverseTarget && radius.get(inverseTarget.vector)) || null; + realTarget = inverseFrom; + } + } + } + + return [target, radius, isObstructed, realTarget || target?.vector || null]; +} diff --git a/dionysus/lib/findPathToTarget.tsx b/dionysus/lib/findPathToTarget.tsx new file mode 100644 index 00000000..11783f7a --- /dev/null +++ b/dionysus/lib/findPathToTarget.tsx @@ -0,0 +1,105 @@ +import { AIBehavior } from '@deities/athena/map/AIBehavior.tsx'; +import { getEntityGroup } from '@deities/athena/map/Entity.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { MoveConfiguration, RadiusItem } from '@deities/athena/Radius.tsx'; +import minBy from '@deities/hephaestus/minBy.tsx'; +import getAttackableArea from './getAttackableArea.tsx'; +import getWinConditionVectors from './getWinConditionVectors.tsx'; + +export default function findPathToTarget( + map: MapData, + unit: Unit, + to: RadiusItem, + moveableRadius: ReadonlyMap, + radiusToTarget: ReadonlyMap, + considerRange?: boolean, +): Vector | null { + const { info } = unit; + let target: RadiusItem | null = to; + const minimumDistance = + (considerRange && + !getWinConditionVectors(map, unit).has(to.vector) && + info.getRangeFor(map.getPlayer(unit))?.[0]) || + 0; + + const isWithinOpponentAttackableArea = (vector: Vector) => { + if ( + unit.matchesBehavior(AIBehavior.Defense) || + unit.matchesBehavior(AIBehavior.Passive) + ) { + return getAttackableArea( + map, + new Set( + map + .getPlayers() + .filter((player) => map.isOpponent(player, unit.player)) + .map(({ id }) => id), + ), + ).has(vector); + } + + return false; + }; + + let radius: number | null = null; + const getRadius = () => + radius ?? (radius = unit.info.getRadiusFor(map.getPlayer(unit))); + + const canAccess = (vector: Vector) => + moveableRadius.has(vector) && + MoveConfiguration.isAccessible(map, unit, vector) && + !map.units.has(vector) && + (!to || vector.distance(to.vector) >= minimumDistance) && + !isWithinOpponentAttackableArea(vector); + + const minByDistance = ( + target: Vector, + filter: (vector: Vector) => boolean | undefined, + ) => + minBy([...moveableRadius.keys()].filter(filter), (vector) => + vector.distance(target), + ); + + while (target) { + if (canAccess(target.vector)) { + const targetVector = target.vector; + if (map.config.fog && !map.getTileInfo(target.vector).style.hidden) { + const hiddenTarget = + minByDistance( + targetVector, + (vector) => + map.getTileInfo(vector).style.hidden && canAccess(vector), + ) || target.vector; + + // Only hide if the distance to the hidden field isn't too large. + if (targetVector.distance(hiddenTarget) < getRadius() / 2) { + return hiddenTarget; + } + } else if ( + unit.info.canTransportUnits() && + !unit.isTransportingUnits() && + getEntityGroup(unit) === 'naval' && + !unit.info.canDropFrom(map.getTileInfo(target.vector)) + ) { + const dropTarget = + minByDistance( + targetVector, + (vector) => + unit.info.canDropFrom(map.getTileInfo(vector)) && + canAccess(vector), + ) || target.vector; + + // Only consider the drop target if the distance to the final target isn't large. + if (dropTarget.distance(to.vector) < getRadius()) { + return dropTarget; + } + } + + return target.vector; + } + target = (target.parent && radiusToTarget.get(target.parent)) || null; + } + return null; +} diff --git a/dionysus/lib/getAttackableArea.tsx b/dionysus/lib/getAttackableArea.tsx new file mode 100644 index 00000000..d8a8d710 --- /dev/null +++ b/dionysus/lib/getAttackableArea.tsx @@ -0,0 +1,12 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { attackable } from '@deities/athena/Radius.tsx'; + +export default function getAttackableArea( + map: MapData, + players: Set, +) { + return map.units + .filter((unit) => players.has(unit.player)) + .flatMap((unit, vector) => attackable(map, unit, vector, 'cover')); +} diff --git a/dionysus/lib/getAttackableUnitsWithinRadius.tsx b/dionysus/lib/getAttackableUnitsWithinRadius.tsx new file mode 100644 index 00000000..71a1ac7a --- /dev/null +++ b/dionysus/lib/getAttackableUnitsWithinRadius.tsx @@ -0,0 +1,45 @@ +import Unit from '@deities/athena/map/Unit.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; + +export default function getAttackableUnitsWithinRadius( + map: MapData, + vector: Vector, + radius: number, +) { + const currentPlayer = map.getCurrentPlayer(); + const attackable = new Map(); + for (let x = 0; x <= radius; x++) { + for (let y = 0; y <= radius - x; y++) { + const s1 = { x: vector.x + x, y: vector.y + y }; + const v1 = map.contains(s1) && vec(s1.x, s1.y); + const u1 = v1 && map.units.get(v1); + + const s2 = { x: vector.x + x, y: vector.y - y }; + const v2 = map.contains(s2) && vec(s2.x, s2.y); + const u2 = v2 && map.units.get(v2); + + const s3 = { x: vector.x - x, y: vector.y + y }; + const v3 = map.contains(s3) && vec(s3.x, s3.y); + const u3 = v3 && map.units.get(v3); + + const s4 = { x: vector.x - x, y: vector.y - y }; + const v4 = map.contains(s4) && vec(s4.x, s4.y); + const u4 = v4 && map.units.get(v4); + if (u1 && map.isOpponent(u1, currentPlayer)) { + attackable.set(v1, u1); + } + if (u2 && map.isOpponent(u2, currentPlayer)) { + attackable.set(v2, u2); + } + if (u3 && map.isOpponent(u3, currentPlayer)) { + attackable.set(v3, u3); + } + if (u4 && map.isOpponent(u4, currentPlayer)) { + attackable.set(v4, u4); + } + } + } + return [...attackable]; +} diff --git a/dionysus/lib/getBuildingWeight.tsx b/dionysus/lib/getBuildingWeight.tsx new file mode 100644 index 00000000..52e06947 --- /dev/null +++ b/dionysus/lib/getBuildingWeight.tsx @@ -0,0 +1,20 @@ +import { + BuildingInfo, + House, + MinFunds, + RepairShop, +} from '@deities/athena/info/Building.tsx'; + +const averageBuildingWeight = + ((House.configuration.funds + RepairShop.configuration.funds) / + 2 / + MinFunds) * + 10; + +export default function getBuildingWeight(info: BuildingInfo) { + return info.isHQ() + ? MinFunds + : info.configuration.funds + ? (info.configuration.funds / MinFunds) * 10 + : averageBuildingWeight; +} diff --git a/dionysus/lib/getInterestingVectors.tsx b/dionysus/lib/getInterestingVectors.tsx new file mode 100644 index 00000000..d76daf0e --- /dev/null +++ b/dionysus/lib/getInterestingVectors.tsx @@ -0,0 +1,198 @@ +import { filterBuildings } from '@deities/athena/info/Building.tsx'; +import { Beach, isSeaTile } from '@deities/athena/info/Tile.tsx'; +import { Ability } from '@deities/athena/info/Unit.tsx'; +import { AIBehavior } from '@deities/athena/map/AIBehavior.tsx'; +import { EntityType, getEntityGroup } from '@deities/athena/map/Entity.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import getWinConditionVectors from './getWinConditionVectors.tsx'; +import needsSupply from './needsSupply.tsx'; +import shouldCaptureBuilding from './shouldCaptureBuilding.tsx'; + +export const BuildableTiles = new Set( + filterBuildings( + ({ configuration: { canBeCreated } }) => canBeCreated, + ).flatMap(({ configuration: { placeOn } }) => (placeOn ? [...placeOn] : [])), +); + +export default function getInterestingVectors( + map: MapData, + from: Vector, + unit: Unit, +): ReadonlyArray { + const { info } = unit; + const building = map.buildings.get(from); + const isTransportingUnits = unit.isTransportingUnits(); + const isDefensive = + (unit.matchesBehavior(AIBehavior.Defense) || + unit.matchesBehavior(AIBehavior.Adaptive)) && + (!building || + !map.matchesTeam(building, unit) || + !building?.canBuildUnits(map.getPlayer(unit))); + const isInDanger = + !isTransportingUnits && + (info.isLongRange() || + !info.hasAttack() || + (unit.isOutOfAmmo() && + !map.units.some( + (unitB) => + map.matchesPlayer(unitB, unit) && + unitB.info.abilities.has(Ability.Supply), + ))) && + from.adjacent().some((vector) => { + const unitB = map.units.get(vector); + return ( + unitB && map.isNonNeutralOpponent(unit, unitB) && unitB.info.hasAttack() + ); + }); + + const vectors: Array = []; + + if (info.hasAbility(Ability.Supply)) { + for (const [vector, unit] of map.units) { + if (map.matchesPlayer(unit, map.currentPlayer) && needsSupply(unit)) { + vectors.push(vector); + } + } + } + + if (info.hasAbility(Ability.Capture) && !isDefensive) { + for (const [vector, building] of map.buildings) { + if (shouldCaptureBuilding(map, unit.player, building, vector)) { + vectors.push(vector); + } + } + } + + if (info.hasAbility(Ability.CreateBuildings)) { + map.forEachField((vector) => { + if ( + BuildableTiles.has(map.getTileInfo(vector)) && + !map.buildings.has(vector) + ) { + vectors.push(vector); + } + }); + } + + if (isInDanger) { + for (const [vector, building] of map.buildings) { + if ( + map.matchesPlayer(unit, building) && + (building.info.isHQ() || + building.info.canHeal(unit.info) || + building.canBuildUnits(map.getPlayer(unit))) + ) { + vectors.push(vector); + } + } + } + + vectors.push(...getWinConditionVectors(map, unit)); + + if (isDefensive) { + for (const [vector, building] of map.buildings) { + if (map.matchesPlayer(unit, building)) { + vectors.push(vector); + } + } + + if (!vectors.length) { + for (const [vector, unitB] of map.units) { + if (map.matchesTeam(unit, unitB) && !vector.equals(from)) { + vectors.push(vector); + } + } + } + + if (!vectors.length) { + for (const [vector, building] of map.buildings) { + if (map.matchesTeam(unit, building)) { + vectors.push(vector); + } + } + } + return [...new Set(vectors)]; + } + + if (unit.info.canTransportUnits()) { + if (isTransportingUnits) { + // Consider either offensive or defensive units but not both at the same time. + const transports = unit.transports.some((transportedUnit) => + transportedUnit.info.hasAttack(), + ) + ? unit.transports.filter((transportedUnit) => + transportedUnit.info.hasAttack(), + ) + : unit.transports; + + // Do not consider ships as interesting vectors for transported units. This ensures that + // transport units don't try to follow opposing units. + const entityGroup = getEntityGroup(unit); + const shouldFilterMap = entityGroup === 'naval' || entityGroup === 'air'; + const buildings = shouldFilterMap + ? map.buildings.filter( + (building) => building.info.isHQ() || building.label != null, + ) + : map.buildings; + + const subsetMap = shouldFilterMap + ? map.copy({ + buildings: buildings.size ? buildings : map.buildings, + units: map.units.filter((unit, vector) => { + if (unit.info.type === EntityType.Ship) { + return false; + } + + if (getEntityGroup(unit) === 'land') { + return true; + } + + const tile = map.getTileInfo(vector); + return tile.id === Beach.id || !isSeaTile(tile); + }), + }) + : map; + + vectors.push( + ...transports.flatMap((transportedUnit) => + getInterestingVectors(subsetMap, from, transportedUnit.deploy()), + ), + ); + } else { + vectors.push( + ...map.units + .filter( + (unitB, vector) => + map.matchesPlayer(unit, unitB) && + unit.info.canTransportUnitType(unitB.info) && + vector.distance(from) > + unitB.info.getRadiusFor(map.getPlayer(unitB)) && + !vector.adjacentWithDiagonals().some((vector) => { + const unitC = map.units.get(vector); + return unitC && map.isNonNeutralOpponent(unit, unitC); + }), + ) + .keys(), + ); + } + } + + if (info.hasAttack()) { + const units = map.units.filter((unitB) => + map.isNonNeutralOpponent(unit, unitB), + ); + if (units.size) { + vectors.push(...units.keys()); + } else { + vectors.push( + ...map.buildings + .filter((buildingB) => map.isNonNeutralOpponent(unit, buildingB)) + .keys(), + ); + } + } + + return [...new Set(vectors)]; +} diff --git a/dionysus/lib/getInterestingVectorsByAbilities.tsx b/dionysus/lib/getInterestingVectorsByAbilities.tsx new file mode 100644 index 00000000..5926d432 --- /dev/null +++ b/dionysus/lib/getInterestingVectorsByAbilities.tsx @@ -0,0 +1,83 @@ +import Player, { PlayerID } from '@deities/athena/map/Player.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { + winConditionHasVectors, + WinCriteria, +} from '@deities/athena/WinConditions.tsx'; +import { BuildableTiles } from './getInterestingVectors.tsx'; +import { PotentialUnitAbilities } from './getPossibleUnitAbilities.tsx'; +import needsSupply from './needsSupply.tsx'; +import shouldCaptureBuilding from './shouldCaptureBuilding.tsx'; + +export default function getInterestingVectorsByAbilities( + map: MapData, + currentPlayer: Player, + label: PlayerID | null, + { + canCreateBuildUnits, + canCreateCaptureUnits, + canCreateSupplyUnits, + }: PotentialUnitAbilities, +): ReadonlyArray { + const vectors: Array = []; + + if (canCreateSupplyUnits) { + for (const [vector, unit] of map.units) { + if (map.matchesPlayer(unit, map.currentPlayer) && needsSupply(unit)) { + vectors.push(vector); + } + } + } + + if (canCreateCaptureUnits) { + for (const [vector, building] of map.buildings) { + if (shouldCaptureBuilding(map, currentPlayer.id, building, vector)) { + vectors.push(vector); + } + } + } + + if (canCreateBuildUnits) { + map.forEachField((vector) => { + if ( + BuildableTiles.has(map.getTileInfo(vector)) && + !map.buildings.has(vector) + ) { + vectors.push(vector); + } + }); + } + + for (const condition of map.config.winConditions) { + if ( + condition.type !== WinCriteria.Default && + (!condition.players || condition.players.includes(currentPlayer.id)) + ) { + if ( + winConditionHasVectors(condition) && + (!condition.label?.size || + (label != null && condition.label.has(label))) + ) { + vectors.push(...condition.vectors); + } + } + } + + const units = map.units.filter((unitB) => + map.isNonNeutralOpponent(currentPlayer, unitB), + ); + if (units.size) { + vectors.push(...units.keys()); + } else { + vectors.push( + ...map.buildings + .filter((buildingB) => + map.isNonNeutralOpponent(currentPlayer, buildingB), + ) + .keys(), + ); + } + + return [...new Set(vectors)]; +} diff --git a/dionysus/lib/getPossibleAttacks.tsx b/dionysus/lib/getPossibleAttacks.tsx new file mode 100644 index 00000000..1018c898 --- /dev/null +++ b/dionysus/lib/getPossibleAttacks.tsx @@ -0,0 +1,268 @@ +import { MinFunds } from '@deities/athena/info/Building.tsx'; +import { Ability, Pioneer } from '@deities/athena/info/Unit.tsx'; +import calculateLikelyDamage from '@deities/athena/lib/calculateLikelyDamage.tsx'; +import getAttackStatusEffect from '@deities/athena/lib/getAttackStatusEffect.tsx'; +import getDefenseStatusEffect from '@deities/athena/lib/getDefenseStatusEffect.tsx'; +import getParentToMoveTo from '@deities/athena/lib/getParentToMoveTo.tsx'; +import { AIBehavior } from '@deities/athena/map/AIBehavior.tsx'; +import { + CounterAttack, + MaxHealth, + MinDamage, +} from '@deities/athena/map/Configuration.tsx'; +import Entity, { isBuilding } from '@deities/athena/map/Entity.tsx'; +import Player, { PlayerID } from '@deities/athena/map/Player.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { attackable, RadiusItem } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import shouldAttack from './shouldAttack.tsx'; + +export type PossibleAttack = Readonly<{ + attackable: ReadonlyMap; + entityB: Entity; + from: Vector; + getWeight: () => number; + parent: Vector; + sabotage: boolean; + to: RadiusItem; + unitA: Unit; + vector: Vector; +}>; + +export default function getPossibleAttacks( + map: MapData, + vision: VisionT, + units: ReadonlyArray<[Vector, Unit]>, + labelsToPrioritize?: Set, +): Array { + const possibleAttacks: Array = []; + const mapWithVision = vision.apply(map); + units.forEach(([position, unitA]) => { + let attackCount = 0; + const originTile = map.getTileInfo(position); + const attackStatusEffect = getAttackStatusEffect(map, unitA, originTile); + const fields = attackable( + mapWithVision, + unitA, + position, + 'cover', + unitA.matchesBehavior(AIBehavior.Stay) ? 0 : undefined, + ); + fields.forEach((item) => { + const { parent, vector } = item; + + let _parentVector: Vector | null; + const getParentVector = () => { + return ( + _parentVector || + (_parentVector = getParentToMoveTo( + map, + unitA, + position, + item, + fields, + )) + ); + }; + + if (!shouldAttack(map, vision, unitA, position, vector)) { + return; + } + + // If the unit is not reachable by the current unit, do not attempt an attack. + if ( + parent && + !position.equals(parent) && + mapWithVision.units.has(parent) + ) { + return; + } + + const entityB = map.units.get(vector) || map.buildings.get(vector); + if (!entityB) { + return; + } + + const targetTile = map.getTileInfo(vector); + const isNeutral = map.isNeutral(entityB); + const entityIsBuilding = isBuilding(entityB); + const damage = calculateLikelyDamage( + unitA, + entityB, + map, + parent || position, + vector, + attackStatusEffect, + getDefenseStatusEffect(map, entityB, targetTile), + 1, + ); + + if ( + entityB.player > 0 && + !entityIsBuilding && + unitA.info.hasAbility(Ability.Sabotage) && + unitA.info.canSabotageUnitType(entityB.info) + ) { + const parentVector = getParentVector(); + const sabotageWeight = + (parentVector && + getSabotageWeight(entityB, map.getPlayer(entityB))) || + 0; + if (parentVector && sabotageWeight > 0) { + possibleAttacks.push({ + attackable: fields, + entityB, + from: position, + getWeight: () => sabotageWeight / attackCount, + parent: parentVector, + sabotage: true, + to: item, + unitA, + vector, + }); + } + } + + if ( + damage == null || + damage <= 0 || + (damage === 1 && entityB.health > MinDamage) || + (damage === 5 && + entityB.health > MaxHealth / 3 && + entityIsBuilding && + entityB.info.isStructure()) || + (entityIsBuilding && isNeutral && !entityB.info.isStructure()) + ) { + return; + } + + if ( + !entityIsBuilding && + isNeutral && + (!entityB.label || !labelsToPrioritize?.has(entityB.label)) + ) { + if ( + !entityB.isBeingRescued() || + entityB.isBeingRescuedBy(unitA.player) + ) { + return; + } + } + + let weight = damage; + const isKill = damage >= entityB.health; + if (isKill) { + // Boost the weight when the entity will likely be killed. + // The lower the damage, the higher the multiplier because + // it frees up other units for higher leverage attacks. + weight = (1 / Math.max(damage, 5)) * Math.pow(MaxHealth, 2); + } else if ( + !entityIsBuilding && + unitA.info.isShortRange() && + entityB.info.hasAttack() && + entityB.info.canAttackAt( + 1, + entityB.info.getRangeFor(map.getPlayer(entityB)), + ) + ) { + const counterDamage = calculateLikelyDamage( + entityB.modifyHealth(-damage), + unitA, + map, + vector, + parent || position, + getAttackStatusEffect(map, entityB, targetTile), + getDefenseStatusEffect( + map, + unitA, + parent ? map.getTileInfo(position) : originTile, + ), + CounterAttack, + ); + // If the counter attack is worse than the attack, skip it. + // No suicide. + if ( + counterDamage && + counterDamage > 0 && + (counterDamage * 0.75 > damage || counterDamage > unitA.health) + ) { + return; + } + } + + // De-prioritize attacks against buildings. + if (entityIsBuilding) { + weight *= 0.5; + } else { + const building = map.buildings.get(vector); + // If the attackable unit is capturing a building, increase the weight. + if (building && entityB.info.hasAbility(Ability.Capture)) { + const { funds } = building.info.configuration; + weight *= + (entityB.isCapturing() ? 10 : 1) * + ((building.info.isHQ() ? 100 : funds ? funds / MinFunds : 10) + + (map.isNeutral(building) && !building.info.isStructure() + ? 0 + : map.matchesPlayer(unitA, building) + ? 5 + : 3)); + // Prioritize transporters with loaded units, long-range units, or units on top of buildings. + } else if ( + entityB.isTransportingUnits() || + entityB.info.isLongRange() || + ((building?.info.isHQ() || + building?.canBuildUnits(map.getPlayer(entityB))) && + map.matchesTeam(unitA, building)) + ) { + weight *= 6; + // If the unit has weapons, prioritize it over ones that don't have an attack. + } else if (entityB.info.hasAttack()) { + weight *= 5; + // Slightly prefer attacking pioneers over the remaining units. + } else if (Pioneer.id === entityB.id) { + weight *= 2.5; + } + } + + // Increase the weight for units that should be destroyed. + if (entityB.label != null && labelsToPrioritize?.has(entityB.label)) { + weight *= 2; + } + + const parentVector = getParentVector(); + if (parentVector) { + attackCount++; + possibleAttacks.push({ + attackable: fields, + entityB, + from: position, + getWeight: () => weight / attackCount, + parent: parentVector, + sabotage: false, + to: item, + unitA, + vector, + }); + } + }); + }); + + return possibleAttacks; +} + +const getSabotageWeight = (unit: Unit, player: Player) => { + const { info } = unit; + if (!info.hasAttack() || unit.health < MaxHealth / 2 || unit.fuel <= 1) { + return 0; + } + + const cost = info.getCostFor(player); + return ( + (((cost < Number.POSITIVE_INFINITY ? cost / MinFunds : 0) + info.defense) * + 2 - + info.configuration.fuel) * + 2 + ); +}; diff --git a/dionysus/lib/getPossibleUnitAbilities.tsx b/dionysus/lib/getPossibleUnitAbilities.tsx new file mode 100644 index 00000000..c7b33d66 --- /dev/null +++ b/dionysus/lib/getPossibleUnitAbilities.tsx @@ -0,0 +1,69 @@ +import { Ability, UnitInfo } from '@deities/athena/info/Unit.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import Player from '@deities/athena/map/Player.tsx'; + +export type PotentialUnitAbilities = Readonly<{ + canCreateBuildUnits: boolean; + canCreateCaptureUnits: boolean; + canCreateSupplyUnits: boolean; + canCreateTransportUnits: boolean; +}>; + +export function getPossibleUnitAbilitiesForBuildings( + buildings: ReadonlyArray, + currentPlayer: Player, +): PotentialUnitAbilities { + return getPossibleUnitAbilities( + [ + ...new Map(buildings.map((building) => [building.id, building])).values(), + ].flatMap((building) => building.getBuildableUnits(currentPlayer)), + ); +} + +export default function getPossibleUnitAbilities( + unitInfos: ReadonlyArray, +) { + let canCreateBuildUnits = false; + let canCreateCaptureUnits = false; + let canCreateSupplyUnits = false; + let canCreateTransportUnits = false; + + for (const unit of unitInfos) { + if (unit.hasAbility(Ability.Capture)) { + canCreateCaptureUnits = true; + } + + if (unit.hasAbility(Ability.CreateBuildings)) { + canCreateBuildUnits = true; + } + + if (unit.hasAbility(Ability.Supply)) { + canCreateSupplyUnits = true; + } + + if (unit.canTransportUnits()) { + canCreateTransportUnits = true; + } + + if ( + canCreateBuildUnits && + canCreateCaptureUnits && + canCreateSupplyUnits && + canCreateTransportUnits + ) { + return { + canCreateBuildUnits, + canCreateCaptureUnits, + canCreateSupplyUnits, + canCreateTransportUnits, + }; + } + } + + return { + canCreateBuildUnits, + canCreateCaptureUnits, + canCreateSupplyUnits, + canCreateTransportUnits, + }; +} diff --git a/dionysus/lib/getUnitInfosWithMaxVision.tsx b/dionysus/lib/getUnitInfosWithMaxVision.tsx new file mode 100644 index 00000000..af7ea02b --- /dev/null +++ b/dionysus/lib/getUnitInfosWithMaxVision.tsx @@ -0,0 +1,19 @@ +import { UnitInfo } from '@deities/athena/info/Unit.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; + +export default function getUnitInfosWithMaxVision( + unitInfosWithWeight: ReadonlyArray, +) { + const unitInfos = sortBy( + [...unitInfosWithWeight], + ({ configuration: { vision } }) => -vision, + ); + const maxVision = unitInfos[0].configuration.vision; + for (let i = 1; i < unitInfos.length; i++) { + const unit = unitInfos[i]; + if (unit.configuration.vision < maxVision) { + return unitInfos.slice(0, i); + } + } + return unitInfos; +} diff --git a/dionysus/lib/getWinConditionVectors.tsx b/dionysus/lib/getWinConditionVectors.tsx new file mode 100644 index 00000000..1fb078aa --- /dev/null +++ b/dionysus/lib/getWinConditionVectors.tsx @@ -0,0 +1,21 @@ +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { winConditionHasVectors } from '@deities/athena/WinConditions.tsx'; + +export default function getWinConditionVectors(map: MapData, unit: Unit) { + const vectors = new Set(); + for (const condition of map.config.winConditions) { + if ( + winConditionHasVectors(condition) && + (!condition.players || condition.players.includes(unit.player)) && + (!condition.label?.size || + (unit.label != null && condition.label.has(unit.label))) + ) { + for (const vector of condition.vectors) { + vectors.add(vector); + } + } + } + return vectors; +} diff --git a/dionysus/lib/needsSupply.tsx b/dionysus/lib/needsSupply.tsx new file mode 100644 index 00000000..284dcc9c --- /dev/null +++ b/dionysus/lib/needsSupply.tsx @@ -0,0 +1,17 @@ +import hasLowAmmoSupply from '@deities/athena/lib/hasLowAmmoSupply.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; + +const needsFuel = (unit: Unit) => + unit.fuel <= unit.info.configuration.fuel * 0.3; + +const needsAmmo = (unit: Unit) => { + const { ammo } = unit; + return ( + !!ammo?.size && + [...ammo].some(([weapon, supply]) => hasLowAmmoSupply(unit, weapon, supply)) + ); +}; + +export default function needsSupply(unit: Unit): boolean { + return needsFuel(unit) || needsAmmo(unit); +} diff --git a/dionysus/lib/shouldAttack.tsx b/dionysus/lib/shouldAttack.tsx new file mode 100644 index 00000000..aec7f63d --- /dev/null +++ b/dionysus/lib/shouldAttack.tsx @@ -0,0 +1,52 @@ +import { Ability } from '@deities/athena/info/Unit.tsx'; +import { EntityType } from '@deities/athena/map/Entity.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; + +export default function shouldAttack( + map: MapData, + vision: VisionT, + unitA: Unit, + from: Vector, + to: Vector, +) { + if (!vision.isVisible(map, to)) { + return false; + } + + const entityB = map.units.get(to) || map.buildings.get(to); + const distance = from.distance(to); + const player = map.getPlayer(unitA); + if (!entityB || !map.isOpponent(entityB, unitA) || !unitA.canAttack(player)) { + return false; + } + + if ( + unitA.info.isLongRange() && + !unitA.canAttackAt(distance, player) && + (!unitA.canMove() || !unitA.info.canAct(player)) + ) { + return false; + } + + if ( + !unitA.getAttackWeapon(entityB) && + !unitA.info.hasAbility(Ability.Sabotage) + ) { + return false; + } + + // Do not attack neutral buildings that could be captured. + if (entityB.info.type === EntityType.Building && map.isNeutral(entityB)) { + return false; + } + + // Do not attack if the current unit needs to move closer to the target but has already moved. + if (distance > 1 && unitA.info.isShortRange() && !unitA.canMove()) { + return false; + } + + return true; +} diff --git a/dionysus/lib/shouldCaptureBuilding.tsx b/dionysus/lib/shouldCaptureBuilding.tsx new file mode 100644 index 00000000..63aa36e4 --- /dev/null +++ b/dionysus/lib/shouldCaptureBuilding.tsx @@ -0,0 +1,27 @@ +import { Ability } from '@deities/athena/info/Unit.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; + +export default function shouldCaptureBuilding( + map: MapData, + player: PlayerID, + building: Building | undefined, + vector: Vector, +): building is Building { + if ( + building && + !building.info.isStructure() && + map.isOpponent(player, building) + ) { + const maybeUnit = map.units.get(vector); + return ( + !maybeUnit || + (!maybeUnit.info.hasAbility(Ability.Capture) && + !map.matchesPlayer(maybeUnit, building)) + ); + } + + return false; +} diff --git a/dionysus/lib/sortByDamage.tsx b/dionysus/lib/sortByDamage.tsx new file mode 100644 index 00000000..fc78fdf7 --- /dev/null +++ b/dionysus/lib/sortByDamage.tsx @@ -0,0 +1,45 @@ +import { UnitInfo } from '@deities/athena/info/Unit.tsx'; +import calculateLikelyDamage from '@deities/athena/lib/calculateLikelyDamage.tsx'; +import getAttackStatusEffect from '@deities/athena/lib/getAttackStatusEffect.tsx'; +import getDefenseStatusEffect from '@deities/athena/lib/getDefenseStatusEffect.tsx'; +import Player from '@deities/athena/map/Player.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import sortBy from '@deities/hephaestus/sortBy.tsx'; + +export default function sortByDamage( + map: MapData, + opponentUnits: ReadonlyArray<[Vector, Unit]>, + unitInfos: ReadonlyArray, + currentPlayer: Player, +) { + return sortBy( + [...unitInfos], + // If none of the available units have an attack, recommend the most expensive units first. + unitInfos.every((unitInfo) => !unitInfo.hasAttack()) + ? (unitInfo) => -unitInfo.getCostFor(currentPlayer) + : (unitInfo) => { + const unitA = unitInfo.create(currentPlayer); + const attackStatusEffect = getAttackStatusEffect(map, unitA, null); + return -( + opponentUnits.reduce( + (sum, [position, entityB]) => + sum + + (calculateLikelyDamage( + unitA, + entityB, + map, + // Assume equal position. + position, + position, + attackStatusEffect, + getDefenseStatusEffect(map, entityB, null), + 1, + ) || 0), + 0, + ) / opponentUnits.length + ); + }, + ); +} diff --git a/dionysus/lib/sortPossibleAttacks.tsx b/dionysus/lib/sortPossibleAttacks.tsx new file mode 100644 index 00000000..2301fc78 --- /dev/null +++ b/dionysus/lib/sortPossibleAttacks.tsx @@ -0,0 +1,16 @@ +import { PossibleAttack } from './getPossibleAttacks.tsx'; + +export default function sortPossibleAttacks( + itemA: PossibleAttack, + itemB: PossibleAttack, +): number { + const aIsLongRange = itemA.unitA.info.isLongRange(); + const bIsLongRange = itemB.unitA.info.isLongRange(); + if (aIsLongRange && !bIsLongRange) { + return 1; + } else if (!aIsLongRange && bIsLongRange) { + return -1; + } + + return itemA.getWeight() - itemB.getWeight(); +} diff --git a/dionysus/package.json b/dionysus/package.json new file mode 100644 index 00000000..9a3f895f --- /dev/null +++ b/dionysus/package.json @@ -0,0 +1,17 @@ +{ + "name": "@deities/dionysus", + "version": "0.0.1", + "description": "Dionysus, god of wine, fruitfulness, parties, festivals, madness, chaos, drunkenness, vegetation, ecstasy, and the theater.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "dependencies": { + "@deities/apollo": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hephaestus": "workspace:*", + "@nkzw/immutable-map": "^1.2.2" + } +} diff --git a/docs/content/examples/map-data-examples.tsx b/docs/content/examples/map-data-examples.tsx new file mode 100644 index 00000000..ed691ca2 --- /dev/null +++ b/docs/content/examples/map-data-examples.tsx @@ -0,0 +1,47 @@ +import { Mountain } from '@deities/athena/info/Tile.tsx'; +import { Flamethrower, Infantry } from '@deities/athena/info/Unit.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import Button from '@deities/ui/Button.tsx'; +import { Fragment, useState } from 'react'; +import PlaygroundGame from '../playground/PlaygroundGame.tsx'; + +// [!region mapA] +const mapA = withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, Mountain.id, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 500, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 500, id: 2, name: 'Bot' }], + }, + ], + }), +); +// [!endregion mapA] + +// [!region mapB] +const mapB = mapA.copy({ + units: mapA.units + .set(vec(2, 1), Flamethrower.create(1)) + .set(vec(3, 3), Infantry.create(2)), +}); +// [!endregion mapB] + +export default function ExampleMap() { + const [render, rerender] = useState(0); + return ( + + + + + ); +} diff --git a/docs/content/examples/map-editor.tsx b/docs/content/examples/map-editor.tsx new file mode 100644 index 00000000..f1bbea4c --- /dev/null +++ b/docs/content/examples/map-editor.tsx @@ -0,0 +1,30 @@ +import { Sniper } from '@deities/athena/info/Unit.tsx'; +import MapEditor from '@deities/hera/editor/MapEditor.tsx'; + +const viewer = { + access: 'User', + character: { + unitId: Sniper.id, + variant: 0, + }, + displayName: 'Maxima', + factionName: 'Atlas', + id: 'Demo-User-12', + skills: [], + username: 'demo-maxima', +} as const; + +export default function MapEditorExample() { + return ( + {}} + fogStyle="soft" + setHasChanges={() => {}} + tiltStyle="on" + updateMap={() => {}} + user={viewer} + /> + ); +} diff --git a/docs/content/pages/core-concepts/actions.mdx b/docs/content/pages/core-concepts/actions.mdx new file mode 100644 index 00000000..1060a658 --- /dev/null +++ b/docs/content/pages/core-concepts/actions.mdx @@ -0,0 +1 @@ +# Actions diff --git a/docs/content/pages/core-concepts/immutable-data-structures.mdx b/docs/content/pages/core-concepts/immutable-data-structures.mdx new file mode 100644 index 00000000..120b0dab --- /dev/null +++ b/docs/content/pages/core-concepts/immutable-data-structures.mdx @@ -0,0 +1,60 @@ +# Immutable Data Structures + +All game related data structures and algorithms in Athena Crisis are "headless" and can run on the client, server, or during build time. Map and game state is represented using [_immutable persistent data structures_](https://roberterdin.github.io/2017/09/immutable-persistent-data-structures). Immutable means that instead of directly changing the game state, any action like moving or attacking a unit returns a new game state object. Persistent refers to reusing all data that doesn't change between two game states. These two concepts together make it easy to keep many game states around, make it fast to check whether changes happened between two states, and is memory efficient. + +Imagine a basic game state with a map that is three fields wide and one field high. The game state can be represented with an array, where each field either has a unit or not: + +```tsx +const state = [unit, null, null]; +``` + +Many games use an imperative model. When you want to move the unit from one position to another, you might make a change like this: + +```tsx +state[2] = state[0]; +state[0] = null; +``` + +This directly modifies the existing state object. It now becomes hard to know what the game state was before, which can cause problems when other operations still think they are operating on a previous version of the game state. It also makes it harder to compare what changed with a state transition. + +Let's look at the same example with an immutable model: + +```tsx +const state = [unit, null, null]; +const newState = [null, null, unit]; +``` + +At the core, instead of mutating the game state, we create a completely new game state object each time with the immutable model. Note that the unit, assuming its an instance of a `Unit` class, did not change. However, when you are moving a unit in Athena Crisis, it has to be marked as "moved". In the imperative version, it might look like this: + +```tsx +const state = [unit, null, null]; +const unit = state[0]; +unit.moved = true; +state[2] = unit; +state[0] = null; +``` + +The immutable version could look something like this: + +```tsx +class Unit { + move() { + return this.copy({ + moved: true, + }); + } +} + +const state = [unit, null, null]; +const newState = [null, null, unit.move()]; +``` + +The imperative version is faster and memory efficient. However, if another part of the codebase is holding on to the unit, it might not know that the unit has moved, or it might not expect the unit object to be mutated. This can lead to hard to find bugs, especially when there are many variables and state changes involved. The immutable version is slower and uses more memory, especially when many things change at once. The downsides can be limited by using persistent data structures, which re-use as much data as possible between two game states. + +In a real world example, you might be storing unit positions in a [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) data structure which is expensive to copy each time a change is made. The immutable `Map` data structure from [Immutable.js](https://immutable-js.com/), which we released as a standalone package called [`@nkzw/immutable-map`](https://github.com/nkzw-tech/immutable-map), makes use of structral sharing to make copying cheap. It works similar to git commits, where only the changes are stored and the rest is shared between two game states. Due to this, _the immutable model can be more memory efficient and sometimes even faster than the imperative model_. + +_These advantages make immutable state models ideal for turn-based strategy games like Athena Crisis._ + +:::info[Note] +Immutable.js has fallen out of favor in recent years. Its meta-programming and code size slow down app start, and many of its data structures are less relevant today. However, to our knowledge, there is no better and faster alternative of an immutable `Map` data structures in JavaScript. We published our own package that only includes `Map`, and we are welcome to contributions to speed up the package. +::: diff --git a/docs/content/pages/core-concepts/map-data.mdx b/docs/content/pages/core-concepts/map-data.mdx new file mode 100644 index 00000000..29e157ec --- /dev/null +++ b/docs/content/pages/core-concepts/map-data.mdx @@ -0,0 +1,124 @@ +import { lazy } from 'react'; +import ClientComponent from '../../playground/ClientComponent.tsx'; + +# The `MapData` Class + +The [`MapData`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/MapData.tsx) class is at the core of each map and game state. As outlined in section about [Immutable Data Structures](/core-concepts/immutable-data-structures), it is immutable. Any change to map state returns a new instance of `MapData`. Here is the data that it holds: + +```tsx +class MapData { + map: TileMap, + modifiers: ModifierMap, + decorators: DecoratorMap, + config: MapConfig, + size: SizeVector, + currentPlayer: PlayerID, + round: number, + active: PlayerIDs, + teams: Teams, + buildings: ImmutableMap, + units: ImmutableMap, +} +``` + +Each map in the game is a grid defined by `size`. `map` is an Array of tile ids (In-game they are referred to as "fields"), with `modifiers` being the corresponding Array to identify the specific sprite variant for rendering. For example, if a tile in a specific location is a Street, with Street tiles above and below, but not to the right and left, the modifier will store information that it should render a vertical Street sprite. `map` is critical for gameplay and behaviors, but `modifiers` is only used for rendering. Modifiers are only stored on each map to avoid recalculating them frequently. In tests, you can use `withModifiers(map)` to generate the correct modifiers automatically. + +:::info[Note] +In Athena Crisis, the look of a tile on the screen depends on its adjacent tiles. There is always only one way to render a tile based on its neighbors. The map editor first checks if a tile can be placed via [`canPlaceTile`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib/canPlaceTile.tsx), and then recalculates the modifiers for the field and its neighbors via [`getModifier`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib/getModifier.tsx). +::: + +While `map` and `modifiers` are dense arrays, `decorators`, `buildings` and `units` are sparse. This is efficient because each game map has a tile and modifier for each field, but usually only few decorations, buildings and units. + +## Fun with Maps + +There are helper functions to serialize and deserialize map state from plain JavaScript values. Let's take a look at how to create an instance of `MapData` as is often seen in tests: + +```tsx +// [!include ~/examples/map-data-examples.tsx:mapA] +``` + +This creates a map with a 3x3 grid of plain fields (id `1`) and a Mountain in the center. The map has two teams with one human player and one bot. Now, let's add some units to this map: + +```tsx +// [!include ~/examples/map-data-examples.tsx:mapB] +``` + +We said "add some units", but in reality we created a completely new map with the units added. If we render this map, we see a Flamethrower on one side, and an Infantry on the other: + + import('../../examples/map-data-examples.tsx'))} +/> + +## Vectors & Positions + +In the above example we made use of a [`vec`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/vec.tsx) function. `vec` is a convenience function to create [`Vector`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/Vector.tsx) instances, which are 2d coordinates. Vectors cannot be created directly, and are always accessed via `vec`. Instances are cached for the duration of the session, and the same instance is returned for the same coordinates: + +```tsx +console.log(vec(3, 15) === vec(3, 15)); // true +``` + +Not only is this more memory efficient, but it also allows using them as keys in a `Map` or `Set`: + +```tsx +// Doesn't work: +const set = new Set([new Vector(1, 2), new Vector(3, 4)]); + +set.has(new Vector(1, 2)); // false + +// Works: +const set = new Set([vec(1, 2), vec(3, 4)]); + +set.has(vec(1, 2)); // true +``` + +Vectors have a number of convenience methods to navigate a grid. Here are some of the most useful ones: + +```tsx +vec(1, 3).down(); // Vector { x: 1, y: 4 } + +vec(2, 2).adjacent(); // up, right, down, left + +vec(2, 2).expand(); // self, up, right, down, left + +vec(5, 5).distance(vec(1, 1)); // 8, Manhattan distance +``` + +## Map State Queries + +Since most data structures are immutable, it's common to access data fields directly. For example, to find all opponent's of the current player you can do: + +```tsx +const opposingUnits = map.units.filter((unit) => + map.isOpponent(unit, map.currentPlayer), +); +``` + +This example will return a new `ImmutableMap` of all units. `MapData` contains many helper methods to query map state. For example, `map.isOpponent` checks if two players or entities are opponents, `map.isTeam(unit, player)` checks if they are the same team. To check if a unit matches a player, you can use `map.matchesPlayer(unit, player). These checks are necessary because a game can have multiple teams each consisting of one or more players. + +Since it's inconvenient to calculate the index of a tile in the `map` array, you can use `map.getTileInfo(vector)` to receive the tile structure for a specific field: + +```tsx +map.getTileInfo(vec(2, 2)).id === Mountain.id; // true +``` + +`MapData` has a few methods that return a new map state, for example `map.recover(playerID)` which returns a new map with all `completed` and `moved` states removed from units. This is used to reset the state for a player when a user ends their turn. + +There is a large number of query functions available in [`athena/lib`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib) that can be used to access map state or produce new ones. These functions are used widely in game logic and the AI. + +## Updating Map State + +Let's say we want to set the health of each opposing unit to `1`: + +```tsx +const units = map.units.map((unit) => + map.isOpponent(unit, map.currentPlayer) ? unit.setHealth(1) : unit, +); + +const newMap = map.copy({ units }); +``` + +That's it! After a mutation to map state it can be shared with other players, like for example when an action is taken on the server or the state can be stored in a database using `JSON.stringify(newMap)`. In the next section we'll discuss the formalized approach to update game state via [Actions](/core-concepts/actions). + +:::info[Note] +Many data structures in Athena Crisis are technically mutable as they are basic JavaScript Arrays, Sets or Maps. This is usually for performance reasons. Their immutability is enforced via TypeScript types. +::: diff --git a/docs/content/pages/core-concepts/overview.mdx b/docs/content/pages/core-concepts/overview.mdx new file mode 100644 index 00000000..f0588eb9 --- /dev/null +++ b/docs/content/pages/core-concepts/overview.mdx @@ -0,0 +1,7 @@ +# Core Concepts – Overview + +This section dives into the core concepts of the codebase to give you a high-level overview of how Athena Crisis works. The technical walkthroughs primarily cover these three top level folders in the repository: + +- [`athena`](https://github.com/nkzw-tech/athena-crisis/tree/main/athena) → Data structures and algorithms for manipulating _map_ state (_client/server_). +- [`apollo`](https://github.com/nkzw-tech/athena-crisis/tree/main/apollo) → Data structures and algorithms for manipulating _game_ state (_client/server_). +- [`hera`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera) → Game engine and rendering (_client_). diff --git a/docs/content/pages/getting-started.mdx b/docs/content/pages/getting-started.mdx new file mode 100644 index 00000000..56590b32 --- /dev/null +++ b/docs/content/pages/getting-started.mdx @@ -0,0 +1,20 @@ +# Getting Started + +Athena Crisis is a modern retro turn-based strategy game developed by [Nakazawa Tech](https://nkzw.tech) and published by [Null](https://null.com). The source code of Athena Crisis is licensed under the [MIT License](https://github.com/nkzw-tech/athena-crisis/blob/main/LICENSE.md) and can be used to improve Athena Crisis, build additional tools, study game development with JavaScript or create entirely new turn-based strategy games. + +This documentation website is aimed at explaining the core architecture and provides playgrounds to try out various parts of the codebase. To get started, you can clone the codebase using `git`: + +```bash +git clone git://github.com/nkzw-tech/athena-crisis.git +``` + +Athena Crisis requires [Node.js](https://nodejs.org/en/download/package-manager) and the latest major version of [`pnpm`](https://pnpm.io/installation). After installing the latest versions, you can get started quickly with these commands: + +```bash +pnpm install && pnpm dev:setup +pnpm dev +``` + +The above commands set up the repository. After they complete, you can visit [localhost:3003](http://localhost:3003/) to run the local version of this documentation website. + +If you prefer video content, check out [How NOT to Build a Video Game](https://www.youtube.com/watch?v=m8SmXOTM8Ec) to get an overview of the tech behind Athena Crisis. If you have questions, feel free to [join us on Discord](https://discord.gg/2VBCCep7Fk). diff --git a/docs/content/pages/index.mdx b/docs/content/pages/index.mdx new file mode 100644 index 00000000..d6fbeb44 --- /dev/null +++ b/docs/content/pages/index.mdx @@ -0,0 +1,26 @@ +--- +layout: landing +--- + +import { lazy } from 'react'; +import { HomePage } from 'vocs/components'; +import ClientComponent from '../playground/ClientComponent.tsx'; + + + Athena Crisis logo + Open Source Docs & Playground + + Athena Crisis is a modern retro turn-based strategy game. + + + + Getting Started + + + GitHub + + + import('../playground/PlaygroundDemoGame.tsx'))} + /> + diff --git a/docs/content/pages/playground/map-editor.mdx b/docs/content/pages/playground/map-editor.mdx new file mode 100644 index 00000000..7bc8a445 --- /dev/null +++ b/docs/content/pages/playground/map-editor.mdx @@ -0,0 +1,10 @@ +--- +layout: minimal +--- + +import { lazy } from 'react'; +import ClientComponent from '../../playground/ClientComponent.tsx'; + +# Map Editor + + import('../../examples/map-editor.tsx'))} /> diff --git a/docs/content/playground/ClientComponent.tsx b/docs/content/playground/ClientComponent.tsx new file mode 100644 index 00000000..6c339d4e --- /dev/null +++ b/docs/content/playground/ClientComponent.tsx @@ -0,0 +1,31 @@ +import Spinner from '@deities/ui/Spinner.tsx'; +import Stack from '@deities/ui/Stack.tsx'; +import { Suspense, useEffect, useState } from 'react'; + +export default function ClientComponent({ + module: Module, +}: { + module: () => JSX.Element; +}) { + const [element, setElement] = useState(null); + + useEffect(() => { + import('./ClientScope.tsx').then(({ default: ClientScope }) => + setElement( + + + + + } + > + + + , + ), + ); + }, [Module]); + + return element; +} diff --git a/docs/content/playground/ClientScope.tsx b/docs/content/playground/ClientScope.tsx new file mode 100644 index 00000000..17b850de --- /dev/null +++ b/docs/content/playground/ClientScope.tsx @@ -0,0 +1,51 @@ +import { initializeCSSVariables } from '@deities/ui/cssVar.tsx'; +import { ScaleContext } from '@deities/ui/hooks/useScale.tsx'; +import { css, injectGlobal } from '@emotion/css'; +import { init as initFbt, IntlVariations } from 'fbt'; + +initializeCSSVariables(); + +initFbt({ + hooks: { + getViewerContext: () => ({ + GENDER: IntlVariations.GENDER_UNKNOWN, + locale: 'en_US', + }), + }, + translations: {}, +}); + +injectGlobal(` +@font-face { + font-display: swap; + font-family: Athena; + src: url('/fonts/AthenaNova.woff2'); +} + +body { + font-family: Athena, ui-sans-serif, system-ui, sans-serif; + font-size: 20px; + font-weight: normal; + line-height: 1em; +} + +`); + +if (import.meta.env.DEV) { + import('@deities/hera/ui/fps/Fps.tsx'); +} + +export default function ClientScope({ children }: { children: JSX.Element }) { + return ( + +
{children}
+
+ ); +} + +const style = css` + all: initial; + + font-family: Athena, ui-sans-serif, system-ui, sans-serif; + outline: none; +`; diff --git a/docs/content/playground/PlaygroundDemoGame.tsx b/docs/content/playground/PlaygroundDemoGame.tsx new file mode 100644 index 00000000..01231511 --- /dev/null +++ b/docs/content/playground/PlaygroundDemoGame.tsx @@ -0,0 +1,37 @@ +import convertBiome from '@deities/athena/lib/convertBiome.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import demo1, { + metadata as metadata1, +} from '@deities/hermes/map-fixtures/demo-1.tsx'; +import demo2, { + metadata as metadata2, +} from '@deities/hermes/map-fixtures/demo-2.tsx'; +import randomEntry from '../../../hephaestus/randomEntry.tsx'; +import PlaygroundGame from './PlaygroundGame.tsx'; + +const biome = randomEntry([ + Biome.Grassland, + Biome.Desert, + Biome.Snow, + Biome.Swamp, + Biome.Volcano, +]); + +const [map, metadata] = randomEntry([ + [demo1, metadata1], + [demo1, metadata1], + [demo1, metadata1], + [demo2, metadata2], +]); +const currentDemoMap = convertBiome( + map.copy({ + config: map.config.copy({ + fog: randomEntry([true, false, false, false, false]), + }), + }), + biome, +); + +export default function PlaygroundDemoGame() { + return ; +} diff --git a/docs/content/playground/PlaygroundGame.tsx b/docs/content/playground/PlaygroundGame.tsx new file mode 100644 index 00000000..19947ead --- /dev/null +++ b/docs/content/playground/PlaygroundGame.tsx @@ -0,0 +1,81 @@ +import { MapMetadata } from '@deities/apollo/MapMetadata.tsx'; +import { prepareSprites } from '@deities/art/Sprites.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import GameMap from '@deities/hera/GameMap.tsx'; +import useClientGame from '@deities/hera/hooks/useClientGame.tsx'; +import useClientGameAction from '@deities/hera/hooks/useClientGameAction.tsx'; +import GameActions from '@deities/hera/ui/GameActions.tsx'; +import MapInfo from '@deities/hera/ui/MapInfo.tsx'; +import setupGamePad from '@deities/ui/controls/setupGamePad.tsx'; +import setupKeyboard from '@deities/ui/controls/setupKeyboard.tsx'; +import useScale from '@deities/ui/hooks/useScale.tsx'; +import { useInView } from 'framer-motion'; +import { useRef } from 'react'; + +prepareSprites(); +setupGamePad(); +setupKeyboard(); + +const startAction = { + type: 'Start', +} as const; + +export default function PlaygroundGame({ + map, + metadata, +}: { + map: MapData; + metadata?: MapMetadata; +}) { + const userId = 'User-Demo'; + const [game, setGame] = useClientGame( + map, + userId, + metadata?.effects || new Map(), + startAction, + ); + + const onAction = useClientGameAction(game, setGame); + const zoom = useScale(); + const ref = useRef(null); + const isInView = useInView(ref, { margin: '-20% 0px 6% 0px' }); + + return ( +
+ + {(props, actions) => { + const hide = + !isInView || props.lastActionResponse?.type === 'GameEnd'; + + return ( + <> + + + + ); + }} + +
+ ); +} diff --git a/docs/content/public/apple-touch-icon.png b/docs/content/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e7aaf7b75e572306d9452909633dff88c58e957e GIT binary patch literal 3185 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&w@Ll*i%Q8-bKxfKP~P!Wv`i$@=YkjDP}l z?gIbk+8i!a_{wwCCMoMF?i?p+=@p=*rM2dYd03XT zKmlj6u8i9((^emY{q=r(w@s5|VBo&z>Eakt!T9FR@okH}gxW6Jr=F_*|L^?uZF`#E zc<#35UfUJ@qTrj{W}%4(+i;L}x4-%S^!dWl|G#t}iud$ho2|y+z`(!=^u7Qx14FFA8*U?h zYap%ys+qHjHRAG{8Fh1)#|9Z)&dDi>`Mj^EcU$VVb=ULF7 zSbKJ9#O0hX^4GIYnx$3AUe12Ix98^m+T8`Tvs2>k(umcsd*8)Jl=z(WIV)6mG^b+S ziv9bcp^Fi|r27gbm>Mp|c5SWRoBVyMNa%U%;LS_YO!6PR`6rUIMZa6W`1J2IS_N&Y zep;4W*0g6|`_4X(oh|&|`1Z|&|9oF_*S)r1&k0UK4gZKuknog3fN?;xy3p@z|COFA zXTDf1OY=QDX_ocQy!*4i=fC{xv<@1=d0tpjF-4)AP%=GfSz1rd<2iBm<&&PMnwIaL zza}mIa<-ZM_p|RW?)kFOh!LC>2soeU3<@`=XX31PkBe?-1lnJIeW&K$yVtQn#Xe_e z&DwqQn9temU!u%+_ME=&ElgVWhnr4-aUgTq^%s@}zn}D8`Qvfc(li?w+plN5G!6c& zYg!(CeYLf@J^NiT;RCJUz(p%IC<<_Jnd7H9D|4Hfde*aDe?89btewA}^Ly}T)wRnY zi3?~xb+gg~d$(D#)#vu?oq4};xAfiZ?{|iZK>`*SwZxUH1i}*@{RirM)^=@@+syy` z%A8;CJyxGzxpV$3{gpKl0BPYU3OrK0VBj;|0)$kjVFEA9A~T|hg} zzW(d8UVdk+TUJ`$)T6+Pj}|HEgZ%QC-Rhc>D<$QFKC9mTF>%&!>9yZe7k{0%&R&$F-3CSUt}t=8x4;^TX# z=brs-elK14^D>+Bw5kF>8vXw8{mL5uORs~seKb%l-!yS%SpD4>r?iW*uYqHa%zh5tA zK`(8dQl+*nkhr@FZb6tiH z>&sprU;?(_7p=(nKk>2%`}4hmR{Id`r3M~oqQ5cCDc19&TBZH(Ti4maNdeS=rXVq3 aYX1;)WRI;(?1x=d#Wzp$P!}?txYS literal 0 HcmV?d00001 diff --git a/docs/content/public/athena-crisis.svg b/docs/content/public/athena-crisis.svg new file mode 100644 index 00000000..e3044bbb --- /dev/null +++ b/docs/content/public/athena-crisis.svg @@ -0,0 +1 @@ + diff --git a/docs/content/public/favicon.ico b/docs/content/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3a04224503c90ea55e7e8860906d978ad5afc6a6 GIT binary patch literal 2462 zcmd^=u}T9$5QgWl5V43LNV=qyRJKCEMhGZ^kD}OUW3Ei+6WH6?SXf5VSoi{Vc?3(l zasJKz=VZ9MMC>%-&&-LBYgUnW~3dm^%LvXC5@5KkY=?QSo$2#Bca zpOc-f%u=%yW6A#H+?lnsKgaa^8~r;jmLS>HGBt8#hW z$Y1ANV6^n`bNhLl{5abZ8-4Q3sH+$k*OTNoS$7UYtp2Z)?Rm$eGliIoc4u_km}Ael z2Wv3V=i&La65{L)|LSzO#Ac2*--}`fueyFQb~m|EDE9Dmp5MQm>F{2Y;Ij_rz#9FL zhXogTTKK#}KRfx@|Mad&V31EX_OHw5T>kFCB%gV(!9*|igGKCZ_E^cEfy`*hgawyu z&u0(tJfC|bGp@;o1sCF7!ABg=(raBdda*XL5l4*kvyL{-*|a7HEIbSH$o9{Oxh7VO zH?cWmosEpPHTk@6tWnQ6&Jc5LyqE*Wi+S>Y!zQcF`kwIXYW_p)b3^UrIyMzDwTqaW mol8$X%*VP+DpJ0bj>USGKQfI?D$_#c#Tc_qd&hLX-!otGw$&y8 literal 0 HcmV?d00001 diff --git a/docs/content/public/favicon.png b/docs/content/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a6128bc083c758836c0e87c367b5f684ef62c7fc GIT binary patch literal 284 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaA=?3_Oxc>kDAISWFbIba}XHM)` z@qeyOWk>(vLWPcsf;x8r8}DLYcNa^q04*&opt7?X=Yb>xQ%R6tFhj#^Z9kBib39!f zLn>}9J@3fZY{0`BDCE&&c;;_^^z~ewjL5Br+5WmuF#0QZ;BAZ9&J!~(=sRxZaw%VQ zDN9sOV1MCHo90%H+2uk9d_rAbe70GBA&a%x;mSgO$E@5Y&Rbh9ALMXeYPmyYgU|!R zSGA|P853S_(pj^CFzopr0N5jQxc~qF literal 0 HcmV?d00001 diff --git a/docs/content/public/fonts/AthenaNova.woff2 b/docs/content/public/fonts/AthenaNova.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b00db90dfe9017ec9caf33ba85e08680886f11e7 GIT binary patch literal 17756 zcmb5UV~j3L&@DQ)ZQC<@w8wkQXKdTHZQHhO+qP}nGw1zsPHu8@&!4;gbW-W8R4Ua~ zUERx7R+I$@80bHUXaOPqw}71b0RaiW|8Lv>*#F<)7Zp{K!NZ}!Z56}|vV+hA3g<(H zf(b7)3yTwnO&0(HW(OeyP3Hk40uSSa^e-0G#Ex@-*ly9D1F6_z*Q3m~Y^mb(&*V0P zZ?x4kglbq2NsdfaWhwD1Q}Cj;4^L^Ej`*#00F#y08HB!^NFxACDMM1=Nn6o*2@j?B)t*Z5hc| z#Nh5gaW}4(i>6eb_yXCogNRZU6})KjA%5v7|Bo2VWG)BKVPYc_azw3Ns8|R_@L!3W zhFj27$?A-Wi^_um3b&$7D*nUYN>`Wni#tU-)!eKxhdvwY7d8DvM-l#{J3{_f_nRr$ z-Xlt>IFW7ySg4dS)l}nPk~rs*Os8NLg!4;0gd(PZ*-5|r7WY3!U5wn~OHt%fR$TUXrhNVg`fp~_we3Z4qzgu0S=n)l zE(=!nT4U~W!*7&hGLF!OyhyQi9fIWuIgUJK^dIQJ4?i4FgoL0_ZPfW)lu22sfeNz_ zV4!ivLC4w--v=4(e;3)lN)x|h+>(T|ScRGZq_>4>!)5QDGA&GbwG1CDUjGn|1YnE? zFpolA=`B?AXQYCxiPpGu3Ch@XKXvW78~-%-#n|yAG-7t$%xX>SAsG*JlPMb0It5o& zu{?9V7?;>a0K1bMd8IU9O{~%7tLs1#)jd$Y%jE~4@qX|0bf>Qi0lWHTN%>M>9-Z$^ z`eXZw{t;8w2UgUXXG0aqcX`bTQOlI9+)S_nDZj1#{BgbJzs~(ur%JpCx73dP-Gd>k zT>2c(Za1Y&G|~S7si!+Sp}0N%ci&xbu|cC3ZqZrMH<4K3>^CunvK*86wWjh1iY`>i zmj{9RKJVg{y<2R7c!IK*XFqH~7hcM3nJ!5;;0JT5Rf=V$e$pJ|%NSvK zg$mZldeg`N#bnAh7Zv96yrkbP>aw17t72l`ou7O04|M6^8chD)`^n1X1SqqKavj#C zOXwZ)Pj}j@ENyd~GoWBR$&6S(rEyb@t<-t;y|+?APv0&uh-_ zHz%QGHR<@+e-LL$AZz!v+?bWh#6McybU{<o%@k z#y~+xp?DyOjfEDbNpwOm`9W`Up7wqnk+J=ZIK*Y~}hb^vVQ%1b|SP2Oj3yJF4iP#iU%iCWQsBOZucJ+^< zKdvIT-0vGrX`o_R?{Ah~^kusbi_|5;2QcD`J|~+DA>VJz<*81FH+sOB8w37b092cL z%C!l0c|5u&s13i(>O{Oo=ID-@yk@F;FI%(o_a9AuGrG(Vllm|1+dgLjs<&ANlk!s& z?7(m5Z@iTt_&1h4Y<@UCOCE(yeGJB37UPuiAJfc@E^lmzS6qD=jvHIz89_)12|#>z18 zq&c{1E03ldvcXpr(^NXst)yPrpjpauqK+*e1zZ@YB8;vzG(ZFU?IiyVw0Tb z`SZZ0DOs6NrcXL?CrOyT{2x;bu~2|^2PUA#fh!ySblU?{%o$mVi{as4772qy$S|+; zD^?K8;={(UzOZu{lSuiJqB@mozC!XvPx>WW#)wH&CTntu>>rcq^wv0)OlMqnYf6I| z0?+HlUEt7#{jGRRvA-0f*<~;%NsBX_npvo`!A=r2R^21d2PI|a>^B{^`J*`=H_05Z zw<>G~{yNQ4k#tUrqVz_qlOXaKj?n0J02U|9j!~kd^psT>ECgg4>G`jJ;Eg8^VKe)z zm{bu3XRPve8ALDz0^k^D%2caMof}w&T*|iJrV=ec{e0gLl91b_sUi`T>=KAwNAZ`> zMws%Pr~V8FB5?UE1i%xe78e66{Yf;6HG;(zF3s&T53EJ?5q4|HV)%|;UqP@I_6l^U(AT zShS0E(qdRA|Hd+JtA6&`jVr4|F!i!YhD(tZW~3b%(jEkG3U)_JNnZ-fV7v#q2KQ1f zvqf_q#`K-$4qRt5T31DG-CeGqcGJP;x@RltYe2pKE-|J!PU*KFqXP)E|9V9%xh#Cg zJAM1_1W(E?t#fmBE~yol%vBXzh1suzU3z`hQHV3rcO%&PVk{fpNlDm7KS&J~cbA;1 zcv_H*c>Hus=Azby5*AD5b44PaT9jFG z^K;B+m=FkkWm9ilyX@$cpQrll<1^H4WU6hCWB7(lt!1aEGAfmT@1l^oB`)L4%qO3) z{yRES{Y_8hoW>&2x|HRH=H+@e{Y`(-Lmq~Xr5-%o%4r8uE}pwN(pX+>Pm8<^|85!` z?&LYCosQ*sH~o&S)G9+32zav=nuq7klzaZ7lS0RXDWnNx5p$P(p}x@}wN6~f1rD%&o=fKqqZlj34u{=3=B+fm0(#@$6{PFHJ1 zRb%@pzC`Uw$pm)&V~i6S5rYwmBXDrJQ+Q1+jf5lWMPcK$%Q&r@=A6jx4A}03$L=h} z?xe(SHy5OVY&0y-u|!9UP*9$Eh_vq_p9%CXl~~`NAMmZM!--zdx=Sn|hV@cEAxPj~ zLIJ>T3LTuU3?yLo5JU%Hx`F>0D*f53F8K+(Lo;eFUv3OZ5;PV@tocqQY?Zw6kJ~Wz zs!?%l%gCpp%(5f?54U`Lmcq{JQ{gva5+?q?WO?VLn2ds!*04=0`ceLnH%Ng1P(&yw zsy%o$BvM>w`ytw|5gt4tKQwe7kD_W#1>Eb^A02xB0#C$8u_?^`0^ZD8{!{&(Hf1pq z`jD!lYQ6d-8_4UX?dDkoKJv!|5$)}0yi0+jj-=P)Z?j)~a3|1Y(PeoYivimjZ_8G^ z?~sG{8J|=}B$>R~v(8xmlG2=N5I}P86nvsZQS-sAM9bA;EZzVF^`b z88&GE`xp?B&b%Zl#eB(VF8GWlv&DQVM09xkKt9lrD6B!IbSC4GEO4pL{VKIa{mB?! z$e1in$Nh-{ns4L5HGF^wq%$N7{1DFw#6`)E;1JJoe35J} zS1Hkm5zXZUqI1TPH>*tOm3Wgl+e3A?tx5=t0|EYq47C8O_l)`W0_nH0q>`euG&`pGsF0 zd_#ODX1GNZrs1uLmAKE2$(U>l3as)NSZq;JIv#{{8oW0#f@G>}i!N0^ZTNBEvFcUE z0!0c~d7Z{xVL{TUfl=nl(NbI7F$yIJL=>--A1%NiE_i4~V;QhsQ(#;+mi1CWwCIeB zSj6BjAgsG>w-upbmkF-7HnJgUVy+rvkI`c$&&J^#BSEUhjvN{T0qp#@f*KpYlJC@N zTk|H2NGureiL}ebN#AfMY({$CfwC7yT>*T+0r4|>umw(gFs9iL#uCPIAjY1#LuRo# zcS4pvdr2~2JRbDOae~A!v9)1Hnes-&5arUqnNmezk3m~XR%+~id~_hnSt-0a<+7CG z{d#&giW&%^w$5%zn$Gc_}Blplummoo+W%=B z@rgi&>$rG^438?})*5pCcq7oF=2)Zeg?cp95;>I0gC;Tm4aE0?_SUF`df@t;qY^{M_b?=%vk*dtN z?1}T_5y~r1HMn5I5u}%sb9sPXhoI2I(`WrXb2ZN ziCWivEfqDk7A+ik-2NZifZW-za{oBw@`{K~$%55av3LXyzx`-~@H%J+L1GUS#+Fa& zA=YVWU)Q*sie!9qY6qyd4*PvHOLtM{@i`Y6ez(u^PYQkc@ShSTkgJpsko_kOf6#Wh zaC;5h6N@6aA`)dH27*`x!>jnUil@g^gEU#cS{_qP65}iS4EJuW|_-Vc@&Rjki6x4O~RbQ=@`pJ+2l5MdM8uV7CQ-y&4o4sb+D)dVs~$ z1ErE2pt0L)!79h}C=alizb;X94}9bthct9c?dmVC!_0D6Ce72+Kr1n59&ao9RpLhw z7Zmu~CMXTzM*m!duBW;HTf$R8x|TE4bB(51v+{&IARf1NWNcO!wi^m}xT9a=;-8AS z_G5|7;g~ruTHd#+mY}*SMirvclCXPGI`$Y`>+oN-+ASN_pn0WB8LCh{Eh4l9wNqRP zJaY4z+b!g}K&k5>e1i=twJ4y(GU6B<+hZ(2PP$=cT)4^iK%C{dUZym3w};N9)o}bW zM4gg3%c}!AdXFsglZJ(q(nH(mOPzW%pB8zFsnt}I&zpqCUL4NQp&hBUBWht~nI3dPJcx> z5B1492smse?W7*LpSMoRFb~+Q*jfd~Up(I4F%VVrQ?#Awd4gljowr)Tkh7MY3b3kk zXE_J}#xx7Br2>wm3;a0w?fE2CHVsdre(^?#|4wbs}S3JNL{A zW08RgIF%3y9O!zGYaB)`Ii!P`2?Xr5KpIOZ-1D0fz9bkHAKY)uRvt(6ueSIX{6`r~ zXC#L@|G{w>j9^L~noLa%3El4#dCw?}cCOi)Jn2@!>DuDRCQ3$)%V>kCJclE|=Qf{o zB^PE+kMuGt&&{19FzkPwS;CWAd+eY7gN5b?42GWD(X|cB^Dw@E6Ek~-hae6 zv|HGda;>IV*qXOnMbuXteBazKGuB&YG5*UiP`2dwX0p^zaJJeB(P&53Oty9QN4lCa zbp==SUFMk(g(=}E*ZgVJS(iu83Y zOPp(y!2w(z`*eDLXKB}A`$8K2C#oi$++(z!o#y*g@bmBfJC@xTRT*Lp=QLq<=eZ5B zY02BVyQkpv%r@m;>Ri%Vp_Bo)b1aIG(y(XFI>#?N={Tc~RVZI#Z z2s_Ec^Zq%G`?KptXlYZ^3f^F@qB{IWZnn^`y%TE4h4I$suESHGy;6LnCo2Rk z5eO4(>s%qC+S{{B+lLV#(AA!3=DQtqQ=#Kd$sm@B!vE-}Q_pQDtmJS27IUK=YdA!3_FkvD0Mn zX?aKW&RsR9C-9wvH~1olAW0;R9@cdZwRhynWNbNXTerooeNX!y?o-aDxPMAn@b)4u z#c=2SHM>D$tV0{dyv{JfMSc63j3Gc!t++^|qTA5hn2@mpnSqe{MAhYS&Zo?*syYQT zEEn_w<2=KgQ8U1F|5UYTx2C$PXaxmZvx<>%m2X^%7IV(3EvY7|!W>(mW}%~@Vzve7 zED7^d@Gs3v|5Ph3t@pqsWS2Ga@BPZ2`8sqkbv@dH^L!?EQ}Xer(7UFNFaE*ra?>tU z;~8}a?262&MTpd!y!0(d?=YGzRTMuTwt}wlI8UssxBX*i>?yB}HS3VBUYdxO?>~KW zokinW-o|t92@S2M8Zs>g^)2NqCc>KyWPYQ0uM>0o*eNv~+Daq+>ZSLhVyGzAMn~n`g&#~kjd2un+}qDWsR;`VZoCavsR0bJE@hn@5pL-y|?e{$N_ho zZjlBBfzJcZBBKdjtqHY`1AT!Uy&OH=4If-3%nyytmus;PXH08fDVcv|j2AL|w3~H1 zq-(jQXEIf<$cV$Q5dXRaa!9{nylL*=-DNH%f1|dxQ*#JW(2qK-mPGgN?ABf~ToeNJmr5&5nb2Jnf&l`DQhyA7jjPkJotF+C?r) z{W-j9`a}CsmjB&N?laxd>l>;9pE_})#o5BX`Mf#m1ormOjpoCjad}mvNrX{bzXLjM zIprh~{Zj9M3UR;MVERyKAj??n_^2YjS)heCAgk`o&sJ=0h%~12LpxYy62cwun@caF|U1&i1{l@8>qJ!yIB8pa_sm z{*_K!97onF+?v4NJPkWRB9vk(b$cToq`mdJ`a=8wjId5EgSM*lESxesd>UAL4mb)K zE$*-wdh1StJYbr1o)^UG{^KRSL~3h9m{nyu#8%$6Zy-IH_0Z8^!njgEch!toe}?>c z7*L&qWOU>K%0ZPFz9=SdbdW#kBDzXf+#vL@(6mVIJdy~a-SZFzcY(U6^uBhvQB%*o zi5<8pvJED8@YM7F?53!Z=+8of%Xci5`~>0)r-Q;+j7w_Zi5*#BsFq_}$Z#aJ3Idea~DC8Y4bBt@~XJ9LL zCbS|{DUvj^bBtoTK{=Eu#DaxrH^j!x)x#W?t6Y$0fi2rolX~ z?ezG9S(+qyaaFs1^V`1C_<4`*x~4LNs+#V~&`j@30$0*88IL;^x=uMk>GLoBc#`nN zzfk=u>b#_a_duOB3o}0LyF4rp+}G2aW`wtk-wVTwXUA+u4K)h1)ZzKNv-bwLxtXrV zcjzUW*AZ?XQC)s&IEmtS@mF#G7QqeOH|#g;oda@?GHeg_&E9liL(VtcH?%MAywU+d zzIU#Yls$@r0590&;IO~fhAMQMdEeQ zWW88%I)C@CGF~&@7R3G=_HN9D^)G^l$#1-ii40M-OA=!4G?)kFmgl+`vXe(vE=Dug z)1~y~_;b(?CWlCYo2M_xudYmK-_z6W)Q9lthjd}e73lA;I0bVtPj@_VH`ayVYI}d5 z?2Oz)#rSR{kiqx<7Fa*#kdp}?%dl`M$@jUBiMT|0_?*R>s@py9lB6TlD5|1ApGjHw z=9qs${ehcEgS9GKz^Q%oXLhwN)!t_1kM~Ex?qJ#o)+29$DDd02QUOqx-@@1nJu{pg z5-27%L$k&eWpG1qp_=>sLg<|oFfkP3@-?--y$5KOm%q|9q10bzYhJ0W9!fR&R*!R! zDIh@}symRiOv0$obU#jk8XsB=s_;Qz<<*zygxCBcX9vb==*IvzjkFF?n2?1<%w2EqtXPRnPRZ+S0qrh+>U5t2J2NOS_ zv+uS5Q}*K}BR*ubQCO!yg*t3eEnFA;f8Si=n}K4SYXDeTzEvsT`3fd$^bUfwRn2Yi zeBOzfrGJT{c0)_?%r?%vOm-7L~3&Q`UQigb`G=#1G(Q55rYF;$%vr5`P9bx(t=HQmwfmn|#!Vw$E&ZJ1^UEokrzo zxBA9hSpW1y#!tLR8yt`D5gxluz?t|NiOfL==z?LOPcL#0bj^4On@~4vToWKSVLCei zj-Ob67gDiiXe;aO8H)E?eSDUG_f&Qs(2f@dg&LVIZ%sn$*rINy!zi%&LGeYFlM;JL|C2kh$?i_imc|W+#oQcE`tq8sN-`HgeODuK@*Gu5n5Regw6D zU;Qi%%fk(0{BhJj1G&Z*X~Vs&;Y+T<>~WdDlf@)BM`P!z5Q`hL)D27Z>F(>eT`ae` z8+)>#*HSDuUJLI!JYk9-&*!{GbKP6&$IV#*{i1nmsbJ-5hjTV&K9#wkZd^|GUEgvHxjHkFu?l;>{dl-+iFsXu z>Ct4wJhNGhN9e`*3y3U?%2sF~ZimSDnKc(0Z(pa#5!Zre`Uc2eG@PPekN!~l7EeU^ zX_VHn0iTF;a$Ry*a8oF;_t!o_7M`1|9C(h_E9oNLCXVX+Kud1JuO*homuu|0@?-w` zU`5+AQW_&2hbvxem6?ddew=|ZiU-dUUSqei;#H(O4$y&<=a;kL_=qBnKnYMjb`SxZn^Uo;soc5_f1Vf_%**RwFWIQE6gQ{x@3&1@+zepZBb?(pLCr+# zvoKk#&DhsXAGUx$==WNF6>H0PFWRpnE_Eoa`dG)NRIH&6x*9O6%HsQoHj>!dPKr*c z75lp)3m$Y&dRa~JYIUURiqmmXpe10$GN%0OHl!gB(;r(R+IGKf^E01FG=`i9c~$od zio2dp=H^?F2CHQjIG&Vaj2H^3zS{HUrlIk5I0&oNf?v^V`$2*2EvIw{M8?`cZ+1B(${W-vpBw|7 zZY_}O!Mee%iTD|0cXyilP-@h47Mh1AWayOz2Q*W}y5b8>ZK-Qes!V_MC z&k*uTuD6)@9(YFpH;ahOTm6H2!Rr}IF`M;aUU~`C4bqNv9kDb|tvFZ==M@7gnxDim6Jb(vcBdN0jjU@(Y`RMT7mLx>s9e=Xr?kNpR zagl>K=+C`K8mH-ZWj*43%C~6tgigO#tbV_j+En{&kx-^YE*8*KOrk=cD07!WK~cig z33i41y_sJXYwEF>;_GU9=i@f^$#o4KwSx`dFzQCXxNk7VGMQz>mxrT}CxE0N!_v=~ zxB18=JtiCl8et_QVIv`}vz^(`Dh*WA71S+yP&wae&j?!0k_+F0Babz_SfSlG4tm3> zD2gQCIq5c3_i7vWuYpTre<|+{Y`fVqp?V4NuOGNNbtOcf+L^Yvhx}3hy18&tIQ9f4 z=BJE`S3!7bqEnENjIH7AD&dXcz%_W)%($Wfe*{vl>0SL)UWg0u&Rc!BTFly9TjZkt z=gKow=QDHK`vXZINVajOQIqdEi1V`<=xGQwfJb+$Bjh#s2$#q{^mlhX++|?}53M)!`GNT(V-Blilc_8P;=vW>lrA*%9W9y zrez|~FcN!8@046B4xEE%%{eX3>(Mvtng-s!nyxG!8F>S4o+ewq_!pvbTY2@j?WXtp z@HG5N0;5vKR9b6Txhn=|q!11XbKd^GhFdFh<;JX|ed${B1W;dUoa-_IHXX^1w=0%- z6P1U-*~^%FrOZXHFL*Am>^n)y51m-x#_0mLot@+pQr=J(_0aFfP*!KcCGh56;CJr& z)*I==9ymXD34rX6Szu&w;W5Z>SZ}FRC@${%O*7MLFePNwW=&;gLKYRCF^_Za57e5XvC`Pz)m83cC4`8*TJ zQOd;rn{3ZV2>8;Q=ihOItDs{ZXcC+z?(PC)2{q5E7j1!veKS!yt~~g^9ovQ_DeYMcGJD^1xni3V653{nk^`j_7UX zDk<02-bJJ#8-CLK_XMja2a!U~Q=Cs?mWwH}s3=(aL0+0NX8aHP`}~*Ym3J|D{O_#W zhta&PibqD*sK$iL-d#5VQHsYsTqFLSZVJ6d6%<5-rN@RV z*B1=$7yNfmw{buo_WBmnSLuTElo{@+xr!IqchK-1zeiFCMIEe^A4;PA89I&iVpOPY znzaY}Z4V~9tffF_0bRF2FolVq0_bMK+bNPoHaLe5vC1bIV2fRbYj`gVr&b}Ok6p#& z3bbh$K2xOoK^SO}_)xmJ*8s1v?&ao7*{}WGLDT(<^=Jr}c$lmGOjT?TkCB4eqn9<5A@JbLk2{IvPxI18xYI#O_AITKA+sk`U%*k z6ZnLw1_bU;@K=-!1CRqCpwFr}-YI>y`3O4%6b7T9^9Pd`Pb#oH#)G4M5 z?L7_3{S%)!wjTR)sn$Z`0`9vSk21kYB<{|WyZ20hb!SP@(pf+Mkj;y|T--yD9mKFT zKhG?iB_W!soZPXp+BsYinYWcEkmWa5hMpY^`deVR6sbUt&TW2uJ({c3>!!JgD&F4! zuUwpW%)h=Zrd{tlq6c8itb2p40~w+$Mp@`lF3w%n2$ zk!b;!7uIk(){)g#*aN2xqdIQ*LugVPfD8cHFq}+ZG3qCDW6Vl#MG22$u!mbu9L%o2 z1VI=P=*#eu0ZpA|aOp`6Npj0;FAP8AYPsNo24nJEBVuKmr+JQSZXmm6#ix9($t_Y} zZeE@>=rExhyM+=WulB_h&t^@#G?f#Ib?DU@@4M(7$ZdWTHC45UKiHOSUZ|eco(bzE zu-`RJ0cE`!Ch56kn8JHhz(lL9o z7jCD8#FFJbBMv2!+jmyap77w^-)8Ru;(*u%W*%RJdZn>;H_V0JZa?{nW|AqEk}r* z3PS7BEf#t*cY~+*&6}8NFeSwue?$G}j(e4Br zMtDmNMC9!uOEyj+*fMdF^kH2hsi#gisL&`-W~Ao?LcVX_4$U=FWK(>NP`13FY>c!k zz9188cZqIOhlk_9LVnr=v!lZC&lz79B)*Izr!kMahKkm~ARe#5WOU}lr9e}MsbxjUva5r`OMSiLCW2sTCAa6hdP+$$qwzNQCd zYjpHF-8+9JR|La}z1;BAUR-Ir;lBHg@k{bZ{z9MKoBc{~jnSrh&T1w9MPDW5s3`1t zagy0OosuP0*)Dhi8WEzHt16NQL!-^msHxhQGZTA4Tp2yUQ)Fg9Vz~rW{ESI57^Pa8 zO|7*YtYq+8r}yN`GW=s5AvYNeMw^h3f!iqLX?alvwh^!$>a_Zw|Z@-|MF0wjmNAjU^W>7mL;sO zw{#9%iKPT@KG0=@nICm2kK=Lda(Z)c5W;jGuBuio!iTG*MX!21v~-yEcy!9tdi)QC zHdm&TpSlJNd+oYY@hqYOe3=|NER_|4+g*%aC-TSI@tP0zd)GFdnonIr{%Dkh)*Rm$ zO>rMAyYP0=01ok0vm~5r@Sn@~m0s9hWpMSA!hq*m{`I)$8yu2x|8?uiU&$ZW zX^0zRK=*Ol^GKF8gbcM&Bb-pYhms1DikjC14H~#E{|joXtjcBewlt;I2BU*V6OYZX z7NiDKp%Dx{Mvy56Kk+Xx1&BsWNHK$jmBiW`P`GkDZB5fA^gba#EGtidU3C)V>)lFr}5?U#3OxHaa!a&sNGgoDC8c@tpp{LVuund_3JEj8< z^rcnhMV%{~lF<09699S1!mGI}>y}PZy+>D{s_o4h##8xw80TAS0P9t3#Xv#4tWWA> z(`S{yN3R067@1sHAk{bgf*kMPNk52)2=~ybo4_B1aHGS2qFqcVID5GzI_>GBBJGSm zQwUMzQr-vTY0^q0yXHzQc{?gs*=S8sZ*1xBFEo0W%g>b#hM=lwv`;@Oirl^dw#u7` zFEHW{Diz*w)%w_Sih>t6i`t(1wi6uj9ocCYqG6Dk1uocn%wK~@rEggsNe#hw2HU1Y zVn&h7=o=xJw1875qi}vQB%%<{p%+YoJ!}U_fjk(&^_My_b@Rfz7!nb{dWQ9Tzhnq) zGqVZ#K)JiNAKv%X$sg&=?YToV7*sPha3_S}o%}anqH)Rs(qHuZ{dSU#1uCov+fCLaFwje}+Px z1?BCh+tenz3!k6`YndCv$0{krO@W1UU}AQd2oLUaai-sv$TR(z%gPyp1ByrEXG3>s zoINl3ubLQ{P6H_RIKf$3?!x{bLsJjZzr#3N1ySk*SV>NbNR_@9R^smQ7f#q>NnPX_ zL8ob=(HcEV$*Y`DZhwZCgZlGwWibtKu#Qzp*(f~B!Qq}94euyM*`y^Czq1KX>wQ%F z7xych-*Jo_wAd$>@6@9|xkB`KFTE#PvgvQ)nqM!@?}*F}Q3*NsOU(Xbp%|TjsrodW z*6P`UJ1;}i9N8UR>aQ1LKkZ?&t3>G}`PA~^|B8G9jicdsN^tno zWWsS+Rn^gwQdbd3gUK^(Mlo>%xy}p6IaCV@E~RdU6F3Wb7%C&K1#!)s82>uZgcv}5 zsEqJ7nMN|e=q_coJaRQO*(ZDErTBNZBr64OkM5e#xeesl$AE#|?kB5iaVOkxIft-M zGm7(XLcK8vB_SR-435o&H4#l;YHqru1J$V$f>nubaV(4>9Oy@A8uLhL$5d@8VDcI6 zgI_eg=^4SpU{VtHB{YwRd<^r@sAjuVW;5G*SSI-(bvA_|k{Tz2?&ekuom>XS4(*Xn zD#M|RKvXzK2PD&HAAzEq$N2*q0pWX>mOd>D>{pibK^_R|konyLR6<2bhC^CvE#@*| z_{7fL%OB|QnX^aWyitpqwM!`Ns?Amhr~s6x92}*1go#=V zrttqJm~_#aMI4|4EYLAdRM>3P5RRDZtXT8UT>9+XLi!;^7-mdH33QD2tXd>(`% z8VF)xC|X$T(i6nSR5<^?Y~h@q?6@9v-=6(Mj?55KnLYT~#tRp3D6r_z*JwHPNd4cj z`H#g2O{hRFlXWt(p(3;8GSogJ)p;|v@-lPt|F_>jQ2kI5`(P6DaFh1|RO1m9|2Mx3 z+BIxjI=}bbxO8hcu>L0vs6FQDW~3(xzW@B6h>945|9>LMscjRxDn@5Ppw1iVX@Lw= zQG#1y%w2OTrGrGSF3+~jl%1umwY|m5)7{lr67nl4%2iOlH%GR4$DFUro&y=TvM2l$ zjk#C+(f{G^T``M`zgncV@w2GC8oZ$J1V6IOfisZy)C7U99F9Jp=z3b0ZI-0e45*bF zKIO*o?~HkKe}#82yDN5JFoZiI^_UF5lQCIYK zRvV@15M47nt;f`XH>N9eM8=EmleQDfD~u&XRgr^nLaITs=6G40py;u_keA-ucu6K{ z?4!SO4g7@LTHO&GOT7uO$i9{jA@vUhLIz?Gj|1Kh0YS$>VsdiR^YaVn4+3YrnlYFF z7NW}I!}*F#qQ4m9P@s<@NQ@ms8bp)@Y0z@vfaBN$kULnb>IelF=0d3JODAHMYlHBU z;npSi5rB$*}c;&o4aS4WXjY@omW^vF4%)& zTIt*!@)3yNjr2Q>kk$jO#&*0oafbStfvz|OvUorS1z-mBfx9#5AaLN+I$)>-KxKg7 z!9qYJ%k`*P%SJGCa5*3MB0|>YiAL(AmM3|VSho&V`${2KMu;|KubGon>nY5uth^PP z`uE}c+L5tBvNXB9+t^<#X-dhm#C0jFT5@z`(neJG;z9QXth|N$5+2$*v#Dq-e?WDv)`BQ=cpz{l zne4ChW{(w5Ot&y2H~HhYcS)~TPr6$FU+F%RGfYeGbFS3ImGf!=y7q@FW^O{d>k52L z&|7VY_^P7O!wwilBs9AB7$<_uzq=EK)kM2aYSP#t42v~rdgJhynmL9_!btUiafd3* z91g;`75sJPN9G|Oee5M&;=INZiL`g@xJ*l|d!mBPit7xWDm`4=&VmHr@DCt(+RtW` z8S9ks@JwAD)omNzjF=6zI!-u8j6+lEfrmwgmZNJ{hSyTA39i6VQ;Wz02uw2cGqNwY z4N>bl?OZRW>|A<|b^zZuHEbPMV$s8!pbh_fCK*_A|t_&^#vktlu!L*9g|fq5va7@#vO4LLyJYNEEdlZL_RgnQacAg8x=Bm}tZ?V`c%MxolEhYC~$j;~eg zGPH7CSjD?k6ig|XtI2M@_mc{4igP3>c6;+H8qz~Yes!<;vHY?L9HRP8!bJai_!u9N z8uR1hWq(=`QAKgkmeG!E`htLOIMSQ+QG}NaS}U3MH(9RFixli}d=&5wj`N@scv(>R zNhmWNRIT;tWl>PwZ(M!9{E+{p`kN5dtW}mwG{bBXX|VO|Atn29gS&mm9Vy>wmsEmj zG4}{&KQ0Y9LA~b!A3iO@}{?iv8bR^Zi0OyI+}3WttOwB;aXyr~pT0Mu0dk zff#foz)qoo)0+}Kn?5Q;xp2JHTWc~jeL0gFud+MEvOT#x!SxjlaxFq!LcLhh@}5(y z&G&Q`kHHvqC@#xzdfa4;^M3`K1Z4Z-QV!C%R#YfIFJQAVok^2w4DrU+NpozoVzU*q zB#9Xxpr*~X(=_9b9i-|+2R&N^(nbb0pURwWQ1dC8ZlO~#GNyB;qp4aB(@`&qlxlxcj!uC5DM(ccW zF)s(B7>)FC z>RZ==RM{jy3hq;9_(^sEjQd@1;Hp%?(#@F2iZsKBDTj$LsV?RYfEl20TeyNmT26m# zCOnPT`fC4ZYlZ$TyM9r-zCZ#OGw7AD7%>QvOm@?h*MLZM(V7y=P!X#$|& zKH0;Xw}uq)ph<-YvVBQZ;E9 zGS+kSe#RVI_KEWEJsRbQoR4UshC0EA1x-%ZV!XM$_2MFO5&v)4k#uj0^FKG!W|L>b zB6ZQuX7zEbIHq9SQY;QBu*!}MhJ1WAc?bpog@VBlBS!{f*%H&{K1+ct1 z9!ss) z^Z&nxKliW1YwNwjkJx}L)bzZ$xZ2qzy( zzoGzHAQ>GxmS9rGuo~a}g^llaBA5ZGp z!)$8R>t|7i7TG=`qNLn!L7oCd0=g(sCZwARRcb`U)aju?LP|!H7QM80&bqWJ`_Kgs z0U`igkRSuX4FxJRAYkb5zyJXSg9!^>*qgI#S|FKgthFtYn4@czlPOYh#>+$uI4e`$7-p_Uy+4zgT*?pQ%Fs89&;WYD JJAeQH006?Tw50$5 literal 0 HcmV?d00001 diff --git a/docs/content/styles.css b/docs/content/styles.css new file mode 100644 index 00000000..e69de29b diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..d10602b6 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deities/docs", + "version": "0.0.1", + "private": true, + "description": "Athena Crisis Open Source Docs & Playground", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "scripts": { + "build": "vocs build", + "dev": "vocs dev --port 3003", + "preview": "vocs preview" + }, + "dependencies": { + "@deities/apollo": "workspace:*", + "@deities/art": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hera": "workspace:*", + "@deities/hermes": "workspace:*", + "@deities/ui": "workspace:*", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/css": "^11.11.2", + "@types/react": "^18.3.1", + "dunkel-theme": "^1.7.1", + "fbt": "^1.0.2", + "framer-motion": "^11.1.9", + "licht-theme": "^1.7.1", + "react": "19.0.0-canary-fd0da3eef-20240404", + "react-dom": "19.0.0-canary-fd0da3eef-20240404", + "vocs": "1.0.0-alpha.51" + } +} diff --git a/docs/vocs.config.tsx b/docs/vocs.config.tsx new file mode 100644 index 00000000..a70893ff --- /dev/null +++ b/docs/vocs.config.tsx @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs'; +import babelPluginEmotion from '@emotion/babel-plugin'; +import react from '@vitejs/plugin-react'; +import React from 'react'; +import { defineConfig } from 'vocs'; +import babelFbtPlugins from '../infra/babelFbtPlugins.tsx'; +import resolver from '../infra/resolver.tsx'; + +const Licht = JSON.parse( + readFileSync('./node_modules/licht-theme/licht.json', 'utf8'), +); +const Dunkel = JSON.parse( + readFileSync('./node_modules/dunkel-theme/dunkel.json', 'utf8'), +); + +export default defineConfig({ + basePath: '/open-source', + baseUrl: 'https://athenacrisis.com/open-source', + description: 'Open Source Docs & Playground', + editLink: { + pattern: + 'https://github.com/nkzw-tech/athena-crisis/tree/main/docs/content/pages/:path', + text: 'Edit on GitHub', + }, + font: { + mono: { + google: 'Fira Code', + }, + }, + head: ( + <> + + + + ), + iconUrl: '/favicon.png', + markdown: { + code: { + themes: { + dark: Dunkel, + light: Licht, + }, + }, + }, + ogImageUrl: + 'https://vocs.dev/api/og?logo=%logo&title=%title&description=%description', + rootDir: './content', + sidebar: [ + { + link: '/getting-started', + text: 'Getting Started', + }, + { + items: [ + { + link: '/core-concepts/overview', + text: 'Overview', + }, + { + link: '/core-concepts/immutable-data-structures', + text: 'Immutable Data Structures', + }, + { + link: '/core-concepts/map-data', + text: 'MapData ', + }, + { + link: '/core-concepts/actions', + text: 'Actions ', + }, + ], + text: 'Core Concepts', + }, + { + items: [ + { + link: '/playground/map-editor', + text: 'Map Editor', + }, + ], + text: 'Playground', + }, + ], + socials: [ + { + icon: 'github', + link: 'https://github.com/nkzw-tech/athena-crisis', + }, + { + icon: 'discord', + link: 'https://discord.gg/2VBCCep7Fk', + }, + { + icon: 'x', + link: 'https://twitter.com/TheAthenaCrisis', + }, + ], + theme: { + accentColor: '#c3217f', + }, + title: 'Athena Crisis', + topNav: [ + { link: '/core-concepts/overview', match: '/core-concepts', text: 'Docs' }, + { + link: 'https://store.steampowered.com/app/2456430/Athena_Crisis/', + text: 'AC on Steam', + }, + { + items: [ + { + link: 'https://nkzw.tech', + text: 'Nakazawa Tech', + }, + { + link: 'https://null.com', + text: 'Null', + }, + ], + text: 'More', + }, + ], + vite: { + define: { + 'process.env.IS_LANDING_PAGE': `1`, + }, + plugins: [ + react({ + babel: { + plugins: [...babelFbtPlugins, babelPluginEmotion], + }, + }), + ], + resolve: { + alias: [resolver], + }, + }, +}); diff --git a/eslint-plugin/index.js b/eslint-plugin/index.js new file mode 100644 index 00000000..d9372828 --- /dev/null +++ b/eslint-plugin/index.js @@ -0,0 +1,24 @@ +module.exports = { + configs: { + strict: { + rules: { + '@deities/no-copy-expression': 2, + '@deities/no-date-now': 2, + '@deities/no-fbt-import': 2, + '@deities/no-inline-css': 2, + '@deities/no-lazy-import': 2, + '@deities/require-fbt-description': 2, + '@deities/use-mutation-types': 2, + }, + }, + }, + rules: { + 'no-copy-expression': require('./no-copy-expression'), + 'no-date-now': require('./no-date-now'), + 'no-fbt-import': require('./no-fbt-import'), + 'no-inline-css': require('./no-inline-css'), + 'no-lazy-import': require('./no-lazy-import'), + 'require-fbt-description': require('./require-fbt-description'), + 'use-mutation-types': require('./use-mutation-types'), + }, +}; diff --git a/eslint-plugin/no-copy-expression.js b/eslint-plugin/no-copy-expression.js new file mode 100644 index 00000000..5baddc0e --- /dev/null +++ b/eslint-plugin/no-copy-expression.js @@ -0,0 +1,23 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function noCopyExpression(context) { + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'copy' && + node.parent.type === 'ExpressionStatement' + ) { + context.report({ + message: `'copy' calls are side-effect free. Did you forgot to assign the result of this call?`, + node, + }); + } + }, + }; +}; diff --git a/eslint-plugin/no-date-now.js b/eslint-plugin/no-date-now.js new file mode 100644 index 00000000..596f70cc --- /dev/null +++ b/eslint-plugin/no-date-now.js @@ -0,0 +1,24 @@ +module.exports = { + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.name === 'Date' && + node.callee.property.name === 'now' + ) { + context.report({ + fix(fixer) { + return fixer.replaceText(node, 'dateNow()'); + }, + message: `Use 'dateNow()' from the 'ServerTime' module instead of 'Date.now()'.`, + node, + }); + } + }, + }; + }, + meta: { + fixable: 'code', + }, +}; diff --git a/eslint-plugin/no-fbt-import.js b/eslint-plugin/no-fbt-import.js new file mode 100644 index 00000000..8b4889ed --- /dev/null +++ b/eslint-plugin/no-fbt-import.js @@ -0,0 +1,23 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function noFbtImport(context) { + return { + ImportDefaultSpecifier(node) { + if ( + node.local.type === 'Identifier' && + node.local.name === 'fbt' && + node.parent.source.type === 'Literal' && + node.parent.source.value === 'fbt' + ) { + context.report({ + message: `You must use "import { fbt } from 'fbt'"; instead of a default import.`, + node, + }); + } + }, + }; +}; diff --git a/eslint-plugin/no-inline-css.js b/eslint-plugin/no-inline-css.js new file mode 100644 index 00000000..a11bb61a --- /dev/null +++ b/eslint-plugin/no-inline-css.js @@ -0,0 +1,36 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function noInlineCSS(context) { + return { + TaggedTemplateExpression(node) { + const parent = node.parent; + const nodeToCheck = + (parent?.type === 'ArrowFunctionExpression' && parent.body === node) || + (parent?.parent?.parent?.type === 'ExportNamedDeclaration' && + parent.init === node) + ? parent + : parent.type === 'Property' && + parent.value === node && + parent.parent?.type === 'ObjectExpression' + ? parent.parent + : node; + + if ( + node.tag.type === 'Identifier' && + node.tag.name === 'css' && + nodeToCheck.parent?.parent?.type !== 'Program' && + nodeToCheck.parent?.parent?.parent?.type !== 'Program' + ) { + context.report({ + message: + '`css` template literals can only be used at the module top-level.', + node, + }); + } + }, + }; +}; diff --git a/eslint-plugin/no-lazy-import.js b/eslint-plugin/no-lazy-import.js new file mode 100644 index 00000000..290447a3 --- /dev/null +++ b/eslint-plugin/no-lazy-import.js @@ -0,0 +1,23 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function noFbtImport(context) { + return { + ImportDeclaration(node) { + if (node.source.value === 'react') { + for (const specifier of node.specifiers) { + if (specifier.imported && specifier.imported.name === 'lazy') { + context.report({ + message: `Importing 'lazy' from 'react' is forbidden. Use '@deities/ui/lib/lazy.tsx' instead.`, + node: specifier, + }); + break; + } + } + } + }, + }; +}; diff --git a/eslint-plugin/package.json b/eslint-plugin/package.json new file mode 100644 index 00000000..110ec7ad --- /dev/null +++ b/eslint-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@deities/eslint-plugin", + "version": "0.0.1", + "private": true, + "description": "Athena Crisis eslint plugins.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "main": "./index.js" +} diff --git a/eslint-plugin/require-fbt-description.js b/eslint-plugin/require-fbt-description.js new file mode 100644 index 00000000..f7d4df01 --- /dev/null +++ b/eslint-plugin/require-fbt-description.js @@ -0,0 +1,30 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function noInlineCSS(context) { + return { + JSXOpeningElement(node) { + if (node.name?.type === 'JSXIdentifier' && node.name.name === 'fbt') { + const descriptionNode = node.attributes.find( + (node) => + node.name?.type === 'JSXIdentifier' && node.name.name === 'desc', + ); + if ( + !descriptionNode || + (descriptionNode.type === 'JSXAttribute' && + (!descriptionNode.value || + descriptionNode.value.type !== 'Literal' || + !descriptionNode.value.value.length)) + ) { + context.report({ + message: '`fbt` elements must have a string description.', + node: descriptionNode, + }); + } + } + }, + }; +}; diff --git a/eslint-plugin/use-mutation-types.js b/eslint-plugin/use-mutation-types.js new file mode 100644 index 00000000..0c6f1f62 --- /dev/null +++ b/eslint-plugin/use-mutation-types.js @@ -0,0 +1,18 @@ +module.exports.meta = { + fixable: false, + hasSuggestions: false, + type: 'problem', +}; + +module.exports.create = function requireUseMutationType(context) { + return { + CallExpression(node) { + if (node.callee.name === 'useMutation' && !node.typeParameters) { + context.report({ + message: '`useMutation` calls must have type parameters.', + node, + }); + } + }, + }; +}; diff --git a/fixtures/package.json b/fixtures/package.json new file mode 100644 index 00000000..5c799e43 --- /dev/null +++ b/fixtures/package.json @@ -0,0 +1,17 @@ +{ + "name": "@deities/fixtures", + "version": "0.0.1", + "private": true, + "description": "Athena Crisis fixtures.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module", + "devDependencies": { + "@deities/apollo": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hermes": "workspace:*" + } +} diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit new file mode 100755 index 00000000..8763404a --- /dev/null +++ b/git-hooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') +[ -z "$FILES" ] && exit 0 +echo "$FILES" | xargs pnpm prettier --ignore-unknown --write +echo "$FILES" | xargs git add + +exit 0 diff --git a/hephaestus/UnknownTypeError.tsx b/hephaestus/UnknownTypeError.tsx new file mode 100644 index 00000000..4e83760c --- /dev/null +++ b/hephaestus/UnknownTypeError.tsx @@ -0,0 +1,5 @@ +export default class UnknownTypeError extends Error { + constructor(name: string, type: string) { + super(`${name}: Unknown type '${type}'.`); + } +} diff --git a/hephaestus/dateNow.tsx b/hephaestus/dateNow.tsx new file mode 100644 index 00000000..81754db8 --- /dev/null +++ b/hephaestus/dateNow.tsx @@ -0,0 +1,12 @@ +let timeDifference = 0; + +export const ONE_DAY = 1000 * 60 * 60 * 24; + +export function setTime(serverTime: number) { + timeDifference = serverTime - dateNow(); +} + +export default function dateNow() { + // eslint-disable-next-line @deities/no-date-now + return Date.now() + timeDifference; +} diff --git a/hephaestus/getFirst.tsx b/hephaestus/getFirst.tsx new file mode 100644 index 00000000..3fab1c14 --- /dev/null +++ b/hephaestus/getFirst.tsx @@ -0,0 +1,6 @@ +export default function getFirst(iterable: Iterable): T | null { + for (const item of iterable) { + return item; + } + return null; +} diff --git a/hephaestus/getFirstOrThrow.tsx b/hephaestus/getFirstOrThrow.tsx new file mode 100644 index 00000000..e67125d7 --- /dev/null +++ b/hephaestus/getFirstOrThrow.tsx @@ -0,0 +1,9 @@ +import getFirst from './getFirst.tsx'; + +export default function getFirstOrThrow(iterable: Iterable): T { + const entry = getFirst(iterable); + if (entry == null) { + throw new Error(`getFirstOrThrow: Expected at least one entry.`); + } + return entry; +} diff --git a/hephaestus/getOrThrow.tsx b/hephaestus/getOrThrow.tsx new file mode 100644 index 00000000..29773bbb --- /dev/null +++ b/hephaestus/getOrThrow.tsx @@ -0,0 +1,9 @@ +export default function getOrThrow(map: ReadonlyMap, key: K) { + const value = map.get(key); + if (!value) { + throw new Error( + `Could not find entry for key '${JSON.stringify(key, null, 2)}'.`, + ); + } + return value; +} diff --git a/hephaestus/groupBy.tsx b/hephaestus/groupBy.tsx new file mode 100644 index 00000000..0cdaf485 --- /dev/null +++ b/hephaestus/groupBy.tsx @@ -0,0 +1,18 @@ +export default function groupBy( + iterable: Iterable, + fn: (item: T) => S, +): Map> { + const map = new Map>(); + for (const item of iterable) { + const key = fn(item); + if (key != null) { + const items = map.get(key); + if (items) { + items.push(item); + } else { + map.set(key, [item]); + } + } + } + return map; +} diff --git a/hephaestus/isPositiveInteger.tsx b/hephaestus/isPositiveInteger.tsx new file mode 100644 index 00000000..f1d13ccc --- /dev/null +++ b/hephaestus/isPositiveInteger.tsx @@ -0,0 +1,3 @@ +export default function isPositiveInteger(number: unknown) { + return typeof number === 'number' && !Number.isNaN(number) && number > 0; +} diff --git a/hephaestus/isPresent.tsx b/hephaestus/isPresent.tsx new file mode 100644 index 00000000..6f5d9a9c --- /dev/null +++ b/hephaestus/isPresent.tsx @@ -0,0 +1,3 @@ +export default function isPresent(t: T | undefined | null | void): t is T { + return t !== undefined && t !== null; +} diff --git a/hephaestus/jenkinsHash.tsx b/hephaestus/jenkinsHash.tsx new file mode 100644 index 00000000..2312e9ab --- /dev/null +++ b/hephaestus/jenkinsHash.tsx @@ -0,0 +1,69 @@ +const toUtf8 = (str: string) => { + const result = []; + const len = str.length; + for (let i = 0; i < len; i++) { + let charcode = str.charCodeAt(i); + if (charcode < 0x80) { + result.push(charcode); + } else if (charcode < 0x8_00) { + result.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f)); + } else if (charcode < 0xd8_00 || charcode >= 0xe0_00) { + result.push( + 0xe0 | (charcode >> 12), + 0x80 | ((charcode >> 6) & 0x3f), + 0x80 | (charcode & 0x3f), + ); + } else { + i++; + charcode = + 0x1_00_00 + + (((charcode & 0x3_ff) << 10) | (str.charCodeAt(i) & 0x3_ff)); + result.push( + 0xf0 | (charcode >> 18), + 0x80 | ((charcode >> 12) & 0x3f), + 0x80 | ((charcode >> 6) & 0x3f), + 0x80 | (charcode & 0x3f), + ); + } + } + return result; +}; + +const _jenkinsHash = (str: string): number => { + if (!str) { + return 0; + } + + const utf8 = toUtf8(str); + let hash = 0; + const len = utf8.length; + for (let i = 0; i < len; i++) { + hash += utf8[i]; + hash = (hash + (hash << 10)) >>> 0; + hash ^= hash >>> 6; + } + + hash = (hash + (hash << 3)) >>> 0; + hash ^= hash >>> 11; + hash = (hash + (hash << 15)) >>> 0; + return hash; +}; + +const BaseNSymbols = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +const uintToBaseN = (number: number, base: number) => { + if (base < 2 || base > 62 || number < 0) { + return ''; + } + let output = ''; + do { + output = BaseNSymbols[number % base].concat(output); + number = Math.floor(number / base); + } while (number > 0); + return output; +}; + +export default function jenkinsHash(text: string): string { + return uintToBaseN(_jenkinsHash(text), 62); +} diff --git a/hephaestus/maxBy.tsx b/hephaestus/maxBy.tsx new file mode 100644 index 00000000..068be81c --- /dev/null +++ b/hephaestus/maxBy.tsx @@ -0,0 +1,8 @@ +export default function maxBy( + array: ReadonlyArray, + fn: (a: T) => number, +) { + return array.length + ? array.reduce((acc, val) => (fn(val) > fn(acc) ? val : acc)) + : undefined; +} diff --git a/hephaestus/minBy.tsx b/hephaestus/minBy.tsx new file mode 100644 index 00000000..b645067c --- /dev/null +++ b/hephaestus/minBy.tsx @@ -0,0 +1,8 @@ +export default function minBy( + array: ReadonlyArray, + fn: (a: T) => number, +) { + return array.length + ? array.reduce((acc, val) => (fn(val) < fn(acc) ? val : acc)) + : undefined; +} diff --git a/hephaestus/package.json b/hephaestus/package.json new file mode 100644 index 00000000..6f6c0478 --- /dev/null +++ b/hephaestus/package.json @@ -0,0 +1,11 @@ +{ + "name": "@deities/hephaestus", + "version": "0.0.1", + "description": "Hephaestus, god of blacksmiths, metalworking, carpenters, craftsmen, artisans, sculptors, metallurgy, fire, and volcanoes.", + "repository": { + "type": "git", + "url": "git://github.com/nkzw-tech/athena-crisis.git" + }, + "author": "Christoph Nakazawa ", + "type": "module" +} diff --git a/hephaestus/parseInteger.tsx b/hephaestus/parseInteger.tsx new file mode 100644 index 00000000..3bbd8917 --- /dev/null +++ b/hephaestus/parseInteger.tsx @@ -0,0 +1,4 @@ +export default function parseInteger(value: string): number | null { + const number = Number.parseInt(value, 10); + return Number.isNaN(number) ? null : number; +} diff --git a/hephaestus/random.tsx b/hephaestus/random.tsx new file mode 100644 index 00000000..16f41e20 --- /dev/null +++ b/hephaestus/random.tsx @@ -0,0 +1,3 @@ +export default function random(min: number, max: number) { + return Math.floor(min + Math.random() * (max - min + 1)); +} diff --git a/hephaestus/randomEntry.tsx b/hephaestus/randomEntry.tsx new file mode 100644 index 00000000..e512d6d2 --- /dev/null +++ b/hephaestus/randomEntry.tsx @@ -0,0 +1,5 @@ +import random from './random.tsx'; + +export default function randomEntry(array: ReadonlyArray): T { + return array.at(random(0, array.length - 1))!; +} diff --git a/hephaestus/sanitizeText.tsx b/hephaestus/sanitizeText.tsx new file mode 100644 index 00000000..e5f2fbf1 --- /dev/null +++ b/hephaestus/sanitizeText.tsx @@ -0,0 +1,12 @@ +export default function sanitizeText(text: string) { + return text + .replaceAll(/[\u2018\u2019]/g, `'`) + .replaceAll(/[\u201C\u201D]/g, `"`) + .replaceAll(/[\s\u200B\u200C\u2060]/g, ' ') + .replaceAll( + // eslint-disable-next-line no-misleading-character-class + /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]{5,}/g, + '', + ) + .trim(); +} diff --git a/hephaestus/sortBy.tsx b/hephaestus/sortBy.tsx new file mode 100644 index 00000000..8bddddf6 --- /dev/null +++ b/hephaestus/sortBy.tsx @@ -0,0 +1,3 @@ +export default function sortBy(array: Array, fn: (a: T) => number) { + return array.sort((a, b) => fn(a) - fn(b)); +} diff --git a/hephaestus/toSlug.tsx b/hephaestus/toSlug.tsx new file mode 100644 index 00000000..0e7b56b6 --- /dev/null +++ b/hephaestus/toSlug.tsx @@ -0,0 +1,7 @@ +export default function toSlug(text: string) { + return text + .replaceAll(/[^\da-z]/gi, ' ') + .trim() + .replaceAll(/(\s|-)+/gi, '-') + .toLocaleLowerCase(); +} diff --git a/hephaestus/toTag.tsx b/hephaestus/toTag.tsx new file mode 100644 index 00000000..7ac91b68 --- /dev/null +++ b/hephaestus/toTag.tsx @@ -0,0 +1,3 @@ +export default function toTag(text: string) { + return text.replaceAll(/[^\da-z-]/gi, '').toLocaleLowerCase(); +} diff --git a/hera/Building.tsx b/hera/Building.tsx new file mode 100644 index 00000000..3f5e9cea --- /dev/null +++ b/hera/Building.tsx @@ -0,0 +1,343 @@ +import { OilRig, Shipyard } from '@deities/athena/info/Building.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import { AnimationConfig } from '@deities/athena/map/Configuration.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import { applyVar, CSSVariables } from '@deities/ui/cssVar.tsx'; +import { css, cx, keyframes } from '@emotion/css'; +import { Sprites } from 'athena-crisis:images'; +import { memo, useEffect, useRef } from 'react'; +import Label from './Label.tsx'; +import getBuildingSpritePosition from './lib/getBuildingSpritePosition.tsx'; +import getFlashDelay from './lib/getFlashDelay.tsx'; +import sprite from './lib/sprite.tsx'; +import { BuildingAnimation } from './MapAnimations.tsx'; +import Tick from './Tick.tsx'; +import { RequestFrameFunction, TimerFunction } from './Types.tsx'; + +const defaultPosition = vec(1, 1); + +// This is using a const to give the component a name and +// prevents it from being auto-imported as `Building`. +const BuildingTile = memo(function BuildingTile({ + absolute, + animation, + animationConfig, + biome, + building, + fade, + highlight, + isVisible = true, + maybeOutline, + outline, + position = defaultPosition, + requestFrame, + scheduleTimer, + size, + zIndex, +}: { + absolute?: boolean; + animation?: BuildingAnimation; + animationConfig?: AnimationConfig; + biome: Biome; + building: Building; + fade?: boolean | null; + highlight?: boolean; + isVisible?: boolean; + maybeOutline?: boolean; + outline?: boolean; + position?: Vector; + requestFrame?: RequestFrameFunction; + scheduleTimer?: TimerFunction; + size: number; + zIndex?: number; +}) { + if (isVisible === false) { + building = building.hide(biome); + } + + const { x, y } = position; + const { info, player } = building; + const [spritePositionX, spritePositionY] = getBuildingSpritePosition( + info, + player, + biome, + isVisible, + ); + const isBeingCreated = animation?.type === 'createBuilding'; + const positionX = (x - 1) * size; + const positionY = (y - 2) * size + (isBeingCreated ? size / 6 : 0); + const height = size * 2 - (isBeingCreated ? size / 3 : 0); + const elementRef = useRef(null); + useEffect(() => { + if (isBeingCreated) { + if (!animationConfig || !scheduleTimer || !requestFrame) { + throw new Error( + `Building: 'scheduleTimer' or 'requestFrame' props are missing for building animation at '${position}'.`, + ); + } + + const duration = animationConfig.AnimationDuration * 0.8; + const animate = () => { + if (!elementRef.current) { + return; + } + const { style } = elementRef.current; + const pixelsPerStep = size / 6 / duration; + let animateStart: number | null = null; + let start: number | null = null; + + const step = (timestamp: number) => { + if (!animateStart) { + animateStart = timestamp; + } + if (!start) { + start = timestamp; + } + + const progress = Math.min(timestamp - start, duration); + style.height = height + progress * pixelsPerStep * 2 + 'px'; + style.setProperty( + vars.set('y'), + positionY - progress * pixelsPerStep + 'px', + ); + if (progress < duration) { + requestFrame(step); + } + }; + + requestFrame((timestamp: number) => { + start = null; + step(timestamp); + return; + }); + }; + + scheduleTimer(() => { + if (elementRef.current) { + elementRef.current.style.opacity = '1'; + } + }, animationConfig.ExplosionStep / 2); + + animate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animation]); + + const showHighlight = highlight && player > 0; + const isCompleted = building.isCompleted(); + + const buildingTile = ( +
+ {(building.id === Shipyard.id || building.id === OilRig.id) && ( +
+ )} +
+ ); + + return building.label != null ? ( + <> + {buildingTile} +
+
+ + ) : ( + buildingTile + ); +}); + +export default BuildingTile; + +const vars = new CSSVariables< + | 'brightness' + | 'drop-shadow-color' + | 'drop-shadow-size' + | 'saturation' + | 'skew' + | 'x' + | 'y' +>('b'); + +const absoluteStyle = css` + position: absolute; +`; + +const baseStyle = css` + ${vars.set('x', 0)} + ${vars.set('y', 0)} + + pointer-events: none; + transform: translate3d(${vars.apply('x')}, ${vars.apply('y')}, 0); +`; + +const baseBuildingStyle = css` + ${vars.set('brightness', 1.05)} + ${vars.set('drop-shadow-color', 'rgba(0, 0, 0, 0)')} + ${vars.set('drop-shadow-size', '0.5px')} + ${vars.set('saturation', 1)} + ${vars.set('skew', '0deg')} + + filter: brightness(${vars.apply('brightness')}) + saturate(${vars.apply('saturation')}); + transform: translate3d(${vars.apply('x')}, ${vars.apply('y')}, 0) + skewX(${vars.apply('skew')}); + transition: + filter calc(${applyVar('animation-duration')} / 2) ease-in-out, + opacity calc(${applyVar('animation-duration')} / 2) ease-in-out; +`; + +const maybeOutlineStyle = css` + filter: brightness(${vars.apply('brightness')}) + saturate(${vars.apply('saturation')}) + drop-shadow( + ${vars.apply('drop-shadow-size')} ${vars.apply('drop-shadow-size')} 0px + ${vars.apply('drop-shadow-color')} + ) + drop-shadow( + calc(-1 * ${vars.apply('drop-shadow-size')}) + calc(-1 * ${vars.apply('drop-shadow-size')}) 0px + ${vars.apply('drop-shadow-color')} + ) + drop-shadow( + calc(-1 * ${vars.apply('drop-shadow-size')}) + ${vars.apply('drop-shadow-size')} 0px ${vars.apply('drop-shadow-color')} + ) + drop-shadow( + ${vars.apply('drop-shadow-size')} + calc(-1 * ${vars.apply('drop-shadow-size')}) 0px + ${vars.apply('drop-shadow-color')} + ); +`; + +const completedStyle = css` + ${vars.set('saturation', 0.1)} +`; + +const darkCompletedStyle = css` + ${vars.set('brightness', 0.8)} +`; + +const brightStyle = css` + ${vars.set('drop-shadow-color', 'rgb(255, 255, 255)')} + ${vars.set('brightness', 1.3)} +`; + +const outlineStyle = css` + ${vars.set('drop-shadow-color', 'rgb(210, 18, 24)')} +`; + +const fadeStyle = css` + mask-image: linear-gradient( + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.5) 45%, + rgba(0, 0, 0, 1) 65% + ); + mask-type: alpha; +`; + +const attackFlashStyle = css` + animation-delay: ${applyVar('animation-duration-70')}; + animation-duration: ${applyVar('animation-duration-30')}; + animation-iteration-count: 1; + animation-name: ${keyframes` + 0% { + ${vars.set('skew', '0deg')} + opacity: 1; + } + 33% { + ${vars.set('skew', '2.5deg')} + } + 50% { + filter: saturate(0.5); + opacity: 0.5; + } + 66% { + ${vars.set('skew', '-2.5deg')} + } + 100% { + ${vars.set('skew', '0deg')} + opacity: 1; + } +`}; + animation-timing-function: linear; + transform-origin: bottom center; +`; + +const captureStyle = css` + animation-delay: calc(${applyVar('animation-duration')} * 1.5); + animation-duration: ${applyVar('animation-duration-30')}; + animation-iteration-count: 2; + animation-name: ${keyframes` + 0% { + ${vars.set('skew', '0deg')} + } + 33% { + ${vars.set('skew', '2.5deg')} + } + 66% { + ${vars.set('skew', '-2.5deg')} + } + 100% { + ${vars.set('skew', '0deg')} + } +`}; + animation-timing-function: linear; + transform-origin: bottom center; +`; diff --git a/hera/Cursor.tsx b/hera/Cursor.tsx new file mode 100644 index 00000000..38e9cfea --- /dev/null +++ b/hera/Cursor.tsx @@ -0,0 +1,82 @@ +import Vector from '@deities/athena/map/Vector.tsx'; +import { CSSVariables } from '@deities/ui/cssVar.tsx'; +import { css, cx } from '@emotion/css'; +import { Sprites } from 'athena-crisis:images'; +import React, { memo, useCallback, useRef, useState } from 'react'; +import { useTick } from './lib/tick.tsx'; + +export default memo(function Cursor({ + color, + paused, + position, + size, + zIndex, +}: { + color?: 'red' | null; + paused?: boolean; + position: Vector | null; + size: number; + zIndex: number; +}) { + const [previousPosition, setPreviousPosition] = useState(null); + const [transform, setTransform] = useState(''); + + // Keep track of the previous position so that the cursor doesn't flash + // on (1|1) when leaving and re-entering the Map. + if (previousPosition !== position) { + setPreviousPosition(position); + + const currentPosition = position || previousPosition; + setTransform( + currentPosition + ? `translate3d(${(currentPosition.x - 1) * size - 1}px, ${ + (currentPosition.y - 1) * size - 1 + }px, 0)` + : '', + ); + } + + const ref = useRef(null); + useTick( + paused, + useCallback((tick: number) => { + ref.current?.style.setProperty(vars.set('tick'), String(tick % 4)); + }, []), + [], + ); + + return ( +
+ ); +}); + +const vars = new CSSVariables<'tick' | 'background-position-y' | 'size'>('c'); + +const baseStyle = css` + ${vars.set('background-position-y', 0)} + ${vars.set('size', '26px')} + ${vars.set('tick', 0)} + + background-image: url('${Sprites.Cursor}'); + background-position: calc(${vars.apply('tick')} * ${vars.apply('size')} * -1) + ${vars.apply('background-position-y')}; + height: ${vars.apply('size')}; + pointer-events: none; + position: absolute; + transition: opacity 250ms ease-in-out; + width: ${vars.apply('size')}; +`; + +const colors = { + red: css` + ${vars.set('background-position-y', `calc(${vars.apply('size')} * -1)`)} + `, +}; diff --git a/hera/Decorators.tsx b/hera/Decorators.tsx new file mode 100644 index 00000000..970504db --- /dev/null +++ b/hera/Decorators.tsx @@ -0,0 +1,159 @@ +import { spriteImage } from '@deities/art/Sprites.tsx'; +import { DecoratorInfo } from '@deities/athena/info/Decorator.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { DecoratorsPerSide } from '@deities/athena/map/Configuration.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { CSSVariables } from '@deities/ui/cssVar.tsx'; +import useVisibilityState from '@deities/ui/hooks/useVisibilityState.tsx'; +import { css, cx } from '@emotion/css'; +import { memo, useLayoutEffect, useRef } from 'react'; +import { useSprites } from './hooks/useSprites.tsx'; +import tick, { getFrame, getTick } from './lib/tick.tsx'; + +const renderDecorator = ( + context: CanvasRenderingContext2D, + image: CanvasImageSource, + frame: number, + decorator: DecoratorInfo, + vector: Vector, + size: number, + biome: Biome, +) => { + const targetX = (vector.x * size) / DecoratorsPerSide + size / 2 - 1; + const targetY = (vector.y * size) / DecoratorsPerSide; + const { x, y } = decorator.position; + const biomeStyle = decorator.biomeStyle?.get(biome); + context.drawImage( + image, + (x + (biomeStyle?.x || 0) + frame) * size, + (y + (biomeStyle?.y || 0)) * size, + size, + size, + targetX, + targetY, + size, + size, + ); +}; + +export default memo(function Decorators({ + aboveFog, + dim, + map, + outline, + paused, + tileSize: size, +}: { + aboveFog?: boolean; + dim?: boolean; + map: MapData; + outline?: boolean; + paused?: boolean; + tileSize: number; +}) { + const ref = useRef(null); + const hasSprites = useSprites('all'); + const { biome } = map.config; + const isVisible = useVisibilityState(); + + useLayoutEffect(() => { + if (!hasSprites || !isVisible || !ref.current) { + return; + } + + const image = spriteImage('Decorators', biome); + const context = ref.current.getContext('2d')!; + context.clearRect(0, 0, ref.current.width, ref.current.height); + + const currentTick = getTick(); + const animatedDecorators = new Map(); + map.forEachDecorator((decorator, vector) => { + renderDecorator( + context, + image, + (!paused && getFrame(decorator, 0, currentTick)) || 0, + decorator, + vector, + size, + map.config.biome, + ); + + if (decorator.animation) { + animatedDecorators.set(vector, decorator); + } + }); + + if (!paused && isVisible && animatedDecorators.size) { + return tick((tick) => { + animatedDecorators.forEach( + (decorator: DecoratorInfo, vector: Vector) => { + const frame = getFrame(decorator, 0, tick); + if (frame != null) { + renderDecorator( + context, + image, + frame, + decorator, + vector, + size, + map.config.biome, + ); + } + }, + ); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [biome, hasSprites, isVisible, map.size, map.decorators, paused, size]); + + return ( +
+ +
+ ); +}); + +const vars = new CSSVariables('d'); +const style = css` + pointer-events: none; + position: absolute; + z-index: 2; +`; + +const aboveFogStyle = css` + z-index: 10; +`; + +const dimStyle = css` + opacity: 0.7; +`; + +const outlineStyle = css` + ${vars.set('drop-shadow-color', 'rgb(210, 18, 24)')} + + filter: drop-shadow(0.75px 0.75px 0px ${vars.apply('drop-shadow-color')}) + drop-shadow(-0.75px -0.75px 0px ${vars.apply('drop-shadow-color')}) + drop-shadow(-0.75px 0.75px 0px ${vars.apply('drop-shadow-color')}) + drop-shadow(0.75px -0.75px 0px ${vars.apply('drop-shadow-color')}); +`; diff --git a/hera/Fog.tsx b/hera/Fog.tsx new file mode 100644 index 00000000..b71d9f34 --- /dev/null +++ b/hera/Fog.tsx @@ -0,0 +1,136 @@ +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { isSafari } from '@deities/ui/Browser.tsx'; +import { css, cx } from '@emotion/css'; +import { memo, useLayoutEffect, useRef } from 'react'; +import { TileStyle } from './Tiles.tsx'; + +export default memo(function CanvasFog({ + fogStyle, + map, + style, + tileSize: size, + vision, + zIndex, +}: { + fogStyle?: 'soft' | 'hard'; + map: MapData; + style: TileStyle; + tileSize: number; + vision: VisionT; + zIndex: number; +}) { + const offset = 8; + const mainRef = useRef(null); + const darkRef = useRef(null); + useLayoutEffect(() => { + const canvas = mainRef.current; + if (!canvas) { + return; + } + + const context = canvas.getContext('2d')!; + context.fillStyle = 'rgba(0, 0, 0, 1)'; + if (fogStyle === 'soft') { + // Fill the edges of the canvas with black so that fog is not washed out at the edges. + context.fillRect(0, 0, canvas.width, canvas.height); + } else { + context.fillRect(3, 3, canvas.width - 6, canvas.height - 2); + } + + map.forEachField((vector: Vector) => { + if (vision.isVisible(map, vector)) { + context.clearRect( + offset + (vector.x - 1) * size, + offset + (vector.y - 1) * size, + size, + size, + ); + } + }); + + const darkCanvas = darkRef.current; + if (darkCanvas) { + const darkContext = darkCanvas.getContext('2d')!; + darkContext.clearRect(0, 0, darkCanvas.width, darkCanvas.height); + darkContext.drawImage(canvas, 0, 0); + } + }, [fogStyle, map, size, vision]); + + if (!map.config.fog) { + return null; + } + + return ( +
+ {!isSafari && ( + + )} + +
+ ); +}); + +const overflowHiddenStyle = css` + overflow: hidden; +`; + +const containerStyle = css` + pointer-events: none; + position: absolute; +`; + +const mode = isSafari + ? ` +mix-blend-mode: multiply; +opacity: 0.66; +` + : ` +mix-blend-mode: saturation; +`; + +const canvasStyle = css` + ${mode} + + position: absolute; +`; + +const darkenCanvasStyle = css` + mix-blend-mode: multiply; + opacity: 0.25; + position: absolute; +`; + +const blurStyle = css` + filter: blur(6px); +`; diff --git a/hera/GameMap.tsx b/hera/GameMap.tsx new file mode 100644 index 00000000..68642b73 --- /dev/null +++ b/hera/GameMap.tsx @@ -0,0 +1,1901 @@ +import { Action, execute } from '@deities/apollo/Action.tsx'; +import { ActionResponse } from '@deities/apollo/ActionResponse.tsx'; +import getActionResponseVectors from '@deities/apollo/lib/getActionResponseVectors.tsx'; +import updateVisibleEntities from '@deities/apollo/lib/updateVisibleEntities.tsx'; +import { + GameActionResponse, + GameActionResponses, +} from '@deities/apollo/Types.tsx'; +import dropLabels from '@deities/athena/lib/dropLabels.tsx'; +import getAverageVector from '@deities/athena/lib/getAverageVector.tsx'; +import getDecoratorsAtField from '@deities/athena/lib/getDecoratorsAtField.tsx'; +import getFirstHumanPlayer from '@deities/athena/lib/getFirstHumanPlayer.tsx'; +import getUnitsByPositions from '@deities/athena/lib/getUnitsByPositions.tsx'; +import isPvP from '@deities/athena/lib/isPvP.tsx'; +import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; +import { + AnimationConfig, + DoubleSize, + FastAnimationConfig, + MaxHealth, + MaxSize, + SlowAnimationConfig, + TileSize, +} from '@deities/athena/map/Configuration.tsx'; +import { PlayerID, toPlayerID } from '@deities/athena/map/Player.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector, { + sortByVectorKey, + VectorLike, +} from '@deities/athena/map/Vector.tsx'; +import type MapData from '@deities/athena/MapData.tsx'; +import { RadiusItem } from '@deities/athena/Radius.tsx'; +import { winConditionHasVectors } from '@deities/athena/WinConditions.tsx'; +import dateNow from '@deities/hephaestus/dateNow.tsx'; +import parseInteger from '@deities/hephaestus/parseInteger.tsx'; +import AudioPlayer from '@deities/ui/AudioPlayer.tsx'; +import { isIOS } from '@deities/ui/Browser.tsx'; +import Input, { NavigationDirection } from '@deities/ui/controls/Input.tsx'; +import { rumbleEffect } from '@deities/ui/controls/setupGamePad.tsx'; +import throttle from '@deities/ui/controls/throttle.tsx'; +import cssVar, { applyVar, CSSVariables } from '@deities/ui/cssVar.tsx'; +import { ScrollRestore } from '@deities/ui/hooks/useScrollRestore.tsx'; +import Icon from '@deities/ui/Icon.tsx'; +import Heart from '@deities/ui/icons/Heart.tsx'; +import Magic from '@deities/ui/icons/Magic.tsx'; +import scrollToCenter from '@deities/ui/lib/scrollToCenter.tsx'; +import { ScrollContainerClassName } from '@deities/ui/ScrollContainer.tsx'; +import Stack from '@deities/ui/Stack.tsx'; +import { css, cx, keyframes } from '@emotion/css'; +import ImmutableMap from '@nkzw/immutable-map'; +import { AnimatePresence } from 'framer-motion'; +import React, { + Component, + createRef, + MutableRefObject, + PointerEvent as ReactPointerEvent, +} from 'react'; +import processActionResponses from './action-response/processActionResponse.tsx'; +import getHealthColor from './behavior/attack/getHealthColor.tsx'; +import BaseBehavior from './behavior/Base.tsx'; +import { resetBehavior, setBaseClass } from './behavior/Behavior.tsx'; +import MenuBehavior from './behavior/Menu.tsx'; +import NullBehavior from './behavior/NullBehavior.tsx'; +import Cursor from './Cursor.tsx'; +import { EditorState } from './editor/Types.tsx'; +import addEndTurnAnimations from './lib/addEndTurnAnimations.tsx'; +import animateSupply from './lib/animateSupply.tsx'; +import isInView from './lib/isInView.tsx'; +import maskClassName, { MaskPointerClassName } from './lib/maskClassName.tsx'; +import sleep from './lib/sleep.tsx'; +import throwActionError from './lib/throwActionError.tsx'; +import MapComponent from './Map.tsx'; +import { Animation, Animations, MapAnimations } from './MapAnimations.tsx'; +import Mask from './Mask.tsx'; +import MaskWithSubtiles from './MaskWithSubtiles.tsx'; +import Radius, { RadiusType } from './Radius.tsx'; +import { + Actions, + ActionsProcessedEventDetail, + AnimationConfigs, + GameInfoState, + GetLayerFunction, + MapBehavior, + MapEnterType, + Props, + State, + StateLike, + TimerID, + TimerState, +} from './Types.tsx'; +import FlashFlyout from './ui/FlashFlyout.tsx'; +import { FlyoutItem } from './ui/Flyout.tsx'; +import GameDialog from './ui/GameDialog.tsx'; + +setBaseClass(BaseBehavior); + +const baseZIndex = 20; +const liveInterval = 15_000; +const waitingInterval = 60_000; + +const layerOffset = 6; +const layerOffsets = { + /* eslint-disable sort-keys-fix/sort-keys-fix */ + building: 0, + radius: 1, + decorator: 2, + unit: 3, + animation: 4, + top: 5, + /* eslint-enable sort-keys-fix/sort-keys-fix */ +} as const; + +const getLayer: GetLayerFunction = (y, type) => + baseZIndex + y * layerOffset + layerOffsets[type]; + +const hasShake = (animations: Animations) => + animations.some(({ type }) => type === 'shake'); + +const spectatorCodeToPlayerID = (spectatorCode: string) => { + const maybePlayerID = parseInteger(spectatorCode.split('-')[0]); + return maybePlayerID ? toPlayerID(maybePlayerID) : null; +}; + +const getVision = ( + map: MapData, + currentViewer: PlayerID | null, + spectatorCodes: ReadonlyArray | undefined, +) => + map.createVisionObject( + currentViewer || + (spectatorCodes?.length && spectatorCodeToPlayerID(spectatorCodes[0])) || + (!isPvP(map) && getFirstHumanPlayer(map)?.id) || + 0, + ); + +const showNamedPositionsForBehavior = new Set([ + 'base', + 'design', + 'entity', + 'move', + 'null', + 'vector', +]); + +const getWinConditionRadius = ( + { config: { winConditions } }: MapData, + isEditor: boolean, +) => { + let id = 0; + return winConditions.flatMap((condition) => { + if ((!isEditor && condition.hidden) || !winConditionHasVectors(condition)) { + return []; + } + id = (id + 1) % 3; + return { + fields: new Map( + [...condition.vectors].map((vector) => [vector, RadiusItem(vector)]), + ), + path: [], + type: + id === 0 + ? RadiusType.Escort1 + : id === 1 + ? RadiusType.Escort2 + : RadiusType.Escort3, + }; + }); +}; + +const getInlineUIState = (map: MapData, tileSize: number, scale: number) => + !isIOS && + window.innerWidth > scale * (map.size.width + 2) * tileSize && + window.innerHeight > scale * (map.size.height + 4) * tileSize; + +const getInitialState = (props: Props) => { + const { + behavior: baseBehavior, + buildingSize, + currentUserId, + editor, + lastActionResponse, + lastActionTime, + map, + mapName, + paused, + scale, + spectatorCodes, + tileSize, + timeout, + unitSize, + } = props; + const isEditor = !!editor; + const currentViewer = map.getPlayerByUserId(currentUserId)?.id || null; + const behavior: MapBehavior | null = + lastActionResponse?.type === 'GameEnd' + ? new NullBehavior() + : baseBehavior === null + ? null + : baseBehavior + ? new baseBehavior() + : new BaseBehavior(); + + const newState = { + additionalRadius: null, + animationConfig: + (Array.isArray(props.animationConfig) + ? props.animationConfig[map.getCurrentPlayer().isHumanPlayer() ? 1 : 0] + : props.animationConfig) || AnimationConfig, + animations: ImmutableMap() as Animations, + attackable: null, + behavior, + buildingSize, + confirmAction: null, + currentUserId, + currentViewer, + factionNames: props.factionNames, + gameInfoState: null, + initialBehaviorClass: baseBehavior, + inlineUI: getInlineUIState(map, tileSize, scale), + lastActionResponse: lastActionResponse || null, + lastActionTime: lastActionTime || undefined, + map: isEditor ? map : dropLabels(map), + mapName, + namedPositions: null, + navigationDirection: null, + paused: paused || false, + position: null, + preventRemoteActions: false, + previousPosition: null, + radius: null, + replayState: { + isLive: false, + isPaused: false, + isReplaying: false, + isWaiting: false, + pauseStart: null, + }, + selectedAttackable: null, + selectedBuilding: null, + selectedPosition: null, + selectedUnit: null, + showCursor: props.showCursor, + tileSize, + timeout: timeout || null, + unitSize, + vision: getVision(map, currentViewer, spectatorCodes), + winConditionRadius: getWinConditionRadius(map, isEditor), + zIndex: getLayer(map.size.height + 1, 'top') + 10, + }; + return { + ...newState, + ...behavior?.activate?.(newState), + }; +}; + +const getScale = (scale: number | undefined, element: HTMLElement) => + scale || + parseInteger(getComputedStyle(element).getPropertyValue(cssVar('scale'))) || + 2; + +type NativeTimeout = ReturnType | null; + +export default class GameMap extends Component { + static defaultProps = { + buildingSize: TileSize, + confirmActionStyle: 'touch', + scroll: true, + showCursor: true, + tileSize: TileSize, + unitSize: TileSize, + }; + + private _actionQueue: Promise | null = null; + private _actions: Actions; + private _animationConfigs: AnimationConfigs; + private _controlListeners: Array<() => void> = []; + private _isFastForward = { current: false }; + private _isTouch = { current: false }; + private _lastEnteredPosition: Vector | null = null; + private _liveTimer: NativeTimeout = null; + private _maskRef = createRef(); + private _pointerEnabled = true; + private _pointerLock = { current: false }; + private _pointerPosition: { + clientX: number; + clientY: number; + distance: number; + initial: boolean; + } | null = null; + private _releasePointerLock: NativeTimeout = null; + private _resolvers: Array<() => void>; + private _timers: Set; + private _waitingTimer: NativeTimeout = null; + private _wrapperRef = createRef(); + + constructor(props: Props) { + super(props); + + this.state = getInitialState(props); + const animationConfig = props.animationConfig as AnimationConfig; + this._animationConfigs = Array.isArray(props.animationConfig) + ? (props.animationConfig as AnimationConfigs) + : [ + animationConfig || AnimationConfig, + animationConfig || AnimationConfig, + animationConfig || FastAnimationConfig, + animationConfig || FastAnimationConfig, + ]; + this._timers = new Set(); + this._resolvers = []; + this._actions = { + action: this._action, + clearTimer: this._clearTimer, + fastForward: this._fastForward, + optimisticAction: this._optimisticAction, + pauseReplay: this._pauseReplay, + processGameActionResponse: this.processGameActionResponse, + requestFrame: this._requestFrame, + resetPosition: this._resetPosition, + resumeReplay: this._resumeReplay, + scheduleTimer: this._scheduleTimer, + scrollIntoView: this._scrollIntoView, + setEditorState: this._updateEditorState, + showGameInfo: this._showGameInfo, + update: this._update, + }; + } + + static getDerivedStateFromProps(props: Props, state: State) { + let newState: State | null = null; + if ('behavior' in props && props.behavior !== state.initialBehaviorClass) { + const BehaviorClass = props.behavior; + const behavior = BehaviorClass ? new BehaviorClass() : null; + newState = { + ...(newState || state), + behavior, + initialBehaviorClass: BehaviorClass, + ...state.behavior?.deactivate?.(), + }; + newState = { + ...newState, + ...behavior?.activate?.(newState as State), + }; + } + + if ('scale' in props) { + newState = { + ...(newState || state), + inlineUI: getInlineUIState(state.map, state.tileSize, props.scale), + }; + } + + if ('paused' in props && props.paused !== state.paused) { + newState = { + ...(newState || state), + paused: !!props.paused, + }; + } + + if (props.dangerouslyApplyExternalState) { + newState = { + ...(newState || state), + map: props.editor ? props.map : dropLabels(props.map), + vision: getVision( + props.map, + (newState || state).currentViewer, + props.spectatorCodes, + ), + }; + } + + return newState; + } + + override componentDidMount() { + const { + props: { editor, events, inset, onAction, scroll }, + state: { animationConfig, behavior, lastActionResponse, map, paused }, + } = this; + + if (scroll) { + const container = + (inset && + this._maskRef.current?.closest(`.${ScrollContainerClassName}`)) || + window; + if (container === window) { + (this.context as MutableRefObject).current = true; + } + scrollToCenter(container); + } + + this._actionQueue = Promise.resolve().then(async () => { + if (!paused && !editor && onAction && behavior?.type === 'base') { + if (!lastActionResponse) { + await this._update(resetBehavior(NullBehavior)); + await this.processGameActionResponse( + await onAction({ type: 'Start' }), + ); + } else if (lastActionResponse.type === 'EndTurn') { + await sleep(this._scheduleTimer, animationConfig, 'long'); + const { funds, id: player } = map.getCurrentPlayer(); + await new Promise((resolve) => + this._update({ + ...addEndTurnAnimations( + this._actions, + { + current: { funds, player }, + next: { funds, player }, + round: map.round, + type: 'EndTurn', + }, + this.state, + (state) => { + resolve(); + return { + ...state, + ...resetBehavior(this.props.behavior), + }; + }, + [], + ), + }), + ); + } + } + }); + + this._controlListeners = [ + Input.register('navigate', this._disablePointer, 'menu'), + Input.register('navigate', this._navigate), + Input.register('point', this._enablePointer), + Input.register('accept', async () => { + const { editor } = this.props; + const { position, selectedPosition } = this.state; + const vector = position || selectedPosition; + if (!vector) { + return; + } + + if (editor) { + this._updateEditorState({ isDrawing: true }); + await this._update((actualState) => { + const { behavior, position } = actualState; + return position && behavior?.enter + ? behavior.enter(position, actualState, this._actions, { + ...editor, + isDrawing: true, + }) + : null; + }); + this._updateEditorState({ isDrawing: false }); + } else { + this._select(vector); + } + }), + Input.register( + 'cancel', + (event) => + this._cancel( + this.state.position, + undefined, + event.detail?.isEscape || false, + ), + 'dialog', + ), + ]; + + if (!this.props.editor) { + this._controlListeners.push( + Input.register('tertiary', this._fastForward), + Input.register('tertiary:released', this._releaseFastForward), + Input.register('slow', () => { + if (this.state.animationConfig !== SlowAnimationConfig) { + this.setState({ + animationConfig: SlowAnimationConfig, + }); + } + }), + Input.register('slow:released', () => { + if (this.state.animationConfig === SlowAnimationConfig) { + const isHumanPlayer = this.state.map + .getCurrentPlayer() + .isHumanPlayer(); + this.setState({ + animationConfig: this._animationConfigs[isHumanPlayer ? 1 : 0], + }); + } + }), + ); + } + + document.addEventListener('pointermove', this._pointerMove); + document.addEventListener('mousedown', this._mouseDown); + document.addEventListener('mouseup', this._mouseUp); + window.addEventListener('resize', this._resize); + events?.addEventListener('action', this._processRemoteActionResponse); + } + + override componentDidUpdate(previousProps: Props) { + const { + props: { events }, + } = this; + + if (previousProps.events !== events) { + if (previousProps.events) { + previousProps.events.removeEventListener( + 'action', + this._processRemoteActionResponse, + ); + } + events?.addEventListener('action', this._processRemoteActionResponse); + } + } + + override componentWillUnmount() { + const { + props: { events }, + state: { behavior }, + } = this; + + for (const remove of this._controlListeners) { + remove(); + } + + document.removeEventListener('pointermove', this._pointerMove); + document.removeEventListener('mousedown', this._mouseDown); + document.removeEventListener('mouseup', this._mouseUp); + window.removeEventListener('resize', this._resize); + events?.removeEventListener('action', this._processRemoteActionResponse); + + behavior?.deactivate?.(); + } + + // eslint-disable-next-line unicorn/consistent-function-scoping + private _resize = throttle(() => { + this.setState({ + inlineUI: getInlineUIState(this.state.map, this.state.tileSize, 0), + }); + }, 100); + + private _reset = () => { + const { behavior, position, radius } = this.state; + if (position && behavior?.type !== 'attackRadius' && !radius?.locked) { + behavior?.clearTimers?.(); + this.setState({ + position: null, + radius: !radius || radius.type === RadiusType.Attack ? null : radius, + }); + } + }; + + private _endGame = async () => { + const { endGame } = this.props; + if (endGame) { + endGame('Lose'); + this._update(resetBehavior(NullBehavior)); + } + }; + + private _disablePointer = () => { + if (this._pointerEnabled) { + this._pointerEnabled = false; + this._maskRef.current?.classList.remove(MaskPointerClassName); + this._wrapperRef.current?.classList.add('pointerNone'); + } + }; + + private _enablePointer = () => { + if (!this._pointerEnabled) { + this._pointerEnabled = true; + this._maskRef.current?.classList.add(MaskPointerClassName); + this._wrapperRef.current?.classList.remove('pointerNone'); + } + }; + + private _navigate = ({ + detail: direction, + }: CustomEvent) => { + const { + behavior, + lastActionResponse, + map, + navigationDirection, + position, + previousPosition, + selectedPosition, + } = this.state; + + if (behavior?.navigate) { + this.setState({ + navigationDirection: { + ...direction, + previousX: navigationDirection?.x, + previousY: navigationDirection?.y, + }, + }); + } else if (direction) { + const origin = + position || + selectedPosition || + previousPosition || + (lastActionResponse && + lastActionResponse.type !== 'EndTurn' && + getActionResponseVectors(map, lastActionResponse).at(-1)) || + vec(Math.floor(map.size.width / 2), Math.floor(map.size.height / 2)); + const vector = vec(origin.x + direction.x, origin.y + direction.y); + this._enter(vector); + this._scrollIntoView([vector], direction); + } + }; + + private _enter = ( + vector: Vector, + subVector?: Vector, + type: MapEnterType = 'synthetic', + ) => { + if ( + type === 'pointer' && + (!this._pointerEnabled || this._pointerLock.current) + ) { + this._lastEnteredPosition = vector; + return; + } + + const { map, position, radius } = this.state; + if (!map.contains(vector) || radius?.locked) { + return; + } + + if (!vector.equals(position)) { + this.setState({ + position: vector, + previousPosition: position, + }); + } + + this._update((actualState) => { + const { behavior, replayState } = actualState; + if (!replayState.isReplaying) { + const newState = behavior?.enter + ? behavior.enter( + vector, + actualState, + this._actions, + this.props.editor, + subVector, + ) + : null; + if (newState) { + return newState; + } + } + return null; + }); + }; + + private _shouldConfirmAction() { + const { confirmActionStyle } = this.props; + return ( + confirmActionStyle === 'always' || + (confirmActionStyle === 'touch' && !!this._isTouch.current) + ); + } + + private _select = (vector: Vector, subVector?: Vector) => { + if (this._pointerLock.current) { + this._pointerLock.current = false; + this._isTouch.current = false; + return; + } + + this._update((actualState) => { + const { behavior, replayState } = actualState; + if (!replayState.isReplaying) { + const newState = behavior?.select + ? behavior.select( + vector, + actualState, + this._actions, + this.props.editor, + subVector, + this._shouldConfirmAction(), + ) + : null; + if (newState) { + const { selectedPosition } = newState; + const hasBehaviorChange = + newState.behavior?.type !== actualState.behavior?.type; + if ( + (newState.selectedUnit || newState.selectedBuilding) && + (hasBehaviorChange || + (selectedPosition && + !actualState.selectedPosition?.equals(selectedPosition))) + ) { + AudioPlayer.playSound('UI/Accept'); + rumbleEffect('accept'); + } else if ( + hasBehaviorChange && + !selectedPosition && + actualState.selectedPosition + ) { + AudioPlayer.playSound('UI/Cancel'); + } + + return newState; + } + } + this._isTouch.current = false; + return null; + }); + }; + + private _cancel = ( + vector: Vector | null, + transformOrigin: string | undefined, + isEscape: boolean, + ) => { + this._update((state) => { + const newState = { + ...this.state.behavior?.deactivate?.(), + ...resetBehavior(this.props.behavior), + }; + + if ( + state.gameInfoState || + newState.behavior?.type !== state.behavior?.type + ) { + AudioPlayer.playSound('UI/Cancel'); + } + + if (state.gameInfoState) { + return this._resetGameInfoState(); + } + + if ( + !isEscape && + !this.props.editor && + newState.behavior?.type === state.behavior?.type && + vector + ) { + requestAnimationFrame(() => + this._showFieldInfo(vector, transformOrigin || 'center center'), + ); + return null; + } + + return newState; + }); + }; + + private _update = ( + newState: StateLike | null | ((state: State) => StateLike | null), + ): Promise => { + return new Promise((resolve) => { + if (!newState) { + resolve(this.state); + return; + } + this.setState( + (actualState: State) => { + if (typeof newState === 'function') { + newState = newState(actualState); + } + if (!newState) { + return actualState; + } + + const newBehavior = newState.behavior; + const oldBehavior = this.state.behavior; + if (newBehavior && newBehavior !== oldBehavior) { + newState = { + ...this.state, + ...oldBehavior?.deactivate?.(), + ...newState, + }; + if (newBehavior.activate) { + Object.assign( + newState, + newBehavior.activate( + newState as State, + this._actions, + this._shouldConfirmAction(), + ), + ); + } + } + if ( + newState.position && + !(newState.map || actualState.map).contains(newState.position) + ) { + newState = { + ...newState, + position: null, + }; + } + if ( + (newState.currentViewer && + newState.currentViewer !== this.state.currentViewer) || + (newState.map && + newState.map.config.fog !== this.state.map.config.fog) + ) { + newState = { + ...newState, + vision: getVision( + newState.map || this.state.map, + newState.currentViewer || this.state.currentViewer, + this.props.spectatorCodes, + ), + }; + } + if ( + (newState.map && newState.map !== this.state.map) || + (newState.currentUserId && + newState.currentUserId !== this.state.currentUserId) + ) { + newState = { + ...newState, + currentViewer: (newState.map || this.state.map).getPlayerByUserId( + newState.currentUserId || this.state.currentUserId, + )?.id, + }; + } + + if ( + newState.map && + newState.map !== this.state.map && + newState.map.config.winConditions !== + this.state.map.config.winConditions + ) { + newState = { + ...newState, + winConditionRadius: getWinConditionRadius( + newState.map, + !!this.props.editor, + ), + }; + } + + if (this.state.position && !newState.position) { + newState = { + ...newState, + previousPosition: this.state.position, + }; + } + + return newState as State; + }, + () => resolve(this.state), + ); + }); + }; + + private _action = ( + state: State, + action: Action, + ): [Promise, MapData, ActionResponse] => { + const { onAction } = this.props; + const { map, preventRemoteActions, vision } = state; + if (preventRemoteActions) { + const { currentViewer } = state; + const player = map.getCurrentPlayer(); + throw new Error( + `Action: Cannot issue actions while processing remote actions. Current Viewer: '${currentViewer}'\nCurrent Player: '${player.id} (${player.isHumanPlayer() ? 'human' : 'bot'})'\nAction: '${JSON.stringify(action)}'`, + ); + } + + const actionResult = execute(map, vision, action, this.props.mutateAction); + if (!actionResult) { + throwActionError(action); + } + const [actionResponse, newMap] = actionResult; + const remoteAction = + onAction?.(action) || Promise.resolve({ self: { actionResponse } }); + return [remoteAction, newMap, actionResponse]; + }; + + private _optimisticAction = ( + state: State, + action: Action, + ): ActionResponse => { + const [remoteAction, , actionResponse] = this._action(state, action); + remoteAction.then((gameActionResponse) => + this.processGameActionResponse(gameActionResponse), + ); + return actionResponse; + }; + + private processGameActionResponse = async ( + gameActionResponse: GameActionResponse, + ): Promise => { + let { state } = this; + const { others, self, timeout: newTimeout } = gameActionResponse; + if (!self && !others?.length && newTimeout === undefined) { + return state; + } + + const timeout = newTimeout !== undefined ? newTimeout : state.timeout; + if (self) { + const { actionResponse } = self; + if (actionResponse.type === 'Start') { + state = await this._processActionResponses([self]); + if (others?.length) { + // Keep the map disabled until other actions are executed. + state = await this._update(resetBehavior(NullBehavior)); + } + } else if (actionResponse.type === 'EndTurn') { + const { map } = this.state; + const { current, next, supply } = actionResponse; + + // The turn was likely ended through a turn timeout. + if (map.getCurrentPlayer().id !== next.player) { + state = await this._processActionResponses([self]); + } else { + if (supply) { + state = await new Promise((resolve) => { + this._update({ + lastActionResponse: actionResponse, + ...animateSupply( + state, + sortByVectorKey(getUnitsByPositions(map, supply)), + (state) => { + resolve(state); + return null; + }, + ), + lastActionTime: dateNow(), + }); + }); + } + + const currentPlayer = map.getPlayer(current.player); + const nextPlayer = map.getPlayer(next.player); + if ( + currentPlayer.funds !== current.funds || + nextPlayer.funds !== next.funds || + map.getCurrentPlayer().id !== next.player + ) { + state = await this._update({ + lastActionResponse: actionResponse, + lastActionTime: dateNow(), + map: map.copy({ + currentPlayer: next.player, + teams: updatePlayers(map.teams, [ + currentPlayer.setFunds(current.funds), + nextPlayer.setFunds(next.funds), + ]), + }), + }); + } + } + } + + if (state.map.config.fog && (self.buildings || self.units)) { + state = await this._update((state) => ({ + // Ensure that attack action buttons are shown if attackable units + // are now in range. + ...(state.behavior?.type === 'menu' + ? { behavior: new MenuBehavior() } + : null), + map: updateVisibleEntities(state.map, state.vision, self), + })); + } + + // No need to keep processing if there are no other action responses. + if (!others) { + return new Promise((resolve) => { + this.setState( + () => ({ + lastActionResponse: actionResponse, + lastActionTime: dateNow(), + timeout, + }), + () => resolve(this.state), + ); + }); + } + } + + if (others?.length) { + // If there are self and other actions at the same time, wait before processing the other actions. + if (self) { + await sleep(this._scheduleTimer, this.state.animationConfig, 'long'); + } + + state = await this._processActionResponses(others); + } + + if (timeout) { + state = await this._update({ timeout }); + } + + return { + ...state, + timeout, + }; + }; + + private _processActionResponses = async ( + gameActionResponses: GameActionResponses, + ): Promise => { + return new Promise((resolve) => { + this._clearReplayTimers(); + + this._resolvers = []; + this._timers = new Set(); + + const { currentViewer, map } = this.state; + const currentPlayer = map.getCurrentPlayer(); + const isLive = currentViewer !== currentPlayer.id; + this.setState( + { + ...this._resetGameInfoState(), + animationConfig: this._isFastForward.current + ? this.state.animationConfig + : this._animationConfigs[currentPlayer.isHumanPlayer() ? 1 : 0], + preventRemoteActions: true, + replayState: { + isLive, + isPaused: false, + isReplaying: isLive, + isWaiting: false, + pauseStart: null, + }, + }, + async () => { + const { currentViewer } = this.state; + const { behavior, onError } = this.props; + try { + await processActionResponses( + this.state, + this._actions, + gameActionResponses, + this._animationConfigs, + this._isFastForward, + ); + } catch (error) { + if (onError) { + onError(error as Error); + resolve(this.state); + + return; + } else { + throw error; + } + } + this._resolvers = []; + this._timers = new Set(); + + const lastActionResponse = gameActionResponses.at(-1)!.actionResponse; + const currentPlayer = this.state.map.getCurrentPlayer(); + const isLive = + lastActionResponse.type !== 'GameEnd' && + (lastActionResponse.type !== 'EndTurn' || + lastActionResponse.next.player !== currentViewer) && + currentViewer !== currentPlayer.id; + + this.setState( + { + ...(resetBehavior(this.props.behavior) as State), + animationConfig: this._isFastForward.current + ? this.state.animationConfig + : this._animationConfigs[currentPlayer.isHumanPlayer() ? 1 : 0], + behavior: + lastActionResponse.type === 'GameEnd' + ? new NullBehavior() + : behavior + ? new behavior() + : new BaseBehavior(), + lastActionResponse, + position: !this._pointerEnabled + ? this.state.previousPosition + : null, + preventRemoteActions: false, + replayState: { + isLive, + isPaused: false, + isReplaying: false, + isWaiting: false, + pauseStart: null, + }, + }, + () => { + if (isLive) { + this._liveTimer = setTimeout(this._liveTimerFn, liveInterval); + } + + const detail: ActionsProcessedEventDetail = { + gameActionResponses, + map: this.state.map, + }; + this.props.events?.dispatchEvent( + new CustomEvent('actionsProcessed', { + detail, + }), + ); + resolve(this.state); + }, + ); + }, + ); + }); + }; + + private _liveTimerFn = () => + this.setState((state) => { + const { currentViewer, map } = state; + const isWaitingOnHumanPlayer = + (!currentViewer || !map.isCurrentPlayer(currentViewer)) && + map.getCurrentPlayer().isHumanPlayer(); + + if (isWaitingOnHumanPlayer) { + this._waitingTimer = setTimeout(this._waitingTimerFn, waitingInterval); + } + + return { + replayState: { + ...state.replayState, + isLive: false, + isWaiting: true, + }, + }; + }); + + private _waitingTimerFn = () => + this.setState((state) => { + return { + replayState: { + ...state.replayState, + isWaiting: false, + }, + }; + }); + + private _processRemoteActionResponse = async ( + event: Event, + ): Promise => { + if (this.state.replayState.isPaused) { + return new Promise((resolve) => + this._resolvers.push(() => + this._processRemoteActionResponse(event).then(resolve), + ), + ); + } + + const gameActionResponse = (event as CustomEvent) + .detail; + const queue = (this._actionQueue = ( + this._actionQueue || Promise.resolve() + ).then(async () => { + const { animationConfig, animations } = + await this.processGameActionResponse(gameActionResponse); + + if (animations.size) { + // Some animations like HealthAnimation do not have an `onComplete` callback. + // This delay gives these animations a chance to finish before continuing to process the queue. + await new Promise((resolve) => + setTimeout(resolve, animationConfig.AnimationDuration * 3), + ); + } + })); + return queue; + }; + + private _animationComplete = (position: Vector, animation: Animation) => { + const currentAnimation = this.state.animations.get(position); + if (currentAnimation && currentAnimation !== animation) { + throw new Error( + `Animation at position '${position}' changed unexpectedly:\nExpected: ${JSON.stringify( + animation, + null, + 2, + )}\nReceived: ${JSON.stringify( + this.state.animations.get(position), + null, + 2, + )}`, + ); + } + + if ('onComplete' in animation && animation.onComplete) { + const onComplete = animation.onComplete; + this._update((state) => { + const animations = state.animations.delete(position); + return { + animations, + ...onComplete({ + ...state, + animations, + }), + }; + }); + } else { + this.setState((state) => ({ + animations: state.animations.delete(position), + })); + } + }; + + private _clearReplayTimers = () => { + if (this._liveTimer) { + clearTimeout(this._liveTimer); + } + this._liveTimer = null; + + if (this._waitingTimer) { + clearTimeout(this._waitingTimer); + } + this._waitingTimer = null; + }; + + private _pauseReplay = async () => { + this._clearReplayTimers(); + const pauseStart = dateNow(); + const timers = new Set(); + for (const timer of this._timers) { + clearTimeout(timer.timer); + timers.add({ + ...timer, + delay: pauseStart - timer.start, + }); + } + this._timers = timers; + + await this._update({ + replayState: { + ...this.state.replayState, + isPaused: true, + isWaiting: false, + pauseStart, + }, + }); + }; + + private _resumeReplay = async () => { + this._clearReplayTimers(); + + await this._update({ + replayState: { + ...this.state.replayState, + isPaused: false, + isWaiting: false, + pauseStart: dateNow(), + }, + }); + + this._resolvers.forEach((resolve) => resolve()); + this._resolvers = []; + const timers = new Set(); + for (const { delay, fn } of this._timers) { + const timerObject = { + delay, + fn, + start: dateNow(), + timer: window.setTimeout(() => { + this._timers.delete(timerObject); + this._update(fn.call(null, this.state)); + }, delay), + }; + timers.add(timerObject); + } + this._timers = timers; + }; + + private _fastForward = () => { + if (!this._isFastForward.current) { + this._isFastForward.current = true; + const { map } = this.state; + const animationConfig = + this._animationConfigs[map.getCurrentPlayer().isHumanPlayer() ? 3 : 2]; + if (this.state.animationConfig !== animationConfig) { + this.setState({ + animationConfig, + }); + } + } + + return this._releaseFastForward; + }; + + private _releaseFastForward = () => { + this._isFastForward.current = false; + const isHumanPlayer = this.state.map.getCurrentPlayer().isHumanPlayer(); + const animationConfig = this._animationConfigs[isHumanPlayer ? 3 : 2]; + if (this.state.animationConfig === animationConfig) { + this.setState({ + animationConfig: this._animationConfigs[isHumanPlayer ? 1 : 0], + }); + } + }; + + private _clearTimer = (timer: number | TimerID) => { + Promise.resolve(timer).then((timer) => { + window.clearTimeout(timer); + for (const timerObject of this._timers) { + if (timerObject.timer === timer) { + this._timers.delete(timerObject); + break; + } + } + }); + }; + + private _scheduleTimer = async ( + // eslint-disable-next-line @typescript-eslint/ban-types + fn: Function, + delay: number, + ): Promise => { + const { replayState } = this.state; + if (replayState.isPaused) { + let _resolve: () => void; + const promise = new Promise((resolve) => { + _resolve = resolve; + }).then(() => this._scheduleTimer(fn, delay)); + requestAnimationFrame(() => this._resolvers.push(_resolve)); + return promise; + } + + const timer = window.setTimeout(() => { + this._timers.delete(timerObject); + this._update({ + ...fn.call(null, this.state), + replayState: { + ...this.state.replayState, + }, + }); + }, delay); + const timerObject = { + delay, + fn, + start: dateNow(), + timer, + }; + this._timers = this._timers.add(timerObject); + return timer; + }; + + private _requestFrame = (fn: (timestamp: number) => void): number => { + const { replayState } = this.state; + if (replayState.isPaused) { + let _resolve: () => void; + new Promise((resolve) => { + _resolve = () => resolve(); + }).then(() => fn(dateNow() - replayState.pauseStart!)); + requestAnimationFrame(() => this._resolvers.push(_resolve)); + return 0; + } + return requestAnimationFrame(fn); + }; + + private _resetPosition = () => { + const { position, radius } = this.state; + if (position && !radius?.locked) { + this.setState({ position: null }); + } + }; + + private _updateEditorState = (editor: Partial) => + this.props.setEditorState?.(editor); + + private _showGameInfo = (gameInfoState: GameInfoState) => + this.setState((state) => ({ + gameInfoState: { + ...gameInfoState, + ...(gameInfoState.type === 'game-info' + ? { panels: this.props.gameInfoPanels } + : null), + }, + namedPositions: null, + paused: true, + position: null, + previousPosition: state.position, + radius: + state.behavior?.type === 'base' && + state.radius?.type === RadiusType.Attack + ? null + : state.radius, + })); + + private _showFieldInfo = (vector: Vector, origin: string) => { + const { behavior, lastActionResponse, map, vision } = this.state; + if (behavior?.type === 'null' && lastActionResponse?.type !== 'GameEnd') { + return; + } + + const unit = vector && map.units.get(vector); + const building = vector && map.buildings.get(vector); + const tile = vector && map.getTileInfo(vector); + if (unit || building || tile) { + if (behavior?.type === 'base') { + behavior.clearTimers?.(); + } + AudioPlayer.playSound('UI/LongPress'); + this._showGameInfo({ + biome: map.config.biome, + building: vision.isVisible(map, vector) + ? building + : building?.hide(map.config.biome), + decorators: getDecoratorsAtField(map, vector), + modifierField: map.modifiers[map.getTileIndex(vector)], + origin, + tile, + type: 'map-info', + unit: vision.isVisible(map, vector) ? unit : null, + vector, + }); + } + }; + + private _resetGameInfoState = () => ({ + gameInfoState: null, + paused: this.props.paused === true || false, + position: !this._pointerEnabled ? this.state.previousPosition : null, + }); + + private _hideGameInfo = () => + new Promise((resolve) => { + AudioPlayer.playSound('UI/Cancel'); + this.setState(this._resetGameInfoState(), resolve); + }); + + private _pointerDown = (event: ReactPointerEvent) => { + this._isTouch.current = event.pointerType === 'touch'; + }; + + private _pointerMove = (event: PointerEvent) => { + this._enablePointer(); + + if (!this.props.pan || !this._pointerPosition) { + return; + } + + const element = this._maskRef.current; + if ( + event.pointerType === 'touch' || + !element || + (event.target as HTMLElement)?.parentNode !== element + ) { + return; + } + + const { + map: { + size: { height, width }, + }, + tileSize, + } = this.state; + const multiplier = (0.03 * (width + height)) / 2 + 0.85; + const { clientX, clientY } = event; + const distanceX = this._pointerPosition.clientX - clientX; + const distanceY = this._pointerPosition.clientY - clientY; + const distance = Math.abs(distanceX) + Math.abs(distanceY); + if (this._pointerPosition.initial && distance < tileSize) { + return; + } + + this._pointerLock.current = true; + window.scrollBy(distanceX * multiplier, distanceY * multiplier); + + this._pointerPosition = { + clientX, + clientY, + distance: this._pointerPosition.distance + distance, + initial: false, + }; + }; + + private _clearPanState = () => { + this._lastEnteredPosition = null; + this._pointerPosition = null; + if (this._releasePointerLock) { + clearTimeout(this._releasePointerLock); + } + this._releasePointerLock = setTimeout(() => { + this._pointerLock.current = false; + }, 100); + document.removeEventListener('mouseleave', this._clearPanState); + }; + + private _mouseDown = (event: MouseEvent) => { + this._pointerPosition = { + clientX: event.clientX, + clientY: event.clientY, + distance: 0, + initial: true, + }; + document.addEventListener('mouseleave', this._clearPanState); + + const { editor } = this.props; + if ( + editor && + this.state.position && + (event.target as HTMLElement)?.parentNode === this._maskRef.current + ) { + if (event.shiftKey || event.button === 2) { + this._update((actualState) => { + const { behavior, position } = actualState; + return position && behavior?.enterAlternative + ? behavior.enterAlternative( + position, + actualState, + this._actions, + editor!, + ) + : null; + }); + } else { + if (!editor.isDrawing) { + this._updateEditorState({ isDrawing: true }); + } + this._update((actualState) => { + const { behavior, position } = actualState; + return position && behavior?.enter + ? behavior.enter(position, actualState, this._actions, { + ...editor, + isDrawing: true, + }) + : null; + }); + } + } + }; + + private _mouseUp = () => { + const { editor } = this.props; + if (!editor && this._lastEnteredPosition) { + this._enter(this._lastEnteredPosition); + } + + this._clearPanState(); + + if (editor?.isDrawing) { + this._updateEditorState({ + isDrawing: false, + }); + } + }; + + private _getMaskElement = (vector: Vector) => { + return this._maskRef.current?.querySelector(`.${maskClassName(vector)}`); + }; + + private _scrollIntoView = async ( + vectors: ReadonlyArray, + direction?: VectorLike, + ) => { + const mask = this._maskRef.current; + if (!vectors.length || !mask) { + return; + } + + const element = this._getMaskElement(getAverageVector(vectors)); + // Assume that the first and last vectors are the most distant. + const boundaries = + vectors.length === 1 + ? [element] + : [vectors[0], vectors.at(-1)!].map(this._getMaskElement); + + const scale = getScale(this.props.scale, mask); + if ( + element && + !boundaries.some( + (element) => element && isInView(element, scale, DoubleSize), + ) + ) { + element.scrollIntoView({ + behavior: 'smooth', + block: direction?.x && !direction.y ? 'nearest' : 'center', + inline: direction?.y && !direction.x ? 'nearest' : 'center', + }); + return sleep(this._scheduleTimer, this.state.animationConfig, 'long'); + } + }; + + override render() { + const { + props: { + animatedChildren, + children, + className, + editor, + fogStyle, + margin, + scale, + showCursor: propsShowCursor, + skipBanners, + tilted, + }, + state: { + additionalRadius, + animationConfig, + animations, + attackable, + behavior, + currentViewer, + gameInfoState, + map, + namedPositions, + paused, + position, + radius, + replayState, + selectedBuilding, + selectedPosition, + selectedUnit, + showCursor, + tileSize, + vision, + winConditionRadius, + zIndex, + }, + } = this; + const { height, width } = map.size; + + const isFloating = this.props.style === 'floating'; + const StateComponent = behavior?.component; + return ( +
+
+
+ + {winConditionRadius?.map((radius, index) => ( + getLayer(0, 'building')} + key={index} + map={map} + radius={radius} + size={tileSize} + vision={vision} + /> + ))} + {additionalRadius && ( + + )} + {radius && ( + + )} + {(propsShowCursor || propsShowCursor == null) && + showCursor && + !replayState.isReplaying && ( + + )} + + {editor?.selected?.decorator || + editor?.selected?.eraseDecorators ? ( + + ) : ( + void 0 : this._showFieldInfo} + tileSize={tileSize} + zIndex={zIndex - 2} + /> + )} +
+ + {!behavior || showNamedPositionsForBehavior.has(behavior.type) + ? namedPositions?.map((vector) => { + const unit = map.units.get(vector); + return ( + unit?.hasName() && ( + + + + {unit.getName(currentViewer)} + {unit.isLeader() && } + + {unit.health < MaxHealth ? ( + + {unit.health} + + + ) : null} + + , + ]} + key={`named-position-${vector}`} + mini + position={vector} + tileSize={tileSize} + width={map.size.width} + zIndex={zIndex + 20} + /> + ) + ); + }) + : null} + {StateComponent && ( + + )} + {animatedChildren?.(this.state)} + + {children?.(this.state, this._actions)} +
+
+
+ {gameInfoState && ( + + )} +
+ ); + } +} + +GameMap.contextType = ScrollRestore; + +const vars = new CSSVariables<'transform'>('m'); +const style = css` + position: relative; +`; + +const mapStyle = css` + ${vars.set('transform', 'none')} + + -webkit-user-drag: none; + image-rendering: pixelated; + position: relative; + + transform: ${vars.apply('transform')}; + + * { + animation-play-state: running; + } +`; + +const tiltedStyle = css` + ${vars.set('transform', applyVar('perspective-transform'))} + + box-shadow: ${applyVar('border-color-light')} 0 8px 10px; +`; + +const pausedStyle = css` + * { + animation-play-state: paused !important; + } +`; + +const pointerStyle = css` + &.pointerNone * { + cursor: none !important; + pointer-events: none !important; + } +`; + +const explosionAnimation = css` + animation-iteration-count: infinite; + animation-timing-function: linear; + animation-delay: calc(${applyVar('animation-duration')} / 4); + animation-duration: ${applyVar('animation-duration')}; + animation-name: ${keyframes` + 0% { + transform: ${vars.apply('transform')} translate3d(0, 0, 0); + } + 25% { + transform: ${vars.apply('transform')} translate3d(0, 2.5px, 0); + } + 50% { + transform: ${vars.apply('transform')} translate3d(0, -2.5px, 0); + } + 75% { + transform: ${vars.apply('transform')} translate3d(-1.3px, 0, 0); + } + 100% { + transform: ${vars.apply('transform')} translate3d(1.3px, 0, 0); + } + `}; +`; diff --git a/hera/Label.tsx b/hera/Label.tsx new file mode 100644 index 00000000..b16e2c3f --- /dev/null +++ b/hera/Label.tsx @@ -0,0 +1,54 @@ +import { TileSize } from '@deities/athena/map/Configuration.tsx'; +import Entity, { isBuilding } from '@deities/athena/map/Entity.tsx'; +import { applyVar } from '@deities/ui/cssVar.tsx'; +import { css, cx } from '@emotion/css'; +import sprite from './lib/sprite.tsx'; +import Tick from './Tick.tsx'; + +export default function Label({ + entity, + hide, +}: { + entity: Entity; + hide: boolean; +}) { + return entity.label !== null ? ( +
+ ) : null; +} + +const labelSize = 12; +const labelStyle = css` + background-position: calc(${Tick.vars.apply('unit')} * ${-labelSize}px); + filter: brightness(1.1); + height: ${labelSize}px; + opacity: 1; + position: absolute; + right: -${labelSize / 2 + 1}px; + top: -${labelSize / 2 - 1}px; + transition: opacity ${applyVar('animation-duration-70')} ease-in-out; + width: ${labelSize}px; +`; + +const tallBuildingOffsetStyle = css` + top: ${TileSize / 2 - labelSize / 1.5 - 1}px; +`; + +const buildingOffsetStyle = css` + top: ${TileSize / 2 - 1}px; +`; + +const hideStyle = css` + opacity: 0; +`; diff --git a/hera/Map.tsx b/hera/Map.tsx new file mode 100644 index 00000000..700d3e59 --- /dev/null +++ b/hera/Map.tsx @@ -0,0 +1,354 @@ +import { prepareSprites } from '@deities/art/Sprites.tsx'; +import { BuildingInfo } from '@deities/athena/info/Building.tsx'; +import { DecoratorInfo } from '@deities/athena/info/Decorator.tsx'; +import { MovementType } from '@deities/athena/info/MovementType.tsx'; +import { TileInfo } from '@deities/athena/info/Tile.tsx'; +import { UnitInfo, Weapon } from '@deities/athena/info/Unit.tsx'; +import matchesActiveType from '@deities/athena/lib/matchesActiveType.tsx'; +import BuildingT from '@deities/athena/map/Building.tsx'; +import type { AnimationConfig } from '@deities/athena/map/Configuration.tsx'; +import Entity from '@deities/athena/map/Entity.tsx'; +import UnitT from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { RadiusItem } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { css, cx } from '@emotion/css'; +import Images from 'athena-crisis:images'; +// eslint-disable-next-line @deities/no-lazy-import +import React, { lazy } from 'react'; +import Building from './Building.tsx'; +import Decorators from './Decorators.tsx'; +import Fog from './Fog.tsx'; +import { + BuildingDescriptionMap, + BuildingMap, + DecoratorMap, + MovementTypeMap, + TileDescriptionMap, + TileMap, + UnitCharacterDescriptionMap, + UnitDescriptionMap, + UnitMap, + WeaponMap, +} from './i18n/EntityMap.tsx'; +import injectTranslation from './i18n/injectTranslation.tsx'; +import { + Animation, + Animations, + isBuildingAnimation, + isUnitAnimation, +} from './MapAnimations.tsx'; +import { RadiusInfo, RadiusType } from './Radius.tsx'; +import Tick from './Tick.tsx'; +import TileDecorators from './TileDecorators.tsx'; +import Tiles, { TileStyle } from './Tiles.tsx'; +import { + GetLayerFunction, + MapBehavior, + RequestFrameFunction, + TimerFunction, +} from './Types.tsx'; +import ErrorOverlay, { SpriteLoadError } from './ui/ErrorOverlay.tsx'; +import Unit from './Unit.tsx'; + +injectTranslation(BuildingInfo, BuildingMap); +injectTranslation(BuildingInfo, BuildingDescriptionMap, [ + 'description', + 'internalDescription', +]); +injectTranslation(DecoratorInfo, DecoratorMap); +injectTranslation(TileInfo, TileMap); +injectTranslation(TileInfo, TileDescriptionMap, [ + 'description', + 'internalDescription', +]); +injectTranslation(UnitInfo, UnitMap); +injectTranslation(UnitInfo, UnitDescriptionMap, [ + 'description', + 'internalDescription', +]); +injectTranslation(UnitInfo, UnitCharacterDescriptionMap, [ + 'characterDescription', + 'internalCharacterDescription', +]); +injectTranslation(Weapon, WeaponMap); +injectTranslation(MovementType, MovementTypeMap); + +const MapComponent = ({ + animationConfig, + animations, + attackable, + behavior, + className, + fogStyle, + getLayer, + map, + onAnimationComplete = () => void 0, + paused, + radius, + renderEntities = true, + requestFrame = requestAnimationFrame, + scheduleTimer, + selectedBuilding, + selectedPosition, + selectedUnit, + style = 'none', + tileSize, + vision, +}: { + animationConfig: AnimationConfig; + animations?: Animations; + attackable?: ReadonlyMap | null; + behavior: MapBehavior | null; + className?: string; + fogStyle?: 'soft' | 'hard'; + getLayer: GetLayerFunction; + map: MapData; + onAnimationComplete?: (position: Vector, animation: Animation) => void; + paused?: boolean; + radius?: RadiusInfo | null; + renderEntities?: boolean; + requestFrame?: RequestFrameFunction; + scheduleTimer?: TimerFunction; + selectedBuilding?: BuildingT | null; + selectedPosition?: Vector | null; + selectedUnit?: UnitT | null; + style?: TileStyle; + tileSize: number; + vision: VisionT; +}) => { + const { biome } = map.config; + const activeUnitTypes = map.getActiveUnitTypes(); + + const canAttackEntity = (entity: Entity) => + selectedUnit?.getAttackWeapon(entity) && + map.isOpponent(selectedUnit, entity); + + return ( +
+ + + {renderEntities && ( + + {map.reduceEachField>((list, vector) => { + const animation = animations?.get(vector); + const building = map.buildings.get(vector); + const unit = map.units.get(vector); + const vectorKey = String(vector); + const isVisible = vision.isVisible(map, vector); + const isSelected = selectedPosition?.equals(vector); + const hasRadius = radius?.fields.has(vector); + const outline = + (unit || building) && + !!( + radius && + ((hasRadius && + (radius.type === RadiusType.Attackable || + radius.type === RadiusType.Sabotage || + radius.type === RadiusType.Defense || + radius.type === RadiusType.Rescue || + radius.type === RadiusType.Highlight)) || + (attackable?.has(vector) && !hasRadius)) + ); + + if (building) { + const up = vector.up(); + const hasUnitAbove = + (building.info.sprite.size === 'medium' || + building.info.sprite.size === 'tall') && + vision.isVisible(map, up) && + map.units.has(up); + list.push( + , + ); + } + + list.push( + , + ); + + if (unit && (isVisible || animation?.type === 'move')) { + const outlineUnit = + outline && (!radius.focus || radius.focus === 'unit'); + const power = matchesActiveType( + activeUnitTypes.get(unit.player), + unit, + vector, + ); + list.push( + , + ); + } + return list; + }, [])} + + )} + +
+ ); +}; + +const pixelatedStyle = css` + image-rendering: pixelated; +`; + +const pausedStyle = css` + * { + animation-play-state: paused !important; + } +`; + +// Keep images in memory forever. +const imageCache = []; +let loaderPromise: Promise<{ default: typeof MapComponent }> | null = null; +const load = () => { + return ( + loaderPromise || + (loaderPromise = Promise.all([ + prepareSprites(), + ...Images.map( + (path) => + new Promise((resolve) => { + const image = new Image(); + imageCache.push(image); + image.onload = resolve; + image.src = path; + }), + ), + ]) + .then(() => ({ default: MapComponent })) + .catch((error) => { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error(error); + } + const spriteLoadError = new SpriteLoadError(error.message); + return { + default: (props) => ( + <> + + + + ), + }; + })) + ); +}; + +(window.requestIdleCallback || requestAnimationFrame)(load); + +export default lazy(load); diff --git a/hera/MapAnimations.tsx b/hera/MapAnimations.tsx new file mode 100644 index 00000000..06a3c3f9 --- /dev/null +++ b/hera/MapAnimations.tsx @@ -0,0 +1,721 @@ +import { AttackDirection } from '@deities/apollo/attack-direction/getAttackDirection.tsx'; +import { SoundName } from '@deities/athena/info/Music.tsx'; +import { TileInfo } from '@deities/athena/info/Tile.tsx'; +import { UnitAnimationSprite, Weapon } from '@deities/athena/info/Unit.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { + AnimationConfig, + InstantAnimationConfig, +} from '@deities/athena/map/Configuration.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; +import { BaseColor } from '@deities/ui/getColor.tsx'; +import ImmutableMap from '@nkzw/immutable-map'; +import { AnimatePresence } from 'framer-motion'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; +import AttackAnimation from './animations/AttackAnimation.tsx'; +import BuildingCreate from './animations/BuildingCreate.tsx'; +import Explosion, { ExplosionStyle } from './animations/Explosion.tsx'; +import Fireworks from './animations/Fireworks.tsx'; +import Heal from './animations/Heal.tsx'; +import HealthAnimation from './animations/HealthAnimation.tsx'; +import Rescue from './animations/Rescue.tsx'; +import Sabotage from './animations/Sabotage.tsx'; +import Shake from './animations/Shake.tsx'; +import Spawn from './animations/Spawn.tsx'; +import UpgradeAnimation from './animations/UpgradeAnimation.tsx'; +import { + Actions, + FactionNames, + GetLayerFunction, + State, + StateToStateLike, +} from './Types.tsx'; +import Banner from './ui/Banner.tsx'; +import CharacterMessage from './ui/CharacterMessage.tsx'; +import FlashFlyout from './ui/FlashFlyout.tsx'; +import { FlyoutColor, FlyoutItem } from './ui/Flyout.tsx'; +import Message from './ui/Message.tsx'; +import Notice from './ui/Notice.tsx'; + +type UnitDirection = 'left' | 'right'; + +export type ExplosionAnimation = Readonly<{ + direction?: AttackDirection; + onComplete: StateToStateLike; + onExplode?: StateToStateLike; + position?: Vector; + style: ExplosionStyle; + type: 'explosion'; +}>; + +export type SpawnAnimation = Readonly<{ + locked: false; + onComplete: StateToStateLike; + onSpawn?: StateToStateLike; + type: 'spawn'; + unitDirection: UnitDirection; + variant: PlayerID; +}>; + +export type CreateBuildingAnimation = Readonly<{ + onComplete: StateToStateLike; + onCreate?: StateToStateLike; + type: 'createBuilding'; + variant: PlayerID; +}>; + +export type HealAnimation = Readonly<{ + onComplete: StateToStateLike; + type: 'heal'; + unitDirection: UnitDirection; +}>; + +type RescueAnimation = Readonly<{ + onComplete: StateToStateLike; + onRescue?: StateToStateLike; + type: 'rescue'; + unitDirection: UnitDirection; + variant: PlayerID; +}>; + +type SabotageAnimation = Readonly<{ + onComplete: StateToStateLike; + type: 'sabotage'; + unitDirection: UnitDirection; +}>; + +export type FireworksAnimation = Readonly<{ + onComplete: StateToStateLike; + type: 'fireworks'; +}>; + +export type UpgradeAnimation = Readonly<{ + onComplete: StateToStateLike; + onUpgrade: StateToStateLike; + type: 'upgrade'; +}>; + +export type ShakeAnimation = Readonly<{ + type: 'shake'; +}>; + +export type ScrollIntoView = Readonly<{ + onComplete: StateToStateLike; + positions: ReadonlyArray; + type: 'scrollIntoView'; +}>; + +export type HealthAnimation = Readonly<{ + change: number; + position: Vector; + previousHealth: number; + type: 'health'; +}>; + +export type MoveAnimation = Readonly<{ + endSound?: SoundName; + from: Vector; + onComplete: StateToStateLike; + partial: boolean; + path: ReadonlyArray; + pathVisibility: ReadonlyArray | null; + startSound?: SoundName; + tiles: ReadonlyArray; + type: 'move'; +}>; + +export type AttackAnimation = Readonly<{ + direction: AttackDirection; + frames?: 8 | 16; + hasAttackStance: boolean; + locked?: undefined; + offset?: undefined; + onComplete: StateToStateLike; + style: 'unfold' | null; + type: 'attack'; + variant: PlayerID; + weapon: Weapon; +}>; + +export type UnitExplosionAnimation = UnitAnimationSprite & + Readonly<{ + direction?: AttackDirection; + locked: boolean; + onComplete: StateToStateLike; + type: 'unitExplosion'; + withExplosion?: boolean; + }>; + +export type UnfoldAnimation = UnitAnimationSprite & + Readonly<{ + locked?: undefined; + onComplete: StateToStateLike; + type: 'fold' | 'unfold'; + }>; + +export type UnitHealAnimation = UnitAnimationSprite & + Readonly<{ + locked?: undefined; + type: 'unitHeal'; + }>; + +type _FlashAnimation = Readonly<{ + direction: AttackDirection; + hasAttackStance: boolean; + variant: PlayerID; + weapon?: Weapon; +}>; + +export type AttackUnitFlashAnimation = _FlashAnimation & + Readonly<{ + type: 'attackUnitFlash'; + }>; + +export type AttackBuildingFlashAnimation = _FlashAnimation & + Readonly<{ + type: 'attackBuildingFlash'; + }>; + +export type CaptureAnimation = Readonly<{ + direction: AttackDirection; + onComplete: StateToStateLike; + type: 'capture'; + variant: 0; + weapon: Weapon; +}>; + +export type FlashAnimation = Readonly<{ + children: ReactNode; + color: FlyoutColor; + onComplete: StateToStateLike; + position: Vector; + sound?: SoundName; + type: 'flash'; +}>; + +export type BannerAnimation = Readonly<{ + color?: BaseColor | ReadonlyArray; + length: 'short' | 'medium' | 'long'; + onComplete?: StateToStateLike; + player: PlayerID; + sound: SoundName | null; + style?: 'regular' | 'flashy'; + text: string; + type: 'banner'; +}>; + +export type CharacterMessageAnimation = Readonly<{ + factionNames: FactionNames; + map: MapData; + onComplete?: StateToStateLike; + player: PlayerID; + position?: 'top' | 'bottom'; + text: string; + type: 'characterMessage'; + unitId: number; + variant?: number; + viewer?: PlayerID; +}>; + +export type MessageAnimation = Readonly<{ + color?: BaseColor | ReadonlyArray; + onComplete?: StateToStateLike; + position?: 'top' | 'bottom'; + text: string; + type: 'message'; +}>; + +export type NoticeAnimation = Readonly<{ + color?: BaseColor; + onComplete?: StateToStateLike; + text: string; + type: 'notice'; +}>; + +export type UnitAnimation = + | AttackAnimation + | AttackUnitFlashAnimation + | CaptureAnimation + | HealAnimation + | MoveAnimation + | RescueAnimation + | SabotageAnimation + | SpawnAnimation + | UnfoldAnimation + | UnitExplosionAnimation + | UnitHealAnimation; + +export type BuildingAnimation = + | AttackBuildingFlashAnimation + | CreateBuildingAnimation + | CaptureAnimation; + +export type Animation = + | BannerAnimation + | BuildingAnimation + | BuildingAnimation + | CharacterMessageAnimation + | ExplosionAnimation + | FireworksAnimation + | FlashAnimation + | HealthAnimation + | MessageAnimation + | NoticeAnimation + | ScrollIntoView + | ShakeAnimation + | UnitAnimation + | UpgradeAnimation; + +export type Animations = ImmutableMap; + +const withTransition = new Set([ + 'flash', + 'damage', + 'banner', + 'notice', + 'characterMessage', + 'message', +]); + +const unitAnimations = new Set([ + 'attack', + 'attackUnitFlash', + 'capture', + 'fold', + 'heal', + 'move', + 'rescue', + 'sabotage', + 'spawn', + 'unfold', + 'unitHeal', + 'unitExplosion', +]); + +export function isUnitAnimation( + animation?: Animation, +): animation is UnitAnimation { + return !!(animation && unitAnimations.has(animation.type)); +} +export function isBuildingAnimation( + animation?: Animation, +): animation is BuildingAnimation { + return !!( + animation && + (animation.type === 'createBuilding' || + animation.type === 'attackBuildingFlash' || + animation.type === 'capture') + ); +} + +export function hasNotableAnimation(animations: Animations) { + return animations.some( + (animation) => + animation.type !== 'move' && + (isBuildingAnimation(animation) || isUnitAnimation(animation)), + ); +} + +const ScrollIntoView = ({ + onComplete, + positions, + scrollIntoView, + update, +}: { + onComplete: StateToStateLike; + positions: ReadonlyArray; + scrollIntoView: Actions['scrollIntoView']; + update: Actions['update']; +}) => { + useEffect(() => { + scrollIntoView(positions).then(async () => + update(onComplete(await update(null))), + ); + }, [onComplete, positions, scrollIntoView, update]); + + return null; +}; + +const MapAnimation = ({ + actions: { clearTimer, requestFrame, scheduleTimer, scrollIntoView, update }, + animation, + animationComplete, + animationConfig: initialAnimationConfig, + biome, + getLayer, + position, + skipBanners, + tileSize, + userDisplayName, + width, + zIndex, +}: { + actions: Actions; + animation: Animation; + animationComplete: (position: Vector, animation: Animation) => void; + animationConfig: AnimationConfig; + biome: Biome; + getLayer: GetLayerFunction; + position: Vector; + skipBanners?: boolean; + tileSize: number; + userDisplayName: string; + width: number; + zIndex: number; +}) => { + const [animationConfig] = useState(initialAnimationConfig); + return useMemo(() => { + const props = { + animationConfig, + clearTimer, + onComplete: () => animationComplete(position, animation), + rate: + AnimationConfig.AnimationDuration / animationConfig.AnimationDuration, + scheduleTimer, + zIndex, + }; + + const { type } = animation; + switch (type) { + case 'explosion': + return ( + + ); + case 'spawn': + return ( + + ); + case 'shake': + return ; + case 'createBuilding': + return ( + + ); + case 'fireworks': + return ( + + ); + case 'upgrade': + return ( + + ); + case 'heal': + return ( + + ); + case 'rescue': + return ( + + ); + case 'sabotage': + return ( + + ); + case 'capture': + case 'attackUnitFlash': + case 'attackBuildingFlash': + case 'attack': { + const isFlash = + animation.type === 'attackUnitFlash' || + animation.type === 'attackBuildingFlash'; + const initialDelay = + (type === 'attack' || isFlash) && animation.hasAttackStance + ? animationConfig.UnitAnimationStep * 4 + : undefined; + const style = (animation.type === 'attack' && animation.style) || null; + const maybeAnimation = isFlash + ? animation.weapon?.hitAnimation + : animation.weapon?.animation; + const weaponAnimation = Array.isArray(maybeAnimation) + ? maybeAnimation[ + animation.direction.direction === 'down' + ? 2 + : animation.direction.direction === 'up' + ? 1 + : 0 + ] + : maybeAnimation; + + if (weaponAnimation) { + const direction = animation.direction.direction; + const isVertical = direction === 'up' || direction === 'down'; + const positions = + (style === 'unfold' && weaponAnimation.unfoldPositions) || + weaponAnimation.positions; + return ( + <> + {weaponAnimation.mirror && ( + + )} + + + ); + } + return null; + } + case 'flash': + return ( + + {animation.children} + + } + // Use the position in the map for the key, but use the position + // on the object for actual location. This allows to duplicate + // multiple flyouts in the same place. See addFlashAnimation. + position={animation.position} + sound={animation.sound} + tileSize={tileSize} + width={width} + {...props} + /> + ); + case 'health': + return ( + + ); + case 'banner': + return ( + + ); + case 'notice': + return ( + + ); + case 'characterMessage': { + return ( + + ); + } + case 'message': { + return ; + } + case 'scrollIntoView': { + return ( + + ); + } + // Handled directly within Buildings/Units. + case 'fold': + case 'move': + case 'unfold': + case 'unitExplosion': + case 'unitHeal': + return null; + default: { + animation satisfies never; + throw new UnknownTypeError('MapAnimation', type); + } + } + }, [ + animation, + animationComplete, + animationConfig, + biome, + clearTimer, + getLayer, + position, + requestFrame, + scheduleTimer, + scrollIntoView, + skipBanners, + tileSize, + update, + userDisplayName, + width, + zIndex, + ]); +}; + +export function MapAnimations({ + actions, + animationComplete, + getLayer, + skipBanners, + state: { + animationConfig, + animations, + map: { + config: { biome }, + size: { width }, + }, + tileSize, + zIndex, + }, + userDisplayName, +}: { + actions: Actions; + animationComplete: (position: Vector, animation: Animation) => void; + getLayer: GetLayerFunction; + skipBanners?: boolean; + state: State; + userDisplayName: string; +}) { + const animationsWithTransitions: Array = []; + const mainAnimations: Array = []; + animations.forEach((animation, position) => { + const { type } = animation; + if ( + type === 'fold' || + type === 'move' || + type === 'unfold' || + type === 'unitExplosion' + ) { + return; + } + + (withTransition.has(type) + ? animationsWithTransitions + : mainAnimations + ).push( + , + ); + }); + return ( + <> + {mainAnimations} + {animationsWithTransitions} + + ); +} diff --git a/hera/Mask.tsx b/hera/Mask.tsx new file mode 100644 index 00000000..6cc1e1af --- /dev/null +++ b/hera/Mask.tsx @@ -0,0 +1,273 @@ +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { RadiusItem } from '@deities/athena/Radius.tsx'; +import parseInteger from '@deities/hephaestus/parseInteger.tsx'; +import usePress, { LongPressReactEvents } from '@deities/ui/hooks/usePress.tsx'; +import { css, cx } from '@emotion/css'; +import React, { + memo, + MutableRefObject, + RefObject, + useCallback, + useMemo, +} from 'react'; +import maskClassName, { MaskPointerClassName } from './lib/maskClassName.tsx'; +import toTransformOrigin from './lib/toTransformOrigin.tsx'; +import { RadiusInfo, RadiusType } from './Radius.tsx'; +import { MapEnterType } from './Types.tsx'; + +enum Priority { + High = 2, + Low = 1, + None = 0, +} +type Offsets = [ + top: number, + right: number, + down: number, + left: number, + priority: Priority, +]; + +const parseVector = (maybeVector: string | null) => { + if (maybeVector) { + try { + const [x, y] = maybeVector.split(',').map((value) => parseInteger(value)); + return x != null && y != null ? vec(x, y) : null; + } catch { + /* empty */ + } + } + return null; +}; + +const getOffsets = ( + map: MapData, + currentViewer: PlayerID | null, + vector: Vector, + radius: RadiusInfo | null, + attackable: ReadonlyMap | null, + selectedPosition: Vector | null, +): Offsets | null => { + if (!currentViewer) { + return null; + } + + const hasRadius = + radius?.type !== RadiusType.Attack && radius?.fields.has(vector); + + if (hasRadius) { + return [4, 4, 4, 4, Priority.Low]; + } + + if (attackable?.has(vector)) { + return [6, 6, 6, 6, Priority.High]; + } + + const building = !hasRadius && map.buildings.get(vector); + if ( + building && + building.info.sprite.size === 'tall' && + map.matchesPlayer(currentViewer, building) && + !building.isCompleted() + ) { + const up = vector.up(); + const buildingUp = map.buildings.get(up); + const unitUp = map.units.get(up); + return (!buildingUp || !map.matchesPlayer(currentViewer, buildingUp)) && + (!unitUp || !map.matchesPlayer(currentViewer, unitUp)) + ? [12, 6, 6, 6, Priority.Low] + : [6, 6, 6, 6, Priority.Low]; + } + + const unit = map.units.get(vector); + if ( + unit && + !unit.isCompleted() && + (map.matchesPlayer(currentViewer, unit) || selectedPosition?.equals(vector)) + ) { + return [6, 6, 6, 6, Priority.High]; + } + + if (hasRadius) { + return [1, 1, 1, 1, Priority.Low]; + } + + return null; +}; + +export type BaseMaskProps = Readonly<{ + cancel: ( + vector: Vector | null, + transformOrigin: string | undefined, + isEscape: boolean, + ) => void; + enter: (vector: Vector, _: undefined, type: MapEnterType) => void; + map: MapData; + maskRef: RefObject; + select: (vector: Vector) => void; + tileSize: number; + zIndex: number; +}>; + +export default memo(function Mask({ + attackable, + cancel, + currentViewer, + enter, + expand, + map, + maskRef, + pointerLock, + radius, + select, + selectedPosition, + showFieldInfo, + tileSize: size, + zIndex, +}: BaseMaskProps & { + attackable: ReadonlyMap | null; + currentViewer: PlayerID | null; + expand: boolean; + pointerLock: MutableRefObject; + radius: RadiusInfo | null; + selectedPosition: Vector | null; + showFieldInfo: (vector: Vector, origin: string) => void; +}) { + const onLongPress = useCallback( + (event: LongPressReactEvents) => { + const vector = parseVector( + (event.target as HTMLElement)?.getAttribute('data-vector'), + ); + if (!pointerLock.current && vector) { + showFieldInfo(vector, toTransformOrigin(event)); + } + }, + [pointerLock, showFieldInfo], + ); + + const props = usePress({ + cancelOnMovement: size, + onLongPress, + onPress: useCallback( + (event) => { + const vector = parseVector( + (event.target as HTMLElement)?.getAttribute('data-vector'), + ); + if (vector) { + select(vector); + } + }, + [select], + ), + }); + + return useMemo(() => { + const defaultOffsets: Offsets = [0, 0, 0, 0, Priority.None]; + let fields = map.mapFields<[Vector, Offsets]>((vector) => [ + vector, + (expand && + getOffsets( + map, + currentViewer, + vector, + radius, + attackable, + selectedPosition, + )) || + defaultOffsets, + ]); + + // The double pass is necessary to ensure that each field is considered independently without mutations. + // Note that it mutates the previous entries, ~this is fine~ + fields = fields.map(([vector, offsets]) => { + const left = fields[map.getTileIndex(vector.left())]; + const up = fields[map.getTileIndex(vector.up())]; + const leftTop = fields[map.getTileIndex(vector.up().left())]; + const rightTop = fields[map.getTileIndex(vector.up().right())]; + if (left && left[1][1] > 0 && offsets[3] > 0) { + left[1][1] = 0; + offsets = offsets.slice() as Offsets; + offsets[3] = 0; + } + + if (up && up[1][2] > 0 && offsets[0] > 0) { + up[1][2] = 0; + offsets = offsets.slice() as Offsets; + offsets[0] = 0; + } + + if (leftTop && leftTop[1][1] > 0 && offsets[3] > 0) { + leftTop[1][1] = 0; + offsets = offsets.slice() as Offsets; + offsets[3] = 0; + } + + if (rightTop && rightTop[1][3] > 0 && offsets[1] > 0) { + rightTop[1][3] = 0; + offsets = offsets.slice() as Offsets; + offsets[1] = 0; + } + return [vector, offsets]; + }); + + return ( +
{ + event.preventDefault(); + cancel( + parseVector( + (event.target as HTMLElement)?.getAttribute('data-vector'), + ), + toTransformOrigin(event), + false, + ); + }} + ref={maskRef} + {...props()} + > + {fields.map(([vector, [up, right, down, left, priority]]) => ( +
enter(vector, undefined, 'pointer')} + style={{ + height: size + up + down, + left: (vector.x - 1) * size - left, + position: 'absolute', + top: (vector.y - 1) * size - up, + width: size + right + left, + zIndex: zIndex + priority, + }} + /> + ))} +
+ ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + attackable, + currentViewer, + enter, + expand, + map.buildings, + map.size, + map.units, + props, + radius, + select, + size, + zIndex, + ]); +}); + +const maskStyle = css` + cursor: none; + + &.${MaskPointerClassName} { + cursor: pointer; + } +`; diff --git a/hera/MaskWithSubtiles.tsx b/hera/MaskWithSubtiles.tsx new file mode 100644 index 00000000..83280dbb --- /dev/null +++ b/hera/MaskWithSubtiles.tsx @@ -0,0 +1,68 @@ +import { DecoratorsPerSide } from '@deities/athena/map/Configuration.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import { css, cx } from '@emotion/css'; +import React, { memo, useMemo } from 'react'; +import maskClassName, { MaskPointerClassName } from './lib/maskClassName.tsx'; +import { BaseMaskProps } from './Mask.tsx'; +import { MapEnterType } from './Types.tsx'; + +export default memo(function MaskWithSubtiles({ + enter, + map, + maskRef, + select, + tileSize: size, + zIndex, +}: Omit & { + enter: (vector: Vector, subVector: Vector, type: MapEnterType) => void; + select: (vector: Vector, subVector: Vector) => void; +}) { + const decoratorSize = size / DecoratorsPerSide; + return useMemo( + () => ( +
+ {map + .mapFields((vector) => { + const list = []; + for (let x = 1; x <= DecoratorsPerSide; x++) { + for (let y = 1; y <= DecoratorsPerSide; y++) { + const subVector = vec( + (vector.x - 1) * DecoratorsPerSide + x, + (vector.y - 1) * DecoratorsPerSide + y, + ); + list.push( +
select(vector, subVector)} + onPointerEnter={() => enter(vector, subVector, 'pointer')} + style={{ + height: decoratorSize, + left: (subVector.x - 1) * decoratorSize, + position: 'absolute', + top: (subVector.y - 1) * decoratorSize, + width: decoratorSize, + zIndex, + }} + />, + ); + } + } + return list; + }) + .flat()} +
+ ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [decoratorSize, enter, map.size, select, zIndex], + ); +}); + +const maskStyle = css` + cursor: none; + + &.${MaskPointerClassName} { + cursor: pointer; + } +`; diff --git a/hera/Radius.tsx b/hera/Radius.tsx new file mode 100644 index 00000000..3d6ac639 --- /dev/null +++ b/hera/Radius.tsx @@ -0,0 +1,467 @@ +import { Ability } from '@deities/athena/info/Unit.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import isPlayable from '@deities/athena/map/isPlayable.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import SpriteVector from '@deities/athena/map/SpriteVector.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { RadiusItem } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { CSSVariables } from '@deities/ui/cssVar.tsx'; +import { css, cx, keyframes } from '@emotion/css'; +import { motion } from 'framer-motion'; +import React, { memo } from 'react'; +import { GetLayerFunction } from './Types.tsx'; + +export enum RadiusType { + Attack, + Attackable, + Defense, + Escort1, + Escort2, + Escort3, + Highlight, + Lightning, + Move, + Rescue, + Sabotage, +} + +export type RadiusInfo = Readonly<{ + dim?: boolean | null; + fields: ReadonlyMap; + focus?: 'building' | 'unit'; + locked?: boolean | null; + path: ReadonlyArray | null; + type: RadiusType; +}>; + +const Item = memo(function Item({ + biome, + dim, + fields, + getLayer, + position, + size, + type, +}: { + biome: Biome; + dim?: boolean | null; + fields: ReadonlyMap; + getLayer: GetLayerFunction; + position: Vector; + size: number; + type: RadiusType; +}) { + const borderStyles = []; + if (type !== RadiusType.Attackable) { + const down = fields.has(position.down()); + const left = fields.has(position.left()); + const right = fields.has(position.right()); + const up = fields.has(position.up()); + if (!left) { + borderStyles.push(border.left); + } + if (!right) { + borderStyles.push(border.right); + } + if (!up) { + borderStyles.push(border.top); + } + if (!down) { + borderStyles.push(border.bottom); + } + + if (!down && !left) { + borderStyles.push(borderRadius.bottomLeft); + } + if (!down && !right) { + borderStyles.push(borderRadius.bottomRight); + } + if (!up && !left) { + borderStyles.push(borderRadius.topLeft); + } + if (!up && !right) { + borderStyles.push(borderRadius.topRight); + } + } + + const isEscort = + type === RadiusType.Escort1 || + type === RadiusType.Escort2 || + type === RadiusType.Escort3; + + return ( + + ); +}); + +const getArrowPath = ( + originalPath: ReadonlyArray | null, + from: Vector | undefined | null, + size: number, +) => { + if (!originalPath || !originalPath.length || !from) { + return null; + } + + const first = originalPath[0]; + const path = [ + new SpriteVector( + from.x + (first.x - from.x) * 0.65, + from.y + (first.y - from.y) * 0.65, + ), + ]; + + // First, add additional half-steps to the path. + for (let i = 0; i < originalPath.length; i++) { + const current = originalPath[i]; + const after = originalPath[i + 1]; + path.push(new SpriteVector(current.x, current.y)); + if (after) { + path.push( + new SpriteVector( + current.x + (after.x - current.x) / 2, + current.y + (after.y - current.y) / 2, + ), + ); + } + } + + // Then calculate the control points for curves and modify the path. + const controlPoints: Array = [null, null]; + for (let i = 1; i < path.length - 1; i++) { + const before = path[i - 1]; + const after = path[i + 1]; + const delta = new SpriteVector(before.x - after.x, before.y - after.y); + if (delta.x !== 0 && delta.y !== 0) { + controlPoints.push(path[i]); + path.splice(i, 1); + } else { + controlPoints.push(null); + } + } + + const last = path.at(-1)!; + const secondToLast = path.at(-2) || first; + return [ + new SpriteVector( + from.x + (first.x - from.x) * 0.63, + from.y + (first.y - from.y) * 0.63, + ), + ...path.slice(0, -1), + new SpriteVector( + last.x + (secondToLast.x - last.x) * 0.1, + last.y + (secondToLast.y - last.y) * 0.1, + ), + ] + .map((vector, index) => { + const control: Vector | null = controlPoints[index]; + return `${ + index + ? control + ? `Q${(control.x - 0.5) * size},${(control.y - 0.5) * size} ` + : 'L' + : 'M' + }${(vector.x - 0.5) * size},${(vector.y - 0.5) * size}`; + }) + .join(''); +}; + +const color = '#fff'; +const width = 4; + +export default memo(function Radius({ + currentViewer, + getLayer, + map, + radius: { dim, fields, path, type }, + selectedPosition, + selectedUnit, + size, + vision, +}: { + currentViewer: PlayerID | null; + getLayer: GetLayerFunction; + map: MapData; + radius: RadiusInfo; + selectedPosition?: Vector | null; + selectedUnit?: Unit | null; + size: number; + vision: VisionT; +}) { + const showCircle = path?.length === 1 && selectedPosition?.equals(path[0]); + + let subPath: ReadonlyArray | null = null; + if (selectedPosition && path) { + let index: number; + for (index = path.length - 1; index >= 0; index--) { + const vector = path[index]; + const unit = map.units.get(vector); + if ( + !( + (unit && + vision.isVisible(map, vector) && + selectedUnit && + (!isPlayable(map, currentViewer, unit) || + !unit.info.canTransport( + selectedUnit.info, + map.getTileInfo(vector), + ))) || + (map.buildings.has(vector) && + selectedUnit && + !selectedUnit.info.hasAbility(Ability.AccessBuildings)) + ) + ) { + break; + } + } + subPath = path.slice(0, index + 1); + } + const arrowPath = !showCircle + ? getArrowPath(subPath, selectedPosition, size) + : null; + + return ( + <> + {[...fields.values()].map(({ vector }) => ( + + getLayer( + y + + (vision.isVisible(map, vector) && map.units.has(vector) + ? 0 + : 1), + 'radius', + ) + } + key={'r' + String(vector)} + position={vector} + size={size} + type={type} + /> + ))} + {showCircle || arrowPath ? ( + + + + + + + + + ) : null} + + ); +}); + +const vars = new CSSVariables< + 'opacity' | 'color' | 'background-light' | 'x' | 'y' +>('r'); + +const itemStyle = css` + ${vars.set('opacity', 0.4)} + ${vars.set( + 'background-light', + `rgba(${vars.apply('color')}, calc(${vars.apply('opacity')} / 2))`, + )} + + background-position-x: 0; + background-color: rgba(${vars.apply('color')}, ${vars.apply('opacity')}); + background-repeat: no-repeat; + border: solid 0 rgba(${vars.apply('color')}, 0.6); + mask: repeating-linear-gradient( + to top left, + black, + black 1px, + ${vars.apply('background-light')} 0%, + ${vars.apply('background-light')} 2.13px + ); + pointer-events: none; + position: absolute; + transform: translate3d(${vars.apply('x')}, ${vars.apply('y')}, 0); +`; + +const colors: Record = { + [RadiusType.Attack]: css` + ${vars.set('color', '210, 18, 24')} + `, + [RadiusType.Attackable]: ``, + [RadiusType.Defense]: ``, + [RadiusType.Rescue]: ``, + [RadiusType.Escort1]: css` + ${vars.set('opacity', 0.5)} + ${vars.set('color', '230, 230, 0')} + `, + [RadiusType.Escort2]: css` + ${vars.set('opacity', 0.5)} + ${vars.set('color', '195, 33, 127')} + `, + [RadiusType.Escort3]: css` + ${vars.set('opacity', 0.5)} + ${vars.set('color', '0, 230, 230')} + `, + [RadiusType.Highlight]: ``, + [RadiusType.Lightning]: css` + ${vars.set('color', '139, 23, 133')} + ${vars.set('opacity', 0.6)} + + border-radius: 8px; + animation: ${keyframes` + 0% { + transform: translate3d(${vars.apply( + 'x', + )}, ${vars.apply('y')}, 0) scale(1, 1); + } + 50% { + transform: translate3d(${vars.apply( + 'x', + )}, ${vars.apply('y')}, 0) scale(1.1, 1.1); + } + 100% { + transform: translate3d(${vars.apply( + 'x', + )}, ${vars.apply('y')}, 0) scale(0.9, 0.9); + } + `} ease-in-out 2s infinite alternate; + `, + [RadiusType.Move]: css` + ${vars.set('color', '19, 19, 209')} + `, + [RadiusType.Sabotage]: ``, +}; + +const alternateAttackStyle = css` + ${vars.set('color', '255, 215, 0')} +`; + +const animationStyle = css` + animation-duration: 20s; + animation-iteration-count: infinite; + animation-timing-function: linear; + animation-name: ${keyframes` + from { + stroke-dashoffset: 100; + } + to { + stroke-dashoffset: 0; + } +`}; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3)); + stroke-dasharray: 6 8; +`; + +const dimStyle = css` + ${vars.set('opacity', 0.2)} +`; + +const borderRadius = { + bottomLeft: css` + border-bottom-left-radius: 2px; + `, + bottomRight: css` + border-bottom-right-radius: 2px; + `, + topLeft: css` + border-top-left-radius: 2px; + `, + topRight: css` + border-top-right-radius: 2px; + `, +}; + +const animateStyle = css` + mask-size: 200% 200%; + animation: ${keyframes` + 0% { + mask-position: 200% 200%; + } + 100% { + mask-position: 0% 0%; + } + `} linear 40s infinite; +`; + +const border = { + bottom: css` + border-bottom-width: 1px; + `, + left: css` + border-left-width: 1px; + `, + right: css` + border-right-width: 1px; + `, + top: css` + border-top-width: 1px; + `, +}; diff --git a/hera/Tick.tsx b/hera/Tick.tsx new file mode 100644 index 00000000..9847fdc9 --- /dev/null +++ b/hera/Tick.tsx @@ -0,0 +1,90 @@ +import { + SeaAnimation, + ShipyardConstructionSiteDecorator, +} from '@deities/athena/info/Tile.tsx'; +import type { AnimationConfig } from '@deities/athena/map/Configuration.tsx'; +import cssVar, { applyVar, CSSVariables } from '@deities/ui/cssVar.tsx'; +import React, { ReactNode, useCallback, useRef } from 'react'; +import { getIdleFrame, useTick } from './lib/tick.tsx'; + +const UnitAnimation = { + animation: { frames: 8, offset: 1, ticks: 1 }, +}; +const UnitAttackStanceAnimation = { + animation: { frames: 4, offset: 1, ticks: 1 }, +}; +const BuildingAnimation = { + animation: { ...SeaAnimation, offset: 1 }, +}; + +const TileDecoratorAnimation = { + animation: ShipyardConstructionSiteDecorator.animation!, +}; + +export default function Tick({ + animationConfig: { AnimationDuration, UnitAnimationStep, UnitMoveDuration }, + children, + className, + paused, +}: { + animationConfig: AnimationConfig; + children: ReactNode; + className?: string; + paused?: boolean; +}) { + const ref = useRef(null); + useTick( + paused, + useCallback((tick: number) => { + if (ref.current) { + const style = ref.current.style; + const attackStance = getIdleFrame(UnitAttackStanceAnimation, tick); + style.setProperty( + vars.set('unit'), + '' + getIdleFrame(UnitAnimation, tick), + ); + style.setProperty( + vars.set('unit-attack-stance'), + attackStance != null ? '' + (4 + attackStance) : '', + ); + style.setProperty( + vars.set('building'), + '' + getIdleFrame(BuildingAnimation, tick), + ); + style.setProperty( + vars.set('tile-decorator'), + '' + getIdleFrame(TileDecoratorAnimation, tick), + ); + } + }, []), + [], + ); + + return ( +
+ {children} +
+ ); +} + +const vars = (Tick.vars = new CSSVariables< + 'unit' | 'unit-attack-stance' | 'building' | 'tile-decorator' +>('i')); diff --git a/hera/TileDecorator.tsx b/hera/TileDecorator.tsx new file mode 100644 index 00000000..a8c31e8b --- /dev/null +++ b/hera/TileDecorator.tsx @@ -0,0 +1,84 @@ +import { TileInfo } from '@deities/athena/info/Tile.tsx'; +import { Modifier } from '@deities/athena/lib/Modifier.tsx'; +import SpriteVector from '@deities/athena/map/SpriteVector.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import { isSafari } from '@deities/ui/Browser.tsx'; +import { css, cx } from '@emotion/css'; +import { Sprites } from 'athena-crisis:images'; +import React, { memo } from 'react'; +import Tick from './Tick.tsx'; + +const defaultPosition = vec(1, 1); + +// Add 0.1px to prevent a tiny gap between the tile and the decorator. +// This washes out pixels completely in Safari, so we only do this in Chrome. +const pixelGap = isSafari ? 0 : 0.1; + +export default memo(function TileDecorator({ + absolute, + dim, + fade, + modifier, + position, + size, + tile, + zIndex, +}: { + absolute?: boolean; + dim?: boolean | null; + fade?: boolean; + modifier: Modifier; + position?: Vector; + size: number; + tile: TileInfo; + zIndex?: number; +}) { + const { decorator } = tile.style; + if (!decorator) { + return null; + } + + let modifierVector = tile.sprite.modifiers.get(modifier); + if (Array.isArray(modifierVector)) { + modifierVector = new SpriteVector(0, 0); + } + + const { x, y } = position || defaultPosition; + const positionX = decorator.position.x + (modifierVector?.x || 0); + const positionY = decorator.position.y + (modifierVector?.y || 0); + + return ( +
+ ); +}); + +const absoluteStyle = css` + pointer-events: none; + position: absolute; +`; + +const fadeStyle = css` + mask-image: linear-gradient( + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.65) 65%, + rgba(0, 0, 0, 1) 85% + ); + mask-type: alpha; +`; diff --git a/hera/TileDecorators.tsx b/hera/TileDecorators.tsx new file mode 100644 index 00000000..7b4499dd --- /dev/null +++ b/hera/TileDecorators.tsx @@ -0,0 +1,76 @@ +import { getTileInfo, StormCloud } from '@deities/athena/info/Tile.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { RadiusInfo } from './Radius.tsx'; +import TileDecorator from './TileDecorator.tsx'; +import { GetLayerFunction } from './Types.tsx'; + +export default function TileDecorators({ + getLayer, + map, + radius, + tileSize, + vector, + vision, +}: { + getLayer: GetLayerFunction; + isVisible: boolean; + map: MapData; + radius?: RadiusInfo | null; + tileSize: number; + vector: Vector; + vision: VisionT; +}) { + const { + config: { biome }, + } = map; + const up = vector.up(); + const layer0Tile = map.getTileInfo(vector, 0); + const layer1TileID = map.getTile(vector, 1); + const layer1Tile = (layer1TileID && getTileInfo(layer1TileID)) || null; + + const list = []; + const showDecorators = + !map.buildings.has(vector) && + (!layer1Tile || + layer1Tile !== StormCloud || + map.getTile(up, 1) !== StormCloud.id); + if (showDecorators) { + const isVisibleAbove = vision.isVisible(map, up); + const hasUnitAbove = isVisibleAbove && map.units.has(up); + const dim = + hasUnitAbove && radius?.fields.has(vector) && radius.fields.has(up); + if (layer0Tile?.style.decorator?.isVisible(biome)) { + list.push( + , + ); + } + if (layer1Tile?.style.decorator?.isVisible(biome)) { + list.push( + , + ); + } + } + return list; +} diff --git a/hera/Tiles.tsx b/hera/Tiles.tsx new file mode 100644 index 00000000..6e4ecb32 --- /dev/null +++ b/hera/Tiles.tsx @@ -0,0 +1,300 @@ +import { spriteImage } from '@deities/art/Sprites.tsx'; +import { Shelter } from '@deities/athena/info/Building.tsx'; +import { + Campsite, + getFloatingEdgeAnimation, + getTileInfo, + TileInfo, + TileLayer, +} from '@deities/athena/info/Tile.tsx'; +import getBiomeStyle from '@deities/athena/lib/getBiomeStyle.tsx'; +import getFloatingEdgeModifier from '@deities/athena/lib/getFloatingEdgeModifier.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData, { ModifierField } from '@deities/athena/MapData.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import useVisibilityState from '@deities/ui/hooks/useVisibilityState.tsx'; +import { + Tiles0, + Tiles1, + Tiles2, + Tiles3, + Tiles4, + Tiles5, + Tiles6, +} from 'athena-crisis:images'; +import { memo, useLayoutEffect, useRef } from 'react'; +import { useSprites } from './hooks/useSprites.tsx'; +import tick, { getFrame, getTick } from './lib/tick.tsx'; +import renderFloatingTile from './render/renderFloatingTile.tsx'; +import renderTile from './render/renderTile.tsx'; + +export type TileStyle = 'floating' | 'clip' | 'none'; + +const clip = ( + context: CanvasRenderingContext2D, + size: number, + map: MapData, +) => { + const { + config: { biome }, + size: { height, width }, + } = map; + const right = (width + 1) * size - 1; + const bottom = (height + 1) * size - 1; + context.clearRect(size, size, 1, 1); + context.clearRect(size, bottom, 1, 1); + context.clearRect(right, bottom, 1, 1); + + const { + sprite: { noClip }, + } = map.getTileInfo(vec(width, height), 0); + if (!noClip && noClip !== biome) { + context.clearRect(right, size, 1, 1); + } +}; + +const sprites = { + [Biome.Grassland]: Tiles0, + [Biome.Desert]: Tiles1, + [Biome.Snow]: Tiles2, + [Biome.Swamp]: Tiles3, + [Biome.Spaceship]: Tiles4, + [Biome.Volcano]: Tiles5, + [Biome.Luna]: Tiles6, +} as const; + +const createCanvas = ( + mapSize: { height: number; width: number }, + size: number, +) => { + const canvas = document.createElement('canvas'); + canvas.height = (mapSize.height + 2) * size; + canvas.width = (mapSize.width + 2) * size; + return canvas; +}; + +export default memo(function Tiles({ + map, + paused, + renderEntities = true, + style, + tileSize: size, + vision, +}: { + map: MapData; + paused?: boolean; + renderEntities?: boolean; + style?: TileStyle; + tileSize: number; + vision: VisionT; +}) { + const ref = useRef(null); + const canvasRefs = useRef>([]); + const isVisible = useVisibilityState(); + const hasSprites = useSprites('all'); + const { biome } = map.config; + const biomeStyle = getBiomeStyle(biome); + + useLayoutEffect(() => { + if (!hasSprites) { + return; + } + + if (!canvasRefs.current.length) { + canvasRefs.current = [ + createCanvas(map.size, size), + createCanvas(map.size, size), + ]; + } + + const tileset = { + buildings: spriteImage('BuildingsShadow', biome), + structures: spriteImage('StructuresShadow', biome), + tiles: sprites[biome], + }; + + const [visibleCanvas, mainCanvas] = canvasRefs.current; + const context = visibleCanvas.getContext('2d')!; + const mainContext = mainCanvas.getContext('2d')!; + const currentTick = getTick(); + + context.clearRect(0, 0, visibleCanvas.width, visibleCanvas.height); + mainContext.clearRect(0, 0, mainCanvas.width, mainCanvas.height); + + map.forEachTile( + (vector: Vector, tile: TileInfo, layer: TileLayer, modifier: number) => { + renderTile( + context, + tileset, + map, + vision, + (!paused && getFrame(tile.sprite, modifier, currentTick)) || 0, + vector, + tile, + modifier, + size, + renderEntities, + ); + }, + ); + + const floatingTiles = new Map(); + if (style === 'floating') { + for (let x = 0; x <= map.size.width + 1; x++) { + for (let y = 0; y <= map.size.height + 1; y++) { + const vector = vec(x, y); + const modifier = getFloatingEdgeModifier(map, vector); + if (modifier) { + const modifier0 = Array.isArray(modifier) ? modifier[0] : modifier; + if (getFloatingEdgeAnimation(modifier0, map.config.biome)) { + floatingTiles.set(vector, modifier); + } + renderFloatingTile( + context, + mainContext, + tileset, + map, + vision, + vector, + modifier, + currentTick, + size, + paused, + ); + } + } + } + } + + mainContext.drawImage(visibleCanvas, 0, 0); + + if (style === 'clip') { + clip(mainContext, size, map); + } + + if (ref.current) { + ref.current.innerHTML = ''; + if (!mainCanvas.parentNode) { + ref.current.append(mainCanvas); + } + } + + if (!paused && isVisible) { + return tick((tick) => { + map.forEachField((vector: Vector) => { + const tile = + map.buildings.get(vector)?.info === Shelter + ? Campsite + : map.getTileInfo(vector, 0); + const modifier = map.getModifier(vector, 0); + const frame = getFrame(tile.sprite, modifier, tick); + + const layer1Tile = map.getTile(vector, 1); + const layer1TileInfo = + layer1Tile != null ? getTileInfo(layer1Tile) : null; + + const renderLayer0 = + frame != null || + (layer1TileInfo?.sprite?.animation && !tile.sprite.animation); + if (renderLayer0) { + mainContext.clearRect(vector.x * size, vector.y * size, size, size); + context.clearRect(vector.x * size, vector.y * size, size, size); + renderTile( + context, + tileset, + map, + vision, + frame || 0, + vector, + tile, + modifier, + size, + renderEntities, + ); + } + + if ( + layer1TileInfo && + (renderLayer0 || layer1TileInfo?.sprite.animation) + ) { + const layer1Modifier = map.getModifier(vector, 1); + renderTile( + context, + tileset, + map, + vision, + getFrame(layer1TileInfo.sprite, layer1Modifier, tick) || 0, + vector, + layer1TileInfo, + layer1Modifier, + size, + renderEntities, + ); + } + }); + + for (const [vector, modifier] of floatingTiles) { + renderFloatingTile( + context, + mainContext, + tileset, + map, + vision, + vector, + modifier, + tick, + size, + paused, + true, + ); + } + + mainContext.drawImage(visibleCanvas, 0, 0); + if (style === 'clip') { + clip(mainContext, size, map); + } + }); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + biome, + biomeStyle.palette, + hasSprites, + isVisible, + map.buildings, + map.config, + map.currentPlayer, + map.map, + map.modifiers, + map.size, + paused, + renderEntities, + size, + style, + vision, + ]); + + return ( +
+
+
+ ); +}); diff --git a/hera/Types.tsx b/hera/Types.tsx new file mode 100644 index 00000000..e46769d2 --- /dev/null +++ b/hera/Types.tsx @@ -0,0 +1,287 @@ +import { Action, MutateActionResponseFn } from '@deities/apollo/Action.tsx'; +import { ActionResponse } from '@deities/apollo/ActionResponse.tsx'; +import { + GameActionResponse, + GameActionResponses, +} from '@deities/apollo/Types.tsx'; +import { DecoratorInfo } from '@deities/athena/info/Decorator.tsx'; +import { Skill } from '@deities/athena/info/Skill.tsx'; +import { TileInfo } from '@deities/athena/info/Tile.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import Building from '@deities/athena/map/Building.tsx'; +import type { AnimationConfig } from '@deities/athena/map/Configuration.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import MapData, { ModifierField } from '@deities/athena/MapData.tsx'; +import { RadiusItem } from '@deities/athena/Radius.tsx'; +import { VisionT } from '@deities/athena/Vision.tsx'; +import { NavigationDirection } from '@deities/ui/controls/Input.tsx'; +import type { ReactElement, ReactNode } from 'react'; +import { ConfirmProps } from './behavior/confirm/ConfirmAction.tsx'; +import { EditorState, SetEditorStateFunction } from './editor/Types.tsx'; +import { Animations } from './MapAnimations.tsx'; +import { RadiusInfo } from './Radius.tsx'; +import { TileStyle } from './Tiles.tsx'; + +export type Size = Readonly<{ + height: number; + width: number; +}>; + +export type AnimationConfigs = readonly [ + normal: AnimationConfig, + player: AnimationConfig, + fast: AnimationConfig, + fastPlayer: AnimationConfig, +]; + +export type Props = Readonly<{ + animatedChildren?: (state: State) => ReactNode; + animationConfig?: AnimationConfigs | AnimationConfig; + behavior?: MapBehaviorConstructor | null; + buildingSize: number; + children?: (state: State, actions: Actions) => ReactNode; + className?: string; + confirmActionStyle: 'always' | 'touch' | 'never'; + currentUserId: string; + dangerouslyApplyExternalState?: boolean; + editor?: EditorState; + endGame?: (type: 'Lose') => void; + events?: EventTarget; + factionNames: FactionNames; + fogStyle: 'soft' | 'hard'; + gameInfoPanels?: GameInfoPanels; + inset?: number; + lastActionResponse?: ActionResponse | null; + lastActionTime?: number; + map: MapData; + mapName?: string; + margin?: 'minimal'; + mutateAction?: MutateActionResponseFn; + onAction?: (action: Action) => Promise; + onError?: (error: Error) => void; + pan?: true; + paused?: boolean; + scale: number; + scroll?: boolean; + setEditorState?: SetEditorStateFunction; + showCursor: boolean; + skipBanners?: boolean; + spectatorCodes?: ReadonlyArray; + style: TileStyle; + tileSize: number; + tilted: boolean; + timeout?: number | null; + unitSize: number; + userDisplayName: string; +}>; + +export type TimerState = Readonly<{ + delay: number; + // eslint-disable-next-line @typescript-eslint/ban-types + fn: Function; + start: number; + timer: number; +}>; + +export type ReplayState = Readonly<{ + isLive: boolean; + isPaused: boolean; + isReplaying: boolean; + isWaiting: boolean; + pauseStart: number | null; +}>; + +export type State = Readonly<{ + additionalRadius: RadiusInfo | null; + animationConfig: AnimationConfig; + animations: Animations; + attackable: ReadonlyMap | null; + behavior: MapBehavior | null; + confirmAction?: ConfirmProps | null; + currentUserId: string; + currentViewer: PlayerID | null; + factionNames: FactionNames; + gameInfoState: GameInfoState | null; + initialBehaviorClass: MapBehaviorConstructor | undefined | null; + inlineUI: boolean; + lastActionResponse: ActionResponse | null; + lastActionTime?: number; + map: MapData; + mapName?: string; + namedPositions: ReadonlyArray | null; + navigationDirection: NavigationDirection | null; + paused: boolean; + position: Vector | null; + preventRemoteActions: boolean; + previousPosition: Vector | null; + radius: RadiusInfo | null; + replayState: ReplayState; + selectedAttackable: Vector | null; + selectedBuilding: Building | null; + selectedPosition: Vector | null; + selectedUnit: Unit | null; + showCursor: boolean; + tileSize: number; + timeout: number | null; + vision: VisionT; + winConditionRadius: ReadonlyArray | null; + zIndex: number; +}>; + +export type StateLike = Partial; + +export type FactionNames = ReadonlyMap; +export type TimerID = Promise; +// eslint-disable-next-line @typescript-eslint/ban-types +export type TimerFunction = (fn: Function, delay: number) => TimerID; +export type RequestFrameFunction = (fn: (timestamp: number) => void) => void; +export type ClearTimerFunction = (timer: number | TimerID) => void; +export type UpdateFunction = ( + newState: StateLike | null | ((state: State) => StateLike | null), +) => Promise; +export type GetLayerFunction = (y: number, type: LayerTypes) => number; +export type LayerTypes = + | 'building' + | 'radius' + | 'decorator' + | 'unit' + | 'animation' + | 'top'; + +export type GameInfoPanels = Map< + string, + Readonly<{ + content: ReactNode; + title: ReactNode; + }> +>; + +export type CurrentGameInfoState = Readonly<{ + origin: string; + panels?: GameInfoPanels; + type: 'game-info'; +}>; + +export type MapInfoState = Readonly<{ + biome: Biome; + building?: Building | null; + buildingPlayer?: PlayerID; + create?: () => void; + decorators?: ReadonlyMap | null; + modifierField?: ModifierField; + origin: string; + tile?: TileInfo | null; + type: 'map-info'; + unit?: Unit | null; + vector: Vector; +}>; + +export type SkillInfoState = Readonly<{ + action?: (skill: Skill) => void; + actionName?: ReactElement; + canAction?: (skill: Skill) => boolean; + currentSkill: Skill; + origin: string; + showAction?: (skill: Skill) => boolean; + showCost?: boolean; + skills?: ReadonlyArray; + type: 'skill'; +}>; + +export type GameInfoState = + | CurrentGameInfoState + | MapInfoState + | SkillInfoState; + +export type Actions = Readonly<{ + action: ( + state: State, + action: Action, + ) => [Promise, MapData, ActionResponse]; + clearTimer: ClearTimerFunction; + fastForward: () => () => void; + optimisticAction: (state: State, action: Action) => ActionResponse; + pauseReplay: () => Promise; + processGameActionResponse: ( + gameActionResponse: GameActionResponse, + ) => Promise; + requestFrame: RequestFrameFunction; + resetPosition: () => void; + resumeReplay: () => Promise; + scheduleTimer: TimerFunction; + scrollIntoView: (vectors: ReadonlyArray) => Promise; + setEditorState: SetEditorStateFunction; + showGameInfo: (gameInfoState: GameInfoState) => void; + update: UpdateFunction; +}>; + +export type StateWithActions = Readonly<{ + actions: Actions; + editor?: EditorState; + state: State; +}>; + +export type MapEnterType = 'pointer' | 'synthetic'; + +export type MapBehavior = Readonly<{ + readonly activate?: ( + state: State, + actions?: Actions, + shouldConfirm?: boolean, + ) => StateLike | null; + readonly clearTimers?: () => void; + readonly component?: (props: StateWithActions) => JSX.Element | null; + readonly deactivate?: () => StateLike | null; + readonly enter?: ( + vector: Vector, + state: State, + actions: Actions, + editor?: EditorState, + subVector?: Vector, + ) => StateLike | null; + readonly enterAlternative?: ( + vector: Vector, + state: State, + actions: Actions, + editor: EditorState, + ) => StateLike | null; + readonly navigate?: boolean; + readonly select?: ( + vector: Vector, + state: State, + actions: Actions, + editor?: EditorState, + subVector?: Vector, + shouldConfirm?: boolean, + ) => StateLike | null; + readonly type: + | 'attack' + | 'attackRadius' + | 'base' + | 'buySkills' + | 'createBuilding' + | 'createUnit' + | 'design' + | 'dropUnit' + | 'entity' + | 'heal' + | 'menu' + | 'move' + | 'null' + | 'radar' + | 'rescue' + | 'sabotage' + | 'transport' + | 'vector'; +}>; + +export type MapBehaviorConstructor = { new (): MapBehavior }; + +export type StateToStateLike = (state: State) => StateLike | null; + +export type ActionsProcessedEventDetail = Readonly<{ + gameActionResponses: GameActionResponses; + map: MapData; +}>; diff --git a/hera/Unit.tsx b/hera/Unit.tsx new file mode 100644 index 00000000..5779c04d --- /dev/null +++ b/hera/Unit.tsx @@ -0,0 +1,1214 @@ +import { AttackDirection } from '@deities/apollo/attack-direction/getAttackDirection.tsx'; +import { MovementType } from '@deities/athena/info/MovementType.tsx'; +import { isSea, TileInfo } from '@deities/athena/info/Tile.tsx'; +import { UnitInfo } from '@deities/athena/info/Unit.tsx'; +import hasLowAmmoSupply from '@deities/athena/lib/hasLowAmmoSupply.tsx'; +import isFuelConsumingUnit from '@deities/athena/lib/isFuelConsumingUnit.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { + AnimationConfig, + MaxHealth, +} from '@deities/athena/map/Configuration.tsx'; +import { PlayerID } from '@deities/athena/map/Player.tsx'; +import SpriteVector from '@deities/athena/map/SpriteVector.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import AudioPlayer from '@deities/ui/AudioPlayer.tsx'; +import { applyVar, CSSVariables } from '@deities/ui/cssVar.tsx'; +import pixelBorder from '@deities/ui/pixelBorder.tsx'; +import { css, cx, keyframes } from '@emotion/css'; +import { ShadowImages, Sprites } from 'athena-crisis:images'; +import { useEffect, useRef } from 'react'; +import { AnimationDirection } from './animations/Animation.tsx'; +import Label from './Label.tsx'; +import getFlashDelay from './lib/getFlashDelay.tsx'; +import getUnitDirection from './lib/getUnitDirection.tsx'; +import sprite from './lib/sprite.tsx'; +import { + AttackAnimation, + MoveAnimation, + UnfoldAnimation, + UnitAnimation, + UnitExplosionAnimation, + UnitHealAnimation, +} from './MapAnimations.tsx'; +import Tick from './Tick.tsx'; +import { + GetLayerFunction, + RequestFrameFunction, + TimerFunction, +} from './Types.tsx'; + +enum ActionStyle { + Capture = '-35px', + Rescue = '-42px', + Transport = '-28px', +} + +enum FuelStyle { + Low = '0px', + None = '-7px', +} + +enum AmmoStyle { + Low = '-14px', + None = '-21px', +} + +const getFuelStyle = (unit: Unit) => { + return unit.fuel === 0 + ? FuelStyle.None + : unit.fuel <= unit.info.configuration.fuel * 0.25 + ? FuelStyle.Low + : null; +}; + +const getAmmoStyle = (unit: Unit) => { + const { ammo } = unit; + if (ammo?.size) { + if (ammo.size === 1) { + const [weaponA, supplyA] = ammo.entries().next().value; + return supplyA === 0 + ? AmmoStyle.None + : hasLowAmmoSupply(unit, weaponA, supplyA) + ? AmmoStyle.Low + : null; + } else if (ammo.size === 2) { + const iterator = ammo.entries(); + const [weaponA, supplyA] = iterator.next().value; + const [weaponB, supplyB] = iterator.next().value; + return supplyA === 0 && supplyB === 0 + ? AmmoStyle.None + : hasLowAmmoSupply(unit, weaponA, supplyA) || + hasLowAmmoSupply(unit, weaponB, supplyB) + ? AmmoStyle.Low + : null; + } + + const ammoArray = [...ammo]; + if (ammoArray.every(([, s]) => s === 0)) { + return AmmoStyle.None; + } else if ( + ammoArray.some(([weapon, supply]) => + hasLowAmmoSupply(unit, weapon, supply), + ) + ) { + return AmmoStyle.Low; + } + } + + return null; +}; + +const Action = ({ + actionStyle, + hide, + unit, +}: { + actionStyle: ActionStyle | null; + hide: boolean; + unit: Unit; +}) => { + return unit.health && actionStyle ? ( +
+ ) : null; +}; + +const Status = ({ + actionStyle, + ammoStyle, + fuelStyle, + hide, + unit, +}: { + actionStyle: ActionStyle | null; + ammoStyle: AmmoStyle | null; + fuelStyle: FuelStyle | null; + hide: boolean; + unit: Unit; +}) => { + const hasOne = !!(fuelStyle || ammoStyle); + return ( + <> + + {unit.health ? ( +
+ ) : null} + + ); +}; + +const Health = ({ + hasStatus, + hide, + unit: { health }, +}: { + hasStatus: boolean; + hide: boolean; + unit: Unit; +}) => { + return health > 0 && health < MaxHealth ? ( +
+ ) : null; +}; + +const getSpritePosition = ( + unit: Unit, + animation: UnitAnimation | undefined, + tile: TileInfo, +) => { + const isUnfolding = + animation?.type === 'fold' || animation?.type === 'unfold'; + const { sprite } = unit.info; + const vector = + ((isUnfolding || animation?.type === 'unitExplosion') && + animation.position) || + (unit.isUnfolded() && sprite.unfold) || + (animation?.type === 'unitHeal' && sprite.healSprite?.position) || + (animation?.type === 'attack' && sprite.attackStance + ? sprite.position.down(1) + : null) || + (unit.isTransportingUnits() && + ((unit.transports.length > 1 && sprite.transportsMany) || + sprite.transports)) || + (sprite.alternative && !isSea(tile.id) && sprite.alternative) || + sprite.position; + + return sprite.leaderAlternative && !unit.isLeader() ? vector.down(6) : vector; +}; + +const getDirection = ( + attackDirection: AttackDirection, + spriteDirection: 1 | -1, +) => { + const { direction } = attackDirection; + return direction === 'left' || direction === 'right' + ? spriteDirection === -1 + ? 'horizontalAlternative' + : 'horizontal' + : direction; +}; + +const getAnimationStyle = ( + animation: UnitAnimation | undefined, + spriteDirection: 1 | -1, +) => { + if ( + (animation?.type === 'attack' || animation?.type === 'capture') && + animation.weapon.animation.recoil + ) { + return recoilAnimationStyle[ + getDirection(animation.direction, spriteDirection) + ]; + } else if (animation?.type === 'attackUnitFlash') { + return attackFlashStyle[getDirection(animation.direction, spriteDirection)]; + } else if ( + animation?.type === 'rescue' || + animation?.type === 'sabotage' || + animation?.type === 'heal' + ) { + return flashStyle; + } + return null; +}; + +const getDirectionOffset = (info: UnitInfo, direction?: AnimationDirection) => + direction + ? (direction == 'down' ? 1 : direction == 'up' ? 2 : 0) * + info.sprite.directionOffset + : 0; + +export default function UnitTile({ + absolute, + animation, + animationConfig, + animationKey, + biome, + direction, + firstPlayerID, + getLayer = () => 0, + highlightStyle, + maybeOutline, + onAnimationComplete = () => void 0, + outline, + position = vec(1, 1), + power, + requestFrame, + scheduleTimer, + size, + tile, + unit, +}: { + absolute?: boolean; + animation?: UnitAnimation; + animationConfig: AnimationConfig; + animationKey?: Vector; + biome: Biome; + direction?: AttackDirection; + firstPlayerID: PlayerID; + getLayer?: GetLayerFunction; + highlightStyle?: 'move' | 'idle' | 'idle-null' | 'move-null' | undefined; + maybeOutline?: boolean; + onAnimationComplete?: (position: Vector, animation: UnitAnimation) => void; + outline?: 'attack' | 'sabotage' | 'defense' | 'rescue' | undefined; + position?: Vector; + power?: boolean; + requestFrame?: RequestFrameFunction; + scheduleTimer?: TimerFunction; + size: number; + tile: TileInfo; + unit: Unit; +}) { + const elementRef = useRef(null); + const innerElementRef = useRef(null); + const shadowElementRef = useRef(null); + const { info, player } = unit; + const isMoving = animation?.type === 'move'; + const isAttacking = animation?.type === 'attack'; + const hasAttackStance = isAttacking && info.sprite.attackStance; + const isSpawning = animation?.type === 'spawn' && !animationConfig.Instant; + const isAnimating = + animation && + (hasAttackStance || + animation.type === 'fold' || + animation.type === 'unfold' || + animation.type === 'unitExplosion' || + animation.type === 'unitHeal'); + const spritePosition = getSpritePosition(unit, animation, tile); + const spriteAnimationOffset = + isMoving || + isAnimating || + ((highlightStyle === 'move' || highlightStyle === 'move-null') && + player > 0 && + unit.canMove() && + !unit.isUnfolded()) + ? 0 + : 1; + + const animationOffset = + info.sprite.invert && !hasAttackStance + ? (spriteAnimationOffset - 1) * -1 * idleOffset + : spriteAnimationOffset * idleOffset; + + const animationIsLocked = + player === 0 || + (isAnimating && (animation.locked || !isFuelConsumingUnit(unit, tile))); + + // The `hasAttackStance` piece here is a hack to ensure that React will + // reset `backgroundPositionX` upon reconciliation after the animation + // completes. This is necessary because the background position gets mutated + // directly via the DOM, without React's knowledge. There is no other trigger + // within this unit for the animation complete event. + const backgroundPositionX = `calc(${hasAttackStance ? '1/1' : '1'} * ${ + animationIsLocked + ? '' + : `(${Tick.vars.apply('unit')} * ${-spriteSize}px) - ` + }${(spritePosition.x + animationOffset) * spriteSize}px)`; + const backgroundPositionY = -(spritePosition.y * spriteSize) + 'px'; + const { x, y } = isMoving ? animation.from : position; + const unitDirection = + getUnitDirection(firstPlayerID, unit) === 'left' ? -1 : 1; + const { direction: currentDirection = null } = + (animation && 'direction' in animation && animation.direction) || + direction || + {}; + + const actualUnitDirection = currentDirection + ? info.sprite.direction * (currentDirection === 'left' ? 1 : -1) + : unitDirection; + const positionOffset = + (isAnimating && animation?.offset) || info.sprite.offset || {}; + const zIndex = getLayer( + position.y + (currentDirection === 'up' && unit.health > 0 ? 1 : 0), + 'unit', + ); + const style = { + [vars.set('direction')]: '' + actualUnitDirection, + [vars.set('x')]: `${ + (x - 1) * size + (positionOffset.x || 0) * actualUnitDirection + }px`, + [vars.set('recoil-delay')]: `${ + animationConfig.ExplosionStep * + (hasAttackStance && + animation.hasAttackStance && + animation.weapon.animation.recoil + ? 8 + : isAttacking + ? animation.weapon.animation.recoilDelay + : 4) + }ms`, + [vars.set('y')]: `${(y - 1) * size + (positionOffset.y || 0)}px`, + height: size + 'px', + width: size + 'px', + // Do not cut unit off during recoil animation. + // Also use the "reset" hack because zIndex is set imperatively. + zIndex: `calc(${isMoving ? '1/1' : '1'} * ${zIndex})`, + + ...(isMoving && animation.pathVisibility?.length + ? { + opacity: animation.pathVisibility[0] ? '1' : '0', + } + : isSpawning + ? { opacity: '0' } + : null), + }; + + useEffect(() => { + if (isAnimating) { + if (!animationKey || !requestFrame || !scheduleTimer) { + throw new Error( + `Unit: 'animationKey', 'scheduleTimer' or 'requestFrame' props are missing for animation at position '${position}'.`, + ); + } + + const animate = ( + animation: + | UnfoldAnimation + | UnitExplosionAnimation + | AttackAnimation + | UnitHealAnimation, + ) => { + const { frames } = animation; + if (!frames) { + return; + } + + const complete = () => { + if (!hasAttackStance) { + requestFrame(() => onAnimationComplete(animationKey, animation)); + } + }; + + if (!elementRef.current || !innerElementRef.current) { + complete(); + return; + } + + const rate = + AnimationConfig.AnimationDuration / animationConfig.AnimationDuration; + if (animation.type === 'unitExplosion') { + AudioPlayer.playSound('Explosion/Infantry', rate); + } else if (animation.type === 'fold' || animation.type === 'unfold') { + const sound = info.sprite.unfoldSounds?.[animation.type]; + if (sound) { + AudioPlayer.playSound(sound, rate); + } + } + + const { style } = elementRef.current; + const { style: unitStyle } = innerElementRef.current; + const shadowStyle = shadowElementRef.current?.style; + + let start = 0; + const offset = animation.type === 'fold' ? animation.frames - 1 : 0; + const next = (timestamp: number) => { + if (!start) { + start = timestamp; + } + + const totalProgress = timestamp - start; + const animationStep = + Math.round(totalProgress / animationConfig.UnitAnimationStep) % + frames; + const backgroundPositionX = + -( + info.sprite.position.x + + animationOffset + + Math.abs(offset - animationStep) + ) * + spriteSize + + 'px'; + unitStyle.backgroundPositionX = backgroundPositionX; + if (shadowStyle) { + shadowStyle.backgroundPositionX = backgroundPositionX; + } + + if (animationStep < frames - 1) { + requestFrame(next); + return; + } + + if (animation.locked) { + const multiplier = + animation.type === 'unitExplosion' && + animation.fade && + !animation.withExplosion + ? 1.5 + : 0; + const backgroundPositionX = + -( + info.sprite.position.x + + animationOffset + + Math.abs(offset - animationStep) + ) * + spriteSize + + 'px'; + unitStyle.backgroundPositionX = backgroundPositionX; + if (shadowStyle) { + shadowStyle.backgroundPositionX = backgroundPositionX; + } + style.setProperty( + vars.set('transition-multiplier'), + String(multiplier), + ); + style.opacity = '0'; + scheduleTimer( + complete, + animationConfig.UnitMoveDuration * (multiplier + 1), + ); + } else if (hasAttackStance) { + if (hasAttackStance === 'short') { + const backgroundPositionX = `calc((${Tick.vars.apply( + 'unit-attack-stance', + )} * ${-spriteSize}px) - ${ + (spritePosition.x + animationOffset) * spriteSize + }px)`; + unitStyle.backgroundPositionX = backgroundPositionX; + if (shadowStyle) { + shadowStyle.backgroundPositionX = backgroundPositionX; + } + } + // `complete` is called via the weapon animation callback, so it + // does not need to be called here. + } else { + complete(); + } + }; + + const backgroundPositionX = + -(info.sprite.position.x + animationOffset + offset) * spriteSize + + 'px'; + const backgroundPositionY = + -( + spritePosition.y + + getDirectionOffset( + info, + animation.type === 'unitExplosion' || animation.type === 'attack' + ? animation.direction?.direction + : undefined, + ) + ) * + spriteSize + + 'px'; + unitStyle.backgroundPositionX = backgroundPositionX; + unitStyle.backgroundPositionY = backgroundPositionY; + if (shadowStyle) { + shadowStyle.backgroundPositionX = backgroundPositionX; + shadowStyle.backgroundPositionY = backgroundPositionY; + } + + if (animation.locked) { + scheduleTimer( + () => requestFrame(next), + animationConfig.UnitMoveDuration, + ); + } else { + requestFrame(next); + } + }; + + animate(animation); + } + + if (isMoving || (isSpawning && player > 0)) { + if (!animationKey || !scheduleTimer || !requestFrame) { + throw new Error( + `Unit: 'animationKey', 'scheduleTimer' or 'requestFrame' props are missing for animation at position '${position}'.`, + ); + } + + const move = (animation: MoveAnimation, complete: () => void) => { + let { from: position, path, pathVisibility, tiles } = animation; + if (!elementRef.current || !innerElementRef.current) { + complete(); + return; + } + const { style } = elementRef.current; + const { style: unitStyle } = innerElementRef.current; + const shadowStyle = shadowElementRef.current?.style; + const rate = + AnimationConfig.AnimationDuration / animationConfig.AnimationDuration; + const stepDuration = + animationConfig.UnitMoveDuration * (info.sprite.slow ? 1.5 : 1); + const third = stepDuration / 3; + const quarter = stepDuration / 4; + const pixelsPerStep = size / stepDuration; + let currentSpritePosition = spritePosition; + let first = true; + let isVisible: boolean | null; + let moveStart: number | null = null; + let posX: number; + let posY: number; + let start: number | null = null; + let tile: TileInfo; + let previousTile: TileInfo; + let previousType: MovementType; + let to: Vector; + let toX: number; + let toY: number; + + const setDirection = () => { + const direction = isSpawning ? 0 : toY > 0 ? 1 : toY < 0 ? 2 : 0; + const backgroundPositionY = + -( + currentSpritePosition.y + + direction * info.sprite.directionOffset + ) * + spriteSize + + 'px'; + unitStyle.backgroundPositionY = backgroundPositionY; + if (shadowStyle) { + shadowStyle.backgroundPositionY = backgroundPositionY; + } + }; + + const step = (timestamp: number) => { + if (!moveStart) { + moveStart = timestamp; + } + if (!start) { + start = timestamp; + } + + const progress = Math.min(timestamp - start, stepDuration); + style.setProperty( + vars.set('x'), + posX + + (positionOffset.x || 0) + + progress * pixelsPerStep * toX + + 'px', + ); + style.setProperty( + vars.set('y'), + posY + + (positionOffset.y || 0) + + progress * pixelsPerStep * toY + + 'px', + ); + if (progress > stepDuration / 2 && previousTile !== tile) { + currentSpritePosition = getSpritePosition(unit, animation, tile); + previousTile = tile; + setDirection(); + } + + if (progress < stepDuration) { + requestFrame(step); + } else { + next(timestamp); + } + }; + + const playSound = () => { + const { movementType } = info; + const type = + (movementType.alternative && + !isSea(tile.id) && + movementType.alternative) || + movementType; + + if (previousType && previousType.sound !== type.sound) { + AudioPlayer.stop(previousType.sound, third); + } + + previousType = type; + + if (first && animation.startSound) { + AudioPlayer.playOrContinueSound(animation.startSound, rate); + } + + // Only initiate a `playOrContinueSound` call if we won't immediately stop it. + const endSound = animation.endSound || type.endSound; + if (path.length || !endSound) { + AudioPlayer.playOrContinueSound(type.sound, rate); + } + + if (!path.length) { + scheduleTimer( + () => { + AudioPlayer.stop(type.sound, third); + if (endSound) { + AudioPlayer.playOrContinueSound(endSound, rate); + } + }, + endSound + ? endSound === type.endSound && type.endDelay === 'quarter' + ? quarter + : 0 + : third * 2, + ); + } + first = false; + }; + + const next = (timestamp: number) => { + if (to) { + position = to; + } + + to = path[0]; + tile = tiles[0]; + if (to) { + path = path.slice(1); + tiles = tiles.slice(1); + start = null; + posX = (position.x - 1) * size; + posY = (position.y - 1) * size; + toX = to.x - position.x; + toY = to.y - position.y; + setDirection(); + + playSound(); + + if (!isSpawning) { + style.zIndex = String(getLayer(to.y, 'unit')); + style.setProperty( + vars.set('direction'), + String(toX * -info.sprite.direction || 1), + ); + } + if (pathVisibility?.length) { + // `pathVisibility` has one more item than `path`. The first item + // is the "from" position, which we are skipping here but is relevant + // for the initial style of the unit. + pathVisibility = pathVisibility.slice(1); + isVisible = pathVisibility[0]; + + style.opacity = isVisible ? '1' : '0.5'; + } + step(timestamp); + return; + } + + if (!animation.partial) { + unitStyle.backgroundPositionY = backgroundPositionY; + if (shadowStyle) { + shadowStyle.backgroundPositionY = backgroundPositionY; + } + style.setProperty(vars.set('direction'), String(unitDirection)); + } + + if (pathVisibility?.length && !pathVisibility[0]) { + style.opacity = '0'; + scheduleTimer(complete, animationConfig.UnitMoveDuration); + } else { + complete(); + } + }; + + requestFrame(next); + }; + + move( + animation.type === 'spawn' + ? { + from: new SpriteVector(position.x, position.y + 1 / 12), + // This will be handled through the animation component. + onComplete: () => null, + partial: false, + path: [position], + pathVisibility: null, + tiles: [tile], + type: 'move', + } + : animation, + () => + !isSpawning && + requestFrame(() => onAnimationComplete(animationKey, animation)), + ); + } + + if (isSpawning && scheduleTimer) { + scheduleTimer(() => { + if (elementRef.current) { + elementRef.current.style.opacity = '1'; + } + }, animationConfig.ExplosionStep / 2); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animation]); + + const isCompleted = unit.isCompleted(); + const animationStyle = + getAnimationStyle(animation, info.sprite.direction) || + (!isCompleted && + power && + (maybeOutline + ? brightnessAnimationOutlineStyle + : brightnessAnimationStyle)); + + const highlightOutline = + highlightStyle && + highlightStyle !== 'idle-null' && + highlightStyle !== 'move-null' && + !isAnimating && + !isMoving && + !animationStyle; + const shadowImage = ShadowImages.get(info.sprite.name); + const innerStyle = { + backgroundPositionX, + backgroundPositionY: currentDirection + ? -(spritePosition.y + getDirectionOffset(info, currentDirection)) * + spriteSize + + 'px' + : backgroundPositionY, + ...getFlashDelay(animation, animationConfig), + }; + + const hide = !!animation; + const actionStyle = unit.isTransportingUnits() + ? ActionStyle.Transport + : unit.isCapturing() + ? ActionStyle.Capture + : unit.isBeingRescued() + ? ActionStyle.Rescue + : null; + const fuelStyle = getFuelStyle(unit); + const ammoStyle = getAmmoStyle(unit); + + return ( +
+