├── .editorconfig ├── .github ├── copilot-instructions.md └── workflows │ └── buildandtest.yml ├── .gitignore ├── .gitmodules ├── Directory.Build.props ├── Dockerfile ├── Duets.sln ├── LICENSE ├── README.md ├── src ├── Duets.Agents │ ├── Duets.Agents.fsproj │ ├── LanguageModel.fs │ ├── Log.fs │ ├── Savegame.fs │ ├── State.fs │ └── Stats.fs ├── Duets.CityExplorer │ ├── App.axaml │ ├── App.axaml.fs │ ├── Duets.CityExplorer.fsproj │ ├── MainWindow.axaml │ ├── MainWindow.axaml.fs │ ├── Program.fs │ └── app.manifest ├── Duets.Cli │ ├── Components │ │ ├── BarChart.fs │ │ ├── Calendar.fs │ │ ├── ChoicePrompt.fs │ │ ├── CityPrompt.fs │ │ ├── CommandPrompt.fs │ │ ├── Commands │ │ │ ├── Aiport │ │ │ │ ├── BoardPlane.Command.fs │ │ │ │ ├── PassSecurity.Command.fs │ │ │ │ └── WaitForLanding.Command.fs │ │ │ ├── Career │ │ │ │ └── Work.Command.fs │ │ │ ├── Cheats │ │ │ │ ├── Band.Cheats.Commands.fs │ │ │ │ ├── Cheats.Commands.fs │ │ │ │ ├── Life.Cheats.Commands.fs │ │ │ │ ├── Money.Cheats.Commands.fs │ │ │ │ ├── Skills.Cheats.Commands.fs │ │ │ │ └── World.Cheats.Commands.fs │ │ │ ├── Clock.Command.fs │ │ │ ├── Command.fs │ │ │ ├── Concert │ │ │ │ ├── BassSolo.Command.fs │ │ │ │ ├── ConcertCommon.fs │ │ │ │ ├── DoEncore.Command.fs │ │ │ │ ├── DrumSolo.Command.fs │ │ │ │ ├── FinishConcert.Command.fs │ │ │ │ ├── GetOffStage.Command.fs │ │ │ │ ├── GiveSpeech.Command.fs │ │ │ │ ├── GreetAudience.Command.fs │ │ │ │ ├── GuitarSolo.Command.fs │ │ │ │ ├── MakeCrowdSing.Command.fs │ │ │ │ ├── PerformSoundcheck.Command.fs │ │ │ │ ├── PlaySong.Commands.fs │ │ │ │ ├── SetupMerchStand.Command.fs │ │ │ │ ├── SpinDrumsticks.Command.fs │ │ │ │ ├── StartConcert.Command.fs │ │ │ │ └── TuneInstrument.Command.fs │ │ │ ├── Exit.Command.fs │ │ │ ├── Gym │ │ │ │ └── AskForEntrance.Command.fs │ │ │ ├── Help.Command.fs │ │ │ ├── Inventory.Command.fs │ │ │ ├── Items │ │ │ │ ├── Consume.Command.fs │ │ │ │ ├── Cook.Command.fs │ │ │ │ ├── Interactive.Command.fs │ │ │ │ ├── Open.Command.fs │ │ │ │ ├── Put.Command.fs │ │ │ │ └── Sleep.Command.fs │ │ │ ├── Look.Command.fs │ │ │ ├── Map.Command.fs │ │ │ ├── Me.Command.fs │ │ │ ├── MerchandiseWorkshop │ │ │ │ ├── ListOrders.Command.fs │ │ │ │ ├── OrderMerchandise.Command.fs │ │ │ │ └── PickUpOrder.Command.fs │ │ │ ├── MiniGame │ │ │ │ ├── Bet.Command.fs │ │ │ │ ├── Hit.Command.fs │ │ │ │ ├── Leave.Command.fs │ │ │ │ ├── Stand.Command.fs │ │ │ │ └── StartMiniGame.Command.fs │ │ │ ├── Movement.Command.fs │ │ │ ├── Phone.Command.fs │ │ │ ├── RehearsalRoom │ │ │ │ ├── BandInventory.Command.fs │ │ │ │ ├── ComposeSong.Command.fs │ │ │ │ ├── DiscardSong.Command.fs │ │ │ │ ├── FinishSong.Command.fs │ │ │ │ ├── FireMember.Command.fs │ │ │ │ ├── HireMember.Command.fs │ │ │ │ ├── ImproveSong.Command.fs │ │ │ │ ├── ListMembers.Command.fs │ │ │ │ ├── ListSongs.Command.fs │ │ │ │ ├── PracticeSong.Command.fs │ │ │ │ └── SwitchGenre.Command.fs │ │ │ ├── Shop │ │ │ │ ├── Buy.Command.fs │ │ │ │ ├── BuyCar.Command.fs │ │ │ │ ├── Order.Command.fs │ │ │ │ └── SeeMenu.Command.fs │ │ │ ├── Social │ │ │ │ ├── Social.Command.fs │ │ │ │ ├── Social.Commands.fs │ │ │ │ └── StartStopConversation.Command.fs │ │ │ ├── Studio │ │ │ │ ├── Common.fs │ │ │ │ ├── CreateAlbum.Command.fs │ │ │ │ ├── EditAlbumName.Command.fs │ │ │ │ ├── ListUnreleasedAlbums.Command.fs │ │ │ │ ├── RecordSong.Command.fs │ │ │ │ └── ReleaseAlbum.Command.fs │ │ │ ├── Travel │ │ │ │ ├── Drive.Command.fs │ │ │ │ ├── LeaveVehicle.Command.fs │ │ │ │ ├── TravelByMetro.Command.fs │ │ │ │ └── WaitForMetro.Command.fs │ │ │ └── Wait.Command.fs │ │ ├── ConcertDetails.fs │ │ ├── Effect.fs │ │ ├── Figlet.fs │ │ ├── GameInfo.fs │ │ ├── Layout.fs │ │ ├── Map.fs │ │ ├── Message.fs │ │ ├── Notification.fs │ │ ├── Post.fs │ │ ├── ProgressBar.fs │ │ ├── Prompt.fs │ │ ├── Review.fs │ │ ├── Separator.fs │ │ ├── Table.fs │ │ ├── Tip.fs │ │ └── VisualEffects.fs │ ├── Duets.Cli.fsproj │ ├── Duets.Cli.fsproj.user │ ├── Program.fs │ ├── Properties │ │ └── launchSettings.json │ ├── Scenes.fs │ ├── Scenes │ │ ├── Cheats.fs │ │ ├── MainMenu.fs │ │ ├── NewGame │ │ │ ├── BandCreator.fs │ │ │ ├── CharacterCreator.fs │ │ │ ├── SkillEditor.fs │ │ │ └── WorldSelector.fs │ │ ├── Phone │ │ │ ├── Apps │ │ │ │ ├── Bank │ │ │ │ │ ├── Bank.fs │ │ │ │ │ ├── DistributeBandFunds.fs │ │ │ │ │ ├── Transfer.fs │ │ │ │ │ └── UpcomingPayments.fs │ │ │ │ ├── BnB │ │ │ │ │ ├── BnB.fs │ │ │ │ │ ├── List.fs │ │ │ │ │ └── Rent.fs │ │ │ │ ├── Calendar │ │ │ │ │ └── Calendar.fs │ │ │ │ ├── ConcertAssistant │ │ │ │ │ ├── ConcertAssistant.fs │ │ │ │ │ ├── ScheduleOpeningActShow.fs │ │ │ │ │ └── ScheduleSoloShow.fs │ │ │ │ ├── Duber │ │ │ │ │ └── Duber.fs │ │ │ │ ├── Flights │ │ │ │ │ ├── BookFlight.fs │ │ │ │ │ └── Flights.fs │ │ │ │ ├── FoodDelivery │ │ │ │ │ └── FoodDelivery.fs │ │ │ │ ├── Jobs │ │ │ │ │ ├── FindJob.fs │ │ │ │ │ └── Jobs.fs │ │ │ │ ├── Mastodon │ │ │ │ │ ├── Commands │ │ │ │ │ │ ├── Exit.Mastodon.Command.fs │ │ │ │ │ │ ├── Post.Mastodon.Command.fs │ │ │ │ │ │ ├── SignUp.Mastodon.Command.fs │ │ │ │ │ │ ├── SwitchAccount.Mastodon.Command.fs │ │ │ │ │ │ └── Timeline.Mastodon.Command.fs │ │ │ │ │ ├── Mastodon.fs │ │ │ │ │ └── SignUp.fs │ │ │ │ ├── Statistics │ │ │ │ │ ├── AlbumsStatistics.fs │ │ │ │ │ ├── BandStatistics.fs │ │ │ │ │ ├── RelationshipsStatistics.fs │ │ │ │ │ ├── ReviewStatistics.fs │ │ │ │ │ └── Statistics.fs │ │ │ │ └── Weather │ │ │ │ │ └── Weather.fs │ │ │ └── Phone.fs │ │ ├── Settings.fs │ │ └── World.fs │ └── Text │ │ ├── Career.fs │ │ ├── Character.fs │ │ ├── Command.fs │ │ ├── Concert.fs │ │ ├── Creator.fs │ │ ├── Date.fs │ │ ├── Emoji.fs │ │ ├── Events.fs │ │ ├── Generic.fs │ │ ├── Interaction.fs │ │ ├── Items.fs │ │ ├── MainMenu.fs │ │ ├── MiniGame.fs │ │ ├── Phone.fs │ │ ├── Prompts │ │ ├── CarDealerPrompts.fs │ │ ├── CommonPrompts.fs │ │ ├── DrivingPrompts.fs │ │ ├── DuberPrompts.fs │ │ ├── FlightPrompts.fs │ │ ├── WorkPrompts.fs │ │ └── WorldPrompts.fs │ │ ├── Rehearsal.fs │ │ ├── Shop.fs │ │ ├── Skill.fs │ │ ├── Social.fs │ │ ├── Songs.fs │ │ ├── Studio.fs │ │ ├── Styles.fs │ │ ├── Travel.fs │ │ └── World.fs ├── Duets.Common │ ├── Duets.Common.fsproj │ ├── Files.fs │ ├── Func.fs │ ├── List.fs │ ├── Map.fs │ ├── Math.fs │ ├── Operators.fs │ ├── Option.fs │ ├── Pipe.fs │ ├── README.md │ ├── Random.fs │ ├── Result.fs │ ├── Seq.fs │ ├── Serializer.fs │ ├── String.fs │ ├── Tuple.fs │ └── Union.fs ├── Duets.Data │ ├── ATTRIBUTIONS.md │ ├── Careers.fs │ ├── Duets.Data.fsproj │ ├── Genres.fs │ ├── Items │ │ ├── Book.Items.fs │ │ ├── Drink.Items.fs │ │ ├── Electronics.Items.fs │ │ ├── Food │ │ │ ├── All.Food.fs │ │ │ ├── Breakfast.Food.fs │ │ │ ├── Czech.Food.fs │ │ │ ├── French.Food.fs │ │ │ ├── Italian.Food.fs │ │ │ ├── Japanese.Food.fs │ │ │ ├── Mexican.Food.fs │ │ │ ├── Snack.Food.fs │ │ │ ├── Spanish.Food.fs │ │ │ ├── Turkish.Food.fs │ │ │ ├── USA.Food.fs │ │ │ └── Vietnamese.Food.fs │ │ ├── Furniture.Items.fs │ │ ├── Gym.Items.fs │ │ └── Vehicle.Items.fs │ ├── Migrations.fs │ ├── Npcs.fs │ ├── ResourceLoader.fs │ ├── Resources │ │ ├── adjectives.json │ │ ├── adverbs.json │ │ ├── books.json │ │ ├── genres.json │ │ ├── nouns.json │ │ └── npcs.json │ ├── Roles.fs │ ├── Savegame │ │ ├── Migrations │ │ │ └── 0_MigrateFromVersionless.fs │ │ └── Types.fs │ ├── Skills.fs │ ├── Util │ │ └── NpcGen.fsx │ ├── VocalStyles.fs │ ├── Words.fs │ └── World │ │ ├── Cities │ │ ├── Layouts.fs │ │ ├── London │ │ │ ├── Camden.London.fs │ │ │ ├── CityOfLondon.London.fs │ │ │ ├── Greenwich.London.fs │ │ │ ├── Heathrow.London.fs │ │ │ ├── London.fs │ │ │ └── WestEnd.London.fs │ │ ├── LosAngeles │ │ │ ├── DowntownLA.LosAngeles.fs │ │ │ ├── Hollywood.LosAngeles.fs │ │ │ ├── Ids.LosAngeles.fs │ │ │ ├── Koreatown.LosAngeles.fs │ │ │ ├── Lax.LosAngeles.fs │ │ │ ├── LosAngeles.fs │ │ │ └── SantaMonica.LosAngeles.fs │ │ ├── Madrid │ │ │ ├── Barajas.Madrid.fs │ │ │ ├── Centro.Madrid.fs │ │ │ ├── Chamartin.Madrid.fs │ │ │ ├── Chamberi.Madrid.fs │ │ │ ├── Madrid.fs │ │ │ ├── Retiro.Madrid.fs │ │ │ └── Salamanca.Madrid.fs │ │ ├── NewYork │ │ │ ├── Brooklyn.NewYork.fs │ │ │ ├── Ids.NewYork.fs │ │ │ ├── Jamaica.NewYork.fs │ │ │ ├── LowerManhattan.NewYork.fs │ │ │ ├── MidtownWest.NewYork.fs │ │ │ └── NewYork.fs │ │ ├── OpeningHours.fs │ │ ├── PlaceCreators.fs │ │ └── Prague │ │ │ ├── Holešovice.Prague.fs │ │ │ ├── Ids.Prague.fs │ │ │ ├── Libeň.Prague.fs │ │ │ ├── NovéMěsto.Prague.fs │ │ │ ├── Prague.fs │ │ │ ├── Ruzyně.Prague.fs │ │ │ ├── Smíchov.Prague.fs │ │ │ ├── StaréMěsto.Prague.fs │ │ │ ├── Vinohrady.Prague.fs │ │ │ └── Vršovice.Prague.fs │ │ ├── Ids.fs │ │ └── World.fs ├── Duets.Entities │ ├── Album.fs │ ├── Amount.fs │ ├── Band.fs │ ├── BankAccount.fs │ ├── Calendar.fs │ ├── CalendarEvent.fs │ ├── Career.fs │ ├── Character.fs │ ├── Concert.fs │ ├── Duets.Entities.fsproj │ ├── Flight.fs │ ├── Identity.fs │ ├── Instrument.fs │ ├── Interaction.fs │ ├── Inventory.fs │ ├── Item.fs │ ├── Lenses.fs │ ├── MiniGame.fs │ ├── Moodlet.fs │ ├── README.md │ ├── Relationships.fs │ ├── Rental.fs │ ├── Skill.fs │ ├── Social.fs │ ├── SocialNetwork.fs │ ├── Song.fs │ ├── State.fs │ ├── Time.fs │ ├── Types │ │ ├── Album.Types.fs │ │ ├── Attribute.Types.fs │ │ ├── Band.Types.fs │ │ ├── Bank.Types.fs │ │ ├── Book.Types.fs │ │ ├── Calendar.Types.fs │ │ ├── CalendarEvent.Types.fs │ │ ├── Car.Types.fs │ │ ├── Career.Types.fs │ │ ├── Character.Types.fs │ │ ├── City.Types.fs │ │ ├── Common.Types.fs │ │ ├── Concert.Types.fs │ │ ├── Effect.Types.fs │ │ ├── Flight.Types.fs │ │ ├── Genre.Types.fs │ │ ├── Instrument.Types.fs │ │ ├── Interaction.Types.fs │ │ ├── Item.Types.fs │ │ ├── Merch.Types.fs │ │ ├── MiniGames │ │ │ ├── Blackjack.Types.fs │ │ │ ├── MiniGame.Shared.Types.fs │ │ │ └── MiniGame.Types.fs │ │ ├── Moodlet.Types.fs │ │ ├── Notification.Types.fs │ │ ├── Places │ │ │ ├── ConcertSpace.Types.fs │ │ │ ├── Hotel.Types.fs │ │ │ ├── Metro.Types.fs │ │ │ ├── RadioStudio.Types.fs │ │ │ ├── RehearsalSpace.Types.fs │ │ │ ├── Shop.Types.fs │ │ │ └── Studio.Types.fs │ │ ├── Relationship.Types.fs │ │ ├── Rental.Types.fs │ │ ├── Situations.Types.fs │ │ ├── Skill.Types.fs │ │ ├── Social.Types.fs │ │ ├── SocialNetwork.Types.fs │ │ ├── Song.Types.fs │ │ ├── State.Types.fs │ │ ├── Weather.Types.fs │ │ ├── World.Coordinates.Types.fs │ │ └── World.Types.fs │ └── World.fs └── Duets.Simulation │ ├── Albums │ ├── DailyStreams.fs │ ├── DailyUpdate.fs │ ├── FanIncrease.fs │ ├── Hype.fs │ ├── Revenue.fs │ └── ReviewGeneration.fs │ ├── Bands │ ├── FundDistribution.fs │ ├── Generation.fs │ ├── Members.fs │ └── SwitchGenre.fs │ ├── Bank │ └── Operations.fs │ ├── Careers │ ├── Common.fs │ ├── Employment.fs │ ├── JobBoard.fs │ ├── Promotion.fs │ ├── RequirementCharacterUpgrade.fs │ └── Work.fs │ ├── Character │ ├── AttributeChange.fs │ ├── Attributes.fs │ ├── Moodlets.fs │ └── Npc.fs │ ├── Concerts │ ├── DailyUpdate.fs │ ├── Live │ │ ├── Live.Actions.fs │ │ ├── Live.Common.fs │ │ ├── Live.Encore.fs │ │ └── Live.Finish.fs │ ├── OpeningActOpportunities.fs │ ├── Preparation │ │ └── StartConcertPreparation.fs │ └── Scheduler.fs │ ├── Config.fs │ ├── Cooking │ └── Cooking.fs │ ├── Duets.Simulation.fsproj │ ├── EffectModifiers │ ├── EffectModifiers.fs │ └── Moodlets.EffectModifiers.fs │ ├── Events │ ├── Band │ │ ├── Band.Events.fs │ │ ├── Relationships.fs │ │ └── Reviews.fs │ ├── Career.Events.fs │ ├── Character │ │ ├── Character.Events.fs │ │ ├── Drunkenness.fs │ │ ├── Fame.fs │ │ ├── Hospitalization.fs │ │ └── Hunger.fs │ ├── Concert.Events.fs │ ├── Events.fs │ ├── Moodlets │ │ ├── Cleanup.fs │ │ ├── JetLagged.fs │ │ ├── Moodlets.Events.fs │ │ ├── NotInspired.fs │ │ └── TiredOfTouring.fs │ ├── NonInteractiveGame.Events.fs │ ├── Place │ │ ├── ClosingTime.fs │ │ └── RentalExpiration.fs │ ├── Skill.Events.fs │ ├── Time.Events.fs │ ├── Types.fs │ └── World.Events.fs │ ├── Flights │ ├── Airport.fs │ ├── Booking.fs │ └── TicketGeneration.fs │ ├── Gym │ └── PayEntrance.fs │ ├── Interactions │ ├── Items │ │ ├── Actions │ │ │ ├── Exercise.Action.fs │ │ │ └── Read.Action.fs │ │ ├── Drink.Interactions.fs │ │ ├── Food.Interactions.fs │ │ └── Item.Interactions.fs │ └── Sleep.fs │ ├── Items │ └── Items.fs │ ├── Market │ └── GenreMarket.fs │ ├── Merchandise │ ├── Order.Merchandise.fs │ ├── PickUp.Merchandise.fs │ ├── Sell.Merchandise.fs │ └── SetPrice.Merchandise.fs │ ├── MiniGames │ └── Blackjack.fs │ ├── Notifications │ └── Notifications.fs │ ├── Queries │ ├── Albums.fs │ ├── Bands.fs │ ├── Bank.fs │ ├── Calendar.fs │ ├── CalendarEvents.fs │ ├── Career.fs │ ├── Characters.fs │ ├── Concerts.fs │ ├── Flights.fs │ ├── Genres.fs │ ├── Gym.fs │ ├── Interactions │ │ ├── InteractionCommon.fs │ │ ├── Interactions.Airport.fs │ │ ├── Interactions.Career.fs │ │ ├── Interactions.Casino.fs │ │ ├── Interactions.ConcertSpace.fs │ │ ├── Interactions.FreeRoam.fs │ │ ├── Interactions.Gym.fs │ │ ├── Interactions.Items.fs │ │ ├── Interactions.MerchandiseWorkshop.fs │ │ ├── Interactions.MetroStation.fs │ │ ├── Interactions.RehearsalSpace.fs │ │ ├── Interactions.Shop.fs │ │ ├── Interactions.Social.fs │ │ ├── Interactions.Studio.fs │ │ ├── Interactions.fs │ │ └── Requirements │ │ │ ├── Interactions.Requirements.Energy.fs │ │ │ └── Interactions.Requirements.Health.fs │ ├── Inventory.fs │ ├── Items.fs │ ├── Merch.fs │ ├── Metro.fs │ ├── Notifications.fs │ ├── Relationships.fs │ ├── Rentals.fs │ ├── Shop.fs │ ├── Situations.fs │ ├── Skills.fs │ ├── SocialNetworks.fs │ ├── Songs.fs │ └── World.fs │ ├── RandomGen.fs │ ├── Rentals │ ├── PayUpcoming.fs │ └── RentPlace.fs │ ├── Setup │ └── StartGame.fs │ ├── Shop │ └── Shop.fs │ ├── Simulation.fs │ ├── Situations │ └── Situations.fs │ ├── Skills │ ├── ImproveSkills.Common.fs │ └── ImproveSkills.Composition.fs │ ├── Social │ ├── LongTimeNoSee.fs │ ├── Relationship.fs │ ├── Social.Actions.fs │ └── Social.Common.fs │ ├── SocialNetworks │ ├── DailyUpdate.fs │ ├── Reposts.fs │ └── SocialNetworks.fs │ ├── Songs │ ├── Composition │ │ ├── Common.fs │ │ ├── ComposeSong.fs │ │ ├── DiscardSong.fs │ │ ├── FinishSong.fs │ │ └── ImproveSong.fs │ └── Practice.fs │ ├── State │ ├── Albums.fs │ ├── Bands.fs │ ├── Bank.fs │ ├── Calendar.fs │ ├── Career.fs │ ├── Characters.fs │ ├── Concerts.fs │ ├── Flights.fs │ ├── Inventory.fs │ ├── Market.fs │ ├── Merch.fs │ ├── Notifications.fs │ ├── Relationships.fs │ ├── Rentals.fs │ ├── Skills.fs │ ├── SocialNetworks.fs │ ├── Songs.fs │ ├── State.fs │ └── World.fs │ ├── Studio │ ├── RecordAlbum.fs │ ├── ReleaseAlbum.fs │ └── RenameAlbum.fs │ ├── Time │ ├── AdvanceTime.fs │ └── InteractionMinutes.fs │ ├── Travel │ └── Metro.fs │ ├── Vehicles │ ├── Car.fs │ └── Taxi.fs │ ├── Weather │ └── WeatherTransition.fs │ ├── World │ └── Population.fs │ └── WorldNavigation │ ├── Navigation.fs │ ├── Pathfinding.fs │ ├── Policies │ ├── Concert.Policies.fs │ ├── OpeningHours.Policies.fs │ ├── Rental.Policies.fs │ └── Room.Policies.fs │ └── TravelTime.fs └── tests ├── Agents.Tests ├── Agents.Tests.fsproj └── Program.fs ├── Data.Tests ├── Data.Tests.fsproj ├── Items.Tests.fs ├── Program.fs ├── SavegameMigrations.Tests.fs └── World.Tests.fs ├── Entities.Tests ├── Album.Tests.fs ├── Calendar.Tests.fs ├── Concert.Tests.fs ├── Entities.Tests.fsproj ├── Program.fs ├── SocialNetwork.Tests.fs ├── Time.Tests.fs └── World.Tests.fs ├── Simulation.Tests ├── Airport │ └── BoardPlane.Tests.fs ├── Albums │ ├── DailyUpdate.Tests.fs │ └── ReviewGeneration.Tests.fs ├── Bands │ ├── BandGeneration.Tests.fs │ ├── DistributeFunds.Tests.fs │ ├── FireMember.Tests.fs │ └── HireMember.Tests.fs ├── Bank │ ├── Queries.Tests.fs │ └── Transfer.Tests.fs ├── Careers │ ├── Employment.Test.fs │ ├── JobBoard.Test.fs │ └── Work.Test.fs ├── Concerts │ ├── DailyUpdate.Tests.fs │ ├── Live.DedicateSong.Tests.fs │ ├── Live.Encore.Tests.fs │ ├── Live.Finish.Tests.fs │ ├── Live.GreetAudience.Tests.fs │ ├── Live.PlaySong.Tests.fs │ └── OpeningActOpportunities.Tests.fs ├── EffectModifiers │ └── Moodlet.EffectModifiers.Tests.fs ├── Events │ ├── Band.Events.Tests.fs │ ├── Career.Events.Tests.fs │ ├── Character.Events.Tests.fs │ ├── ClosingTime.Events.Tests.fs │ ├── Moodlets │ │ ├── JetLagged.Moodlet.Events.Tests.fs │ │ └── NotInspired.Moodlet.Events.Tests.fs │ ├── Rental.Events.Tests.fs │ └── World.Events.Tests.fs ├── Interactions │ ├── Drink.Interactions.Test.fs │ ├── Exercise.Interactions.Test.fs │ ├── Food.Interactions.Test.fs │ ├── Play.Interactions.Test.fs │ ├── Read.Test.fs │ └── Sleep.Test.fs ├── Items │ ├── Items.Put.Tests.fs │ ├── Items.Remove.Tests.fs │ └── Items.Take.Tests.fs ├── Market │ └── GenreMarket.Tests.fs ├── Merchandise │ ├── Order.Merchandise.Tests.fs │ ├── PickUp.Merchandise.Tests.fs │ └── Sell.Merchandise.Tests.fs ├── MiniGames │ └── Blackjack.Tests.fs ├── Notifications │ └── Notification.Tests.fs ├── Program.fs ├── Simulation.Tests.fs ├── Simulation.Tests.fsproj ├── Skills │ └── ImproveSkills.Tests.fs ├── Social │ └── LongTimeNoSee.Tests.fs ├── SocialNetworks │ ├── DailyUpdate.Tests.fs │ └── Reposts.Tests.fs ├── Songs │ ├── ComposeSong.Tests.fs │ ├── DiscardSong.Tests.fs │ ├── FinishSong.Tests.fs │ ├── ImproveSong.Tests.fs │ └── PracticeSong.Tests.fs ├── State │ ├── State.Albums.Tests.fs │ ├── State.BandManagement.Tests.fs │ ├── State.Bank.Tests.fs │ ├── State.Concerts.Tests.fs │ ├── State.Inventory.Tests.fs │ ├── State.Merch.Tests.fs │ ├── State.Relationships.Tests.fs │ ├── State.Setup.Tests.fs │ ├── State.Skills.Tests.fs │ └── State.SongComposition.Tests.fs ├── Studio │ ├── RecordAlbum.Tests.fs │ └── RenameAlbum.Tests.fs ├── Time │ └── AdvanceTime.Tests.fs ├── Vehicles │ ├── Car.Tests.fs │ └── Taxi.Tests.fs ├── Weather │ └── WeatherTransition.Tests.fs └── World │ ├── Pathfinding.Tests.fs │ ├── Population.Tests.fs │ └── Traveling.Tests.fs └── Test.Common ├── Generators ├── Character.Generator.fs ├── Concert.Generator.fs ├── Date.Generator.fs ├── Song.Generator.fs └── State.Generator.fs ├── Library.fs └── Test.Common.fsproj /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.fs] 4 | indent_style = space 5 | indent_size = 4 6 | max_line_length = 80 7 | -------------------------------------------------------------------------------- /.github/workflows/buildandtest.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | submodules: 'recursive' 18 | - name: Setup dotnet 19 | uses: actions/setup-dotnet@v3 20 | with: 21 | dotnet-version: '9.0' 22 | include-prerelease: true 23 | - name: Restore dependencies 24 | run: dotnet restore 25 | - name: Build 26 | run: dotnet build --no-restore 27 | - name: Test 28 | run: dotnet test --no-build --verbosity normal 29 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/spectre.console"] 2 | path = deps/spectre.console 3 | url = https://github.com/sleepyfran/spectre.console.git 4 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0 2 | 3 | COPY *.sln . 4 | COPY deps ./deps 5 | COPY src ./src 6 | COPY tests ./tests 7 | 8 | RUN dotnet build 9 | ENTRYPOINT ["dotnet", "run", "--project", "src/Cli/Cli.fsproj"] 10 | -------------------------------------------------------------------------------- /src/Duets.CityExplorer/App.axaml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Duets.CityExplorer/App.axaml.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.CityExplorer 2 | 3 | open Avalonia 4 | open Avalonia.Controls.ApplicationLifetimes 5 | open Avalonia.Markup.Xaml 6 | 7 | type App() = 8 | inherit Application() 9 | 10 | override this.Initialize() = 11 | AvaloniaXamlLoader.Load(this) 12 | 13 | override this.OnFrameworkInitializationCompleted() = 14 | match this.ApplicationLifetime with 15 | | :? IClassicDesktopStyleApplicationLifetime as desktop -> 16 | desktop.MainWindow <- MainWindow() 17 | | _ -> () 18 | 19 | base.OnFrameworkInitializationCompleted() -------------------------------------------------------------------------------- /src/Duets.CityExplorer/Program.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.CityExplorer 2 | 3 | open System 4 | open Avalonia 5 | 6 | module Program = 7 | 8 | [] 9 | let buildAvaloniaApp () = 10 | AppBuilder 11 | .Configure() 12 | .UsePlatformDetect() 13 | .WithInterFont() 14 | .LogToTrace(areas = Array.empty) 15 | 16 | [] 17 | let main argv = 18 | buildAvaloniaApp().StartWithClassicDesktopLifetime(argv) -------------------------------------------------------------------------------- /src/Duets.CityExplorer/app.manifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/BarChart.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.BarChart 3 | 4 | open Spectre.Console 5 | 6 | /// Returns the associated color given the level of a skill or the quality 7 | /// of a song. 8 | let private colorForLevel level = 9 | match level with 10 | | level when level < 30 -> Color.Red 11 | | level when level < 60 -> Color.Orange1 12 | | level when level < 80 -> Color.Green 13 | | _ -> Color.Blue 14 | 15 | /// 16 | /// Shows a bar chart with a max value of 100. 17 | /// 18 | /// List of tuples of value and text to display 19 | let showBarChart items = 20 | let mutable barChart = BarChart() 21 | barChart.MaxValue <- 100.0 22 | 23 | barChart <- 24 | barChart.AddItems( 25 | items, 26 | fun (progress, label) -> 27 | BarChartItem(label, float progress, colorForLevel progress) 28 | ) 29 | 30 | AnsiConsole.Write(barChart) 31 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/CityPrompt.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module rec Duets.Cli.Components.CityPrompt 3 | 4 | open Duets.Agents 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | open Duets.Simulation 8 | 9 | /// 10 | /// Shows a prompt to select a city, with the current city at the top and the 11 | /// rest ordered alphabetically. 12 | /// 13 | /// Prompt to show the user 14 | let showCityPrompt prompt = 15 | let currentCity = Queries.World.currentCity (State.get ()) 16 | 17 | showOptionalChoicePrompt 18 | prompt 19 | Generic.cancel 20 | (fun (city: City) -> 21 | if city.Id = currentCity.Id then 22 | $"{Generic.cityName city.Id} (Current)" |> Styles.highlight 23 | else 24 | Generic.cityName city.Id) 25 | (sortedCities currentCity) 26 | 27 | /// Lists all available cities with the current one at the top. 28 | let private sortedCities currentCity = 29 | let allButCurrentCity = 30 | Queries.World.allCities 31 | |> List.filter (fun city -> city.Id <> currentCity.Id) 32 | |> List.sortBy (fun city -> Generic.cityName city.Id) 33 | 34 | currentCity :: allButCurrentCity 35 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Aiport/BoardPlane.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli 4 | open Duets.Cli.Components 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames 8 | open Duets.Simulation.Flights.Airport 9 | 10 | [] 11 | module BoardPlaneCommand = 12 | /// Command that allows the user to board the plane and start their trip. 13 | let create flight = 14 | { Name = "board plane" 15 | Description = Command.boardPlaneDescription flight 16 | Handler = 17 | fun _ -> 18 | showProgressBarAsync [ Travel.waitingToBoard ] 5 19 | 20 | let effects, flightTime = boardPlane flight 21 | 22 | Travel.planeBoarded flight flightTime |> showMessage 23 | 24 | effects |> Effect.applyMultiple 25 | 26 | Scene.World } 27 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Aiport/PassSecurity.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.SceneIndex 7 | open Duets.Cli.Text 8 | open Duets.Entities 9 | open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames 10 | open Duets.Simulation.Flights.Airport 11 | 12 | [] 13 | module PassSecurityCommand = 14 | /// Command that allows the user to pass the security check in the airport. 15 | let get = 16 | { Name = "pass security check" 17 | Description = Command.passSecurityCheckDescription 18 | Handler = 19 | fun _ -> 20 | showProgressBarSync [ Travel.passingSecurityCheck ] 5 21 | 22 | let effects = passSecurityCheck (State.get ()) 23 | Effect.applyMultiple effects 24 | 25 | let takenItemsEffect = 26 | effects 27 | |> List.filter (function 28 | | ItemRemovedFromCharacterInventory _ -> true 29 | | _ -> false) 30 | 31 | match takenItemsEffect with 32 | | eff when eff.Length > 0 -> 33 | Travel.itemsTakenBySecurity |> showMessage 34 | | _ -> () 35 | 36 | Scene.WorldAfterMovement } 37 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Aiport/WaitForLanding.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.SceneIndex 7 | open Duets.Cli.Text 8 | open Duets.Cli.Text.Prompts 9 | open Duets.Simulation.Flights.Airport 10 | 11 | [] 12 | module WaitForLandingCommand = 13 | /// Command that allows the user to wait until the flight finishes. 14 | let create flight = 15 | { Name = "wait" 16 | Description = Command.waitForLandingDescription 17 | Handler = 18 | fun _ -> 19 | let state = State.get () 20 | 21 | Flight.createInFlightExperiencePrompt state flight 22 | |> LanguageModel.streamMessage 23 | |> streamStyled Styles.event 24 | 25 | lineBreak () 26 | lineBreak () 27 | wait 2000 28 | 29 | Flight.createAirportExperiencePrompt state flight 30 | |> LanguageModel.streamMessage 31 | |> streamStyled Styles.event 32 | 33 | lineBreak () 34 | wait 1500 35 | 36 | lineBreak () 37 | 38 | leavePlane (State.get ()) flight |> Effect.applyMultiple 39 | 40 | Scene.WorldAfterMovement } 41 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Cheats/Cheats.Commands.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands.Cheats 2 | 3 | open Duets.Cli.Components.Commands 4 | open Duets.Cli.SceneIndex 5 | 6 | module Index = 7 | let all = 8 | [ BandCommands.makeMeABand 9 | BandCommands.pactWithTheDevil 10 | LifeCommands.happy 11 | LifeCommands.notMoody 12 | LifeCommands.roaming 13 | LifeCommands.spotlight 14 | LifeCommands.timeTravel 15 | MoneyCommands.moneyHeist 16 | MoneyCommands.motherlode 17 | MoneyCommands.rosebud 18 | SkillCommands.pureSkill 19 | WorldCommands.teleport ] 20 | 21 | let enterCommand = 22 | { Name = "iwanttoskipreality" 23 | Description = "" 24 | Handler = fun _ -> Scene.Cheats } 25 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/BassSolo.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Text 4 | open Duets.Simulation.Concerts.Live 5 | 6 | [] 7 | module BassSoloCommand = 8 | /// Command to perform a bass solo. 9 | let create ongoingConcert = 10 | Concert.createSoloCommand 11 | "bass solo" 12 | Command.bassSoloDescription 13 | [ Concert.bassSoloMovingFingersQuickly 14 | Concert.bassSoloSlappingThatBass 15 | Concert.bassSoloGrooving ] 16 | bassSolo 17 | ongoingConcert 18 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/DoEncore.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.Components.Commands 6 | open Duets.Cli.Text 7 | open Duets.Simulation.Concerts.Live.Encore 8 | 9 | [] 10 | module DoEncoreCommand = 11 | /// Returns the artist back to the stage to perform an encore. Assumes that 12 | /// an encore is possible and that the audience will still be there for it. 13 | let create ongoingConcert = 14 | Concert.createCommand 15 | "do encore" 16 | Command.doEncoreDescription 17 | (fun _ -> doEncore (State.get ())) 18 | (fun _ _ -> Concert.encoreComingBackToStage |> showMessage) 19 | ongoingConcert 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/DrumSolo.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Text 4 | open Duets.Simulation.Concerts.Live 5 | 6 | [] 7 | module DrumSoloCommand = 8 | /// Command to perform a drum solo. 9 | let create ongoingConcert = 10 | Concert.createSoloCommand 11 | "drum solo" 12 | Command.drumSoloDescription 13 | [ Concert.drumSoloDoingDrumstickTricks 14 | Concert.drumSoloPlayingWeirdRhythms 15 | Concert.drumSoloPlayingReallyFast ] 16 | drumSolo 17 | ongoingConcert 18 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/FinishConcert.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Duets.Simulation 8 | 9 | [] 10 | module FinishConcertCommand = 11 | /// Puts the artist out of the ongoing concert scene, which shows them the 12 | /// total points accumulated during the concert, the result of it and allows 13 | /// them to move to other places outside the stage/backstage. 14 | let rec create ongoingConcert = 15 | { Name = "finish concert" 16 | Description = Command.finishConcertDescription 17 | Handler = 18 | (fun _ -> 19 | Concerts.Live.Finish.finishConcert 20 | (State.get ()) 21 | ongoingConcert 22 | |> Duets.Cli.Effect.applyMultiple 23 | 24 | Scene.World) } 25 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/GetOffStage.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live.Encore 7 | 8 | [] 9 | module GetOffStageCommand = 10 | /// Command which moves the person from the stage into the backstage. This 11 | /// might end the concert if people is not really interested in staying for 12 | /// the encore. 13 | let rec create ongoingConcert = 14 | Concert.createCommand 15 | "get off stage" 16 | Command.getOffStageDescription 17 | getOffStage 18 | (fun canPerformEncore _ -> 19 | lineBreak () 20 | 21 | if canPerformEncore then 22 | Concert.getOffStageEncorePossible |> showMessage 23 | 24 | lineBreak () 25 | else 26 | Concert.getOffStageNoEncorePossible |> showMessage 27 | 28 | lineBreak ()) 29 | ongoingConcert 30 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/GiveSpeech.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live 7 | 8 | [] 9 | module GiveSpeechCommand = 10 | /// Command which simulates giving a speech during a concert. 11 | let rec create ongoingConcert = 12 | Concert.createCommand 13 | "give speech" 14 | Command.giveSpeechDescription 15 | giveSpeech 16 | (fun result points -> 17 | Concert.showSpeechProgress () 18 | 19 | match result with 20 | | LowPerformance _ -> Concert.speechGivenLowSkill points 21 | | AveragePerformance _ -> Concert.speechGivenMediumSkill points 22 | | GoodPerformance _ 23 | | GreatPerformance -> Concert.speechGivenHighSkill points 24 | | _ -> Concert.tooManySpeeches 25 | |> showMessage) 26 | ongoingConcert 27 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/GreetAudience.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live 7 | 8 | [] 9 | module GreetAudienceCommand = 10 | /// Command which greets the audience in the concert. 11 | let rec create ongoingConcert = 12 | Concert.createCommand 13 | "greet audience" 14 | Command.greetAudienceDescription 15 | greetAudience 16 | (fun result points -> 17 | match result with 18 | | TooManyRepetitionsPenalized 19 | | TooManyRepetitionsNotDone -> 20 | Concert.greetAudienceGreetedMoreThanOnceTip points 21 | | _ -> Concert.greetAudienceDone points 22 | |> showMessage) 23 | ongoingConcert 24 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/GuitarSolo.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Text 4 | open Duets.Simulation.Concerts.Live 5 | 6 | [] 7 | module GuitarSoloCommand = 8 | /// Command to perform a guitar solo. 9 | let create ongoingConcert = 10 | Concert.createSoloCommand 11 | "guitar solo" 12 | Command.guitarSoloDescription 13 | [ Concert.guitarSoloPlayingReallyFast 14 | Concert.guitarSoloPlayingWithTeeth 15 | Concert.guitarSoloDoingSomeTapping ] 16 | guitarSolo 17 | ongoingConcert 18 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/MakeCrowdSing.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live 7 | 8 | [] 9 | module MakeCrowdSingCommand = 10 | /// Command which allows the user make the crowd sing. 11 | let rec create ongoingConcert = 12 | Concert.createCommand 13 | "make crowd sing" 14 | Command.makeCrowdSingDescription 15 | makeCrowdSing 16 | (fun result points -> 17 | match result with 18 | | LowPerformance _ -> 19 | Concert.makeCrowdSingLowPerformance points 20 | | AveragePerformance _ -> 21 | Concert.makeCrowdSingAveragePerformance points 22 | | GoodPerformance _ 23 | | GreatPerformance -> 24 | Concert.makeCrowdSingGreatPerformance points 25 | | _ -> Concert.tooMuchSingAlong 26 | |> showMessage) 27 | ongoingConcert 28 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/PerformSoundcheck.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.Components.Commands 7 | open Duets.Cli.SceneIndex 8 | open Duets.Cli.Text 9 | open Duets.Simulation.Concerts 10 | open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames 11 | 12 | [] 13 | module PerformSoundcheckCommand = 14 | /// Returns a command that marks the soundcheck as done. 15 | let create checklist = 16 | { Name = "soundcheck" 17 | Description = 18 | "Allows you to check the sound of your band before the concert starts, which improves the quality of the concert" 19 | Handler = 20 | fun _ -> 21 | showProgressBarSync 22 | [ "Plugin cables..." |> Styles.progress 23 | "Mic check, mic check..." |> Styles.progress 24 | "Staring into the abyss while the rest of the band checks..." 25 | |> Styles.progress ] 26 | 1 27 | 28 | Live.Actions.soundcheck (State.get ()) checklist 29 | |> Effect.applyMultiple 30 | 31 | Scene.World } 32 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/SpinDrumsticks.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live 7 | 8 | [] 9 | module SpinDrumsticksCommand = 10 | /// Command which performs the action of spinning the drumsticks for drummers. 11 | let rec create ongoingConcert = 12 | Concert.createCommand 13 | "spin drumstick" 14 | Command.makeCrowdSingDescription 15 | spinDrumsticks 16 | (fun result points -> 17 | match result with 18 | | LowPerformance _ -> Concert.drumstickSpinningBadResult points 19 | | AveragePerformance _ 20 | | GoodPerformance _ 21 | | GreatPerformance -> 22 | Concert.drumstickSpinningGoodResult points 23 | | _ -> Concert.tooManyDrumstickSpins 24 | |> showMessage) 25 | ongoingConcert 26 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Concert/TuneInstrument.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Simulation.Concerts.Live 7 | 8 | [] 9 | module TuneInstrumentCommand = 10 | /// Command which allows the player to tune their instrument mid-concert. 11 | let rec create ongoingConcert = 12 | Concert.createCommand 13 | "tune instrument" 14 | Command.tuneInstrumentDescription 15 | tuneInstrument 16 | (fun result points -> 17 | match result with 18 | | Done -> Concert.tuneInstrumentDone points 19 | | _ -> Concert.tooMuchTuning 20 | |> showMessage) 21 | ongoingConcert 22 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Exit.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.SceneIndex 4 | open Duets.Cli.Text 5 | 6 | [] 7 | module ExitCommand = 8 | /// Command which exits the app upon being called. 9 | let get = 10 | { Name = "exit" 11 | Description = Command.exitDescription 12 | Handler = fun _ -> Scene.Exit ExitMode.SaveGame } 13 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Gym/AskForEntrance.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.SceneIndex 7 | open Duets.Cli.Text 8 | open Duets.Simulation.Bank.Operations 9 | open Duets.Simulation.Gym 10 | 11 | [] 12 | module AskForEntranceCommand = 13 | /// Command to pay for a one-time entrance to a gym. 14 | let create entranceFee = 15 | { Name = "ask for entrance" 16 | Description = "Allows you to pay for a one-time entrance to a gym" 17 | Handler = 18 | (fun _ -> 19 | let confirmed = 20 | $"The entrance fee is {Styles.money entranceFee}. Do you want to pay it?" 21 | |> showConfirmationPrompt 22 | 23 | if confirmed then 24 | let result = Entrance.pay (State.get ()) entranceFee 25 | 26 | match result with 27 | | Ok effects -> effects |> Effect.applyMultiple 28 | | Error(NotEnoughFunds _) -> 29 | Shop.notEnoughFunds |> showMessage 30 | 31 | Scene.World) } 32 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Inventory.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | 7 | [] 8 | module InventoryCommand = 9 | /// Command which displays what the character is currently carrying in their 10 | /// inventory. 11 | let create inventory = 12 | { Name = "inventory" 13 | Description = Command.inventoryDescription 14 | Handler = 15 | fun _ -> 16 | if List.isEmpty inventory then 17 | Items.noItemsInventory |> showMessage 18 | else 19 | Items.itemsCurrentlyCarrying |> showMessage 20 | 21 | inventory |> List.map Items.itemRow |> List.iter showMessage 22 | 23 | Scene.World } 24 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/MerchandiseWorkshop/PickUpOrder.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.SceneIndex 6 | open Duets.Entities 7 | open Duets.Simulation.Merchandise.PickUp 8 | 9 | [] 10 | module PickUpMerchandiseOrdersCommand = 11 | /// Command to pick up all the merchandise orders that are available in the shop. 12 | let create (items: Item list) = 13 | { Name = "pick order" 14 | Description = 15 | "Allows you to pick up any order that is already available" 16 | Handler = 17 | fun _ -> 18 | pickUpOrder (State.get ()) items |> Effect.applyMultiple 19 | 20 | Scene.World } 21 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/MiniGame/Leave.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.SceneIndex 7 | open Duets.Cli.Text 8 | open Duets.Simulation.MiniGames 9 | 10 | [] 11 | module LeaveCommand = 12 | /// Command which allows the player to leave the current mini-game. 13 | let create miniGameId miniGameState = 14 | { Name = "leave" 15 | Description = 16 | $"Allows you to leave the {Generic.miniGameName miniGameId} game" 17 | Handler = 18 | (fun _ -> 19 | let result = Blackjack.leave miniGameState 20 | 21 | match result with 22 | | Ok effect -> effect |> Effect.applyMultiple 23 | | Error Blackjack.NotAllowed -> 24 | "You can't leave the game now!" 25 | |> Styles.error 26 | |> showMessage 27 | 28 | Scene.World) } 29 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/MiniGame/StartMiniGame.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | open Duets.Simulation 7 | 8 | [] 9 | module StartMiniGameCommand = 10 | /// Command which starts a mini-game given its ID. 11 | let create miniGameId = 12 | { Name = $"play {Generic.miniGameName miniGameId}" 13 | Description = 14 | $"Allows you to start a game of {Generic.miniGameName miniGameId}" 15 | Handler = 16 | (fun _ -> 17 | MiniGames.Blackjack.startGame |> Effect.apply 18 | 19 | Scene.World) } 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Phone.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.SceneIndex 4 | open Duets.Cli.Text 5 | 6 | [] 7 | module PhoneCommand = 8 | /// Command which opens the phone of the user. 9 | let get = 10 | { Name = "phone" 11 | Description = Command.phoneDescription 12 | Handler = fun _ -> Scene.Phone } 13 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/RehearsalRoom/BandInventory.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | open Duets.Common 7 | open Duets.Entities 8 | 9 | [] 10 | module BandInventoryCommand = 11 | /// Command that displays the inventory of the band. 12 | let create (items: (Item * int) list) = 13 | { Name = "band inventory" 14 | Description = "Shows all the merchandise your band has in stock" 15 | Handler = 16 | (fun _ -> 17 | "Your band currently has:" |> showMessage 18 | 19 | items 20 | |> List.iter (fun (item, quantity) -> 21 | $"- {quantity} {Generic.simplePluralOf (item.Name |> String.lowercase) quantity |> Styles.item}" 22 | |> showMessage) 23 | 24 | Scene.World) } 25 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Shop/Buy.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Duets.Simulation 8 | 9 | [] 10 | module BuyCommand = 11 | /// Command to buy something from a shop by specifying the name of the item 12 | /// via the command arguments or selecting it interactively. 13 | let create availableItems = 14 | { Name = "buy" 15 | Description = Command.buyDescription 16 | Handler = 17 | fun _ -> 18 | let selectedItem = 19 | showSearchableOptionalChoicePrompt 20 | Shop.itemPrompt 21 | Generic.cancel 22 | Shop.itemInteractiveRow 23 | availableItems 24 | 25 | match selectedItem with 26 | | Some item -> 27 | let orderResult = Shop.order (State.get ()) item 28 | 29 | match orderResult with 30 | | Ok effects -> Duets.Cli.Effect.applyMultiple effects 31 | | Error _ -> Shop.notEnoughFunds |> showMessage 32 | | None -> () 33 | 34 | Scene.World } 35 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Shop/SeeMenu.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | 8 | [] 9 | module SeeMenuCommand = 10 | /// Command to display the menu available on a restaurant or bar. 11 | let create (availableItems: PurchasableItem list) = 12 | { Name = "see menu" 13 | Description = Command.seeMenuDescription 14 | Handler = 15 | (fun _ -> 16 | let tableColumns = 17 | [ Shop.itemNameHeader 18 | Shop.itemTypeHeader 19 | Shop.itemPriceHeader ] 20 | 21 | let tableRows = 22 | availableItems 23 | |> List.sortBy (fun (item, _) -> item.Brand) 24 | |> List.map (fun (item, price) -> 25 | [ item.Brand 26 | Shop.itemType item 27 | Shop.itemPrice price ]) 28 | 29 | showTable tableColumns tableRows 30 | 31 | Scene.World) } 32 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Social/Social.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli 4 | open Duets.Cli.SceneIndex 5 | open Duets.Simulation.Social.Common 6 | 7 | [] 8 | module SocialCommand = 9 | /// Creates a command that calls the given action and applies all the 10 | /// effects returned by the response, then returns the world scene. 11 | let create 12 | (args: 13 | {| Name: string 14 | Description: string 15 | Action: unit -> SocialActionResponse 16 | Handler: SocialActionResult -> unit |}) 17 | = 18 | { Name = args.Name 19 | Description = args.Description 20 | Handler = 21 | fun _ -> 22 | let response = args.Action() 23 | response.Result |> args.Handler 24 | response.Effects |> Effect.applyMultiple 25 | 26 | Scene.World } 27 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Studio/Common.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | open Duets.Simulation.Studio.ReleaseAlbum 8 | 9 | module Studio = 10 | /// Shows an error indicating what made the album name validation fail. 11 | let showAlbumNameError error = 12 | match error with 13 | | Album.NameTooShort -> Studio.createErrorNameTooShort 14 | | Album.NameTooLong -> Studio.createErrorNameTooLong 15 | |> showMessage 16 | 17 | /// Shows a prompt that asks the user if they want to release an album and 18 | /// handles the release. 19 | let promptToReleaseAlbum band unreleasedAlbum = 20 | let album = unreleasedAlbum |> Album.fromUnreleased 21 | 22 | let state = State.get () 23 | 24 | let confirmed = 25 | showConfirmationPrompt ( 26 | Studio.commonPromptReleaseAlbum album.Name album.Type 27 | ) 28 | 29 | if confirmed then 30 | releaseAlbum state band unreleasedAlbum |> Duets.Cli.Effect.apply 31 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Studio/ReleaseAlbum.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | [] 11 | module ReleaseAlbumCommand = 12 | /// Command to release an unreleased album. 13 | let create unreleasedAlbums = 14 | { Name = "release album" 15 | Description = Command.releaseAlbumDescription 16 | Handler = 17 | (fun _ -> 18 | let state = State.get () 19 | 20 | let currentBand = Queries.Bands.currentBand state 21 | 22 | showOptionalChoicePrompt 23 | "Which album do you want to release?" 24 | Generic.cancel 25 | (fun (unreleasedAlbum: UnreleasedAlbum) -> 26 | unreleasedAlbum.Album.Name) 27 | unreleasedAlbums 28 | |> Option.iter (Studio.promptToReleaseAlbum currentBand) 29 | 30 | Scene.World) } 31 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Travel/LeaveVehicle.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli 4 | open Duets.Cli.SceneIndex 5 | open Duets.Simulation 6 | 7 | [] 8 | module LeaveVehicleCommand = 9 | /// Command to leave the car or metro. 10 | let get = 11 | { Name = "leave" 12 | Description = "Allows you leave the current vehicle." 13 | Handler = 14 | fun _ -> 15 | Situations.freeRoam |> Effect.apply 16 | Scene.WorldAfterMovement } 17 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Commands/Wait.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Components.Commands 2 | 3 | open Duets.Cli 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | 8 | [] 9 | module WaitCommand = 10 | /// Command which passes time without doing any other change. 11 | let get = 12 | { Name = "wait" 13 | Description = Command.waitDescription 14 | Handler = 15 | (fun _ -> 16 | Wait 1 |> Effect.apply 17 | Scene.World) } 18 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Figlet.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Figlet 3 | 4 | open Spectre.Console 5 | 6 | /// Renders a figlet (see: http://www.figlet.org). 7 | let showFiglet text = 8 | let figlet = FigletText(text).Centered() 9 | figlet.Color <- Color.Blue 10 | AnsiConsole.Write(figlet) 11 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/GameInfo.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.GameInfo 3 | 4 | open Spectre.Console 5 | 6 | /// 7 | /// Shows the version of the game stylized. 8 | /// 9 | /// Current game version 10 | let showGameInfo version = 11 | let gameInfo = $"v{version}" 12 | let styledGameInfo = $"[bold blue dim]{gameInfo}[/]" 13 | 14 | System.Console.SetCursorPosition( 15 | (System.Console.WindowWidth - gameInfo.Length) / 2, 16 | System.Console.CursorTop 17 | ) 18 | 19 | AnsiConsole.MarkupLine(styledGameInfo) 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Layout.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Layout 3 | 4 | open Spectre.Console 5 | 6 | /// Clears the whole console buffer and shows an empty screen. 7 | let clearScreen () = System.Console.Clear() 8 | 9 | /// Prints an empty line in the console. 10 | let lineBreak () = AnsiConsole.WriteLine() 11 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Notification.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Notification 3 | 4 | open Duets.Cli.Text 5 | open Spectre.Console 6 | 7 | /// Shows a notification inside of a panel with a bell with the given title 8 | /// and the given body text. 9 | let showNotification (title: string) (text: string) = 10 | let header = PanelHeader(Styles.header $"{Emoji.notification} {title}") 11 | 12 | Panel( 13 | Markup(text), 14 | Header = header, 15 | Border = BoxBorder.Double, 16 | Expand = true, 17 | Padding = Padding(2, 4) 18 | ) 19 | |> AnsiConsole.Write 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Post.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Post 3 | 4 | open Duets.Cli.Text 5 | open Duets.Entities 6 | open Spectre.Console 7 | 8 | /// Shows a social network post posted by the given account. 9 | let showPost (account: SocialNetworkAccount) (post: SocialNetworkPost) = 10 | Panel( 11 | Rows( 12 | Markup( 13 | $"@{account.Handle} | {Generic.dateWithDay post.Timestamp}" 14 | |> Styles.faded 15 | ), 16 | Text(post.Text), 17 | Markup($"{Emoji.boost} {post.Reposts}") 18 | ), 19 | Border = BoxBorder.Rounded, 20 | Expand = true 21 | ) 22 | |> AnsiConsole.Write 23 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Separator.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Separator 3 | 4 | open Spectre.Console 5 | 6 | /// Renders a line into the screen with an optional text that, if given, shows 7 | /// in the center of the screen. 8 | let showSeparator text = 9 | let rule = Rule().Centered() 10 | rule.Style <- Style.Parse("blue dim") 11 | 12 | match text with 13 | | Some text -> rule.Title <- text 14 | | None -> () 15 | 16 | AnsiConsole.Write(rule) 17 | 18 | /// Renders an empty line in the screen. 19 | let lineBreak () = AnsiConsole.WriteLine() 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/Tip.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.Tip 3 | 4 | open Duets.Cli.Text 5 | open Spectre.Console 6 | 7 | /// Shows a tip (hint) to the player inside a panel. 8 | let showTip title text = 9 | let header = PanelHeader(Styles.header $"{Emoji.tip} {title}") 10 | 11 | Panel( 12 | Markup(text |> Styles.highlight), 13 | Header = header, 14 | Border = BoxBorder.Rounded, 15 | Expand = false 16 | ) 17 | |> AnsiConsole.Write 18 | 19 | /// Shows a list of tips (hints) to the player as a list with a header. 20 | let showTips tips = 21 | "Tips" |> Styles.title |> showMessage 22 | tips |> List.iter (fun tip -> $"- {tip}" |> Styles.hint |> showMessage) 23 | -------------------------------------------------------------------------------- /src/Duets.Cli/Components/VisualEffects.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Components.VisualEffects 3 | 4 | [] 5 | type millisecond 6 | 7 | /// Stops the execution of the CLI for the given amount of seconds. Used to 8 | /// provide a little dramatic pause. 9 | let wait (amount: int) = 10 | System.Threading.Thread.Sleep(amount / 1) 11 | -------------------------------------------------------------------------------- /src/Duets.Cli/Duets.Cli.fsproj.user: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | ProjectDebugger 5 | 6 | 7 | Duets.Cli NO SAVE 8 | 9 | -------------------------------------------------------------------------------- /src/Duets.Cli/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Duets.Cli": { 4 | "commandName": "Project" 5 | }, 6 | "Duets.Cli NO SAVE": { 7 | "commandName": "Project", 8 | "commandLineArgs": "--no-saving" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.SceneIndex 2 | 3 | open Duets.Entities 4 | 5 | /// Defines whether we should save before exiting the game or not. 6 | [] 7 | type ExitMode = 8 | | SaveGame 9 | | SkipSave 10 | 11 | /// Defines the index of all scenes available in the game that can be instantiated. 12 | [] 13 | type Scene = 14 | | MainMenu 15 | | Settings 16 | | Cheats 17 | | CharacterCreator 18 | /// World creator needs the playable character from the previous step. 19 | | WorldSelector of Character 20 | // Band creator needs the playable character that was created in the 21 | // previous step and the selected origin city. 22 | | BandCreator of Character * City 23 | /// Skill creator needs the playable character and the band created in 24 | /// the previous steps. 25 | | SkillEditor of Character * CurrentMember * Band * City 26 | /// Shows the world and allows the character to move around and interact 27 | /// with different objects. 28 | | World 29 | /// Shows the world scene after character's movement same as before, but 30 | /// displaying details about the current place. 31 | | WorldAfterMovement 32 | | Phone 33 | // Saves the game and exits. 34 | | Exit of ExitMode 35 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Cheats.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Cheats 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | 8 | let private leaveCommand = 9 | { Name = "leave" 10 | Description = "Makes you come back to reality" 11 | Handler = fun _ -> Scene.World } 12 | 13 | /// Shows a scene that allows the player to input cheats and debug commands. 14 | let cheatsScene () = 15 | "You're now in cheat mode. Proceed with caution... or not, who cares." 16 | |> Styles.warning 17 | |> showMessage 18 | 19 | let prompt = 20 | $"""{"[[Cheat mode enabled]]" |> Styles.error} What magic trickery are you doing today?""" 21 | |> Styles.prompt 22 | 23 | let commands = 24 | leaveCommand :: ExitCommand.get :: MapCommand.get :: Cheats.Index.all 25 | 26 | commands 27 | |> (@) 28 | [ HelpCommand.createForApp 29 | "cheat mode" 30 | (fun () -> Scene.Cheats) 31 | commands ] 32 | |> showCommandPrompt prompt 33 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/NewGame/WorldSelector.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Cli.Scenes.NewGame.WorldSelector 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | open Duets.Simulation 8 | 9 | /// Shows a wizard that allows the player to customize the game world. 10 | let worldSelector character = 11 | showSeparator None 12 | let cities = Queries.World.allCities 13 | 14 | Creator.cityInfo |> showMessage 15 | 16 | let selectedCity = 17 | showChoicePrompt 18 | Creator.cityPrompt 19 | (fun (city: City) -> Generic.cityName city.Id) 20 | cities 21 | 22 | Scene.BandCreator(character, selectedCity) 23 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Bank/DistributeBandFunds.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Bank.DistributeBandFunds 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.Text 7 | open Duets.Entities 8 | open Duets.Simulation.Bands 9 | open Duets.Simulation.Bank.Operations 10 | 11 | let distributeFunds bankApp = 12 | let state = State.get () 13 | 14 | "This will distribute the amount you choose equally among all band members" 15 | |> Styles.faded 16 | |> showMessage 17 | 18 | let result = 19 | showDecimalPrompt "How much would you like to distribute?" 20 | |> Amount.fromDecimal 21 | |> FundDistribution.distribute state 22 | 23 | match result with 24 | | Ok(effects, totalPerMember) -> 25 | $"You got {totalPerMember |> Styles.money} as your share of the band funds" 26 | |> Styles.success 27 | |> showMessage 28 | 29 | effects |> Effect.applyMultiple 30 | | Error(NotEnoughFunds _) -> 31 | Phone.bankAppTransferNotEnoughFunds |> showMessage 32 | 33 | bankApp () 34 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Bank/Transfer.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Bank.Transfer 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.Text 7 | open Duets.Entities 8 | open Duets.Simulation.Bank.Operations 9 | 10 | /// Asks for the amount that the user wants to transfer from the two accounts 11 | /// and confirms the transaction. 12 | let transfer bankApp sender receiver = 13 | let amount = showDecimalPrompt (Phone.bankAppTransferAmount receiver) 14 | 15 | if amount > 0m then 16 | transfer (State.get ()) sender receiver (amount * 1m
) 17 | |> fun result -> 18 | match result with 19 | | Ok effects -> effects |> List.iter Effect.apply 20 | | Error(NotEnoughFunds _) -> 21 | Phone.bankAppTransferNotEnoughFunds |> showMessage 22 | else 23 | Phone.bankAppTransferNothingTransferred |> showMessage 24 | 25 | bankApp () 26 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/BnB/BnB.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.BnB.Root 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | 7 | type private BnBMenuOptions = 8 | | RentPlace 9 | | ListCurrentBookings 10 | 11 | let private textFromOption opt = 12 | match opt with 13 | | RentPlace -> "Rent place" 14 | | ListCurrentBookings -> "List current bookings" 15 | 16 | /// Creates the BnB app, which allows the user to rent places and manage their 17 | /// bookings. 18 | let rec bnbApp () = 19 | let selection = 20 | showOptionalChoicePrompt 21 | "What do you want to do?" 22 | Generic.back 23 | textFromOption 24 | [ RentPlace; ListCurrentBookings ] 25 | 26 | match selection with 27 | | Some RentPlace -> Rent.rent bnbApp 28 | | Some ListCurrentBookings -> List.listAll bnbApp 29 | | None -> Scene.Phone 30 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/ConcertAssistant/ConcertAssistant.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.ConcertAssistant.Root 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | 7 | type private ConcertMenuOption = 8 | | ScheduleSoloShow 9 | | ScheduleOpeningActShow 10 | 11 | let private textFromOption opt = 12 | match opt with 13 | | ScheduleSoloShow -> "Schedule a show with your band as the headliner" 14 | | ScheduleOpeningActShow -> "Scheduled a show supporting another band" 15 | 16 | let rec concertAssistantApp () = 17 | let selectedChoice = 18 | showOptionalChoicePrompt 19 | Phone.concertAssistantAppPrompt 20 | Generic.nothing 21 | textFromOption 22 | [ ScheduleSoloShow; ScheduleOpeningActShow ] 23 | 24 | match selectedChoice with 25 | | Some ScheduleSoloShow -> SoloShow.scheduleShow concertAssistantApp 26 | | Some ScheduleOpeningActShow -> OpeningAct.scheduleShow concertAssistantApp 27 | | None -> Scene.Phone 28 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Jobs/Jobs.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Jobs.Root 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | type private JobsMenuOption = | FindJob 11 | 12 | let private textFromOption opt = 13 | match opt with 14 | | FindJob -> Phone.findJobOption 15 | 16 | let rec jobsApp () = 17 | let currentCareer = Queries.Career.current (State.get ()) 18 | 19 | match currentCareer with 20 | | Some job -> 21 | let currentJobPlace = 22 | job.Location 23 | |> World.Coordinates.toPlaceCoordinates 24 | ||> Queries.World.placeInCityById 25 | 26 | Phone.currentJobDescription job currentJobPlace.Name 27 | | None -> Phone.unemployed 28 | |> showMessage 29 | 30 | lineBreak () 31 | 32 | let option = 33 | showOptionalChoicePrompt 34 | Phone.optionPrompt 35 | Generic.back 36 | textFromOption 37 | [ FindJob ] 38 | 39 | match option with 40 | | Some FindJob -> FindJob.findJob jobsApp currentCareer 41 | | _ -> Scene.Phone 42 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Commands/Exit.Mastodon.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Scenes.Phone.Apps.Mastodon.Commands 2 | 3 | open Duets.Cli.SceneIndex 4 | open Duets.Cli.Components.Commands 5 | 6 | [] 7 | module ExitCommand = 8 | /// Command which returns the user to the phone. 9 | let get = 10 | { Name = "exit" 11 | Description = "Closes the app and returns you to the phone" 12 | Handler = fun _ -> Scene.Phone } 13 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Commands/Post.Mastodon.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Scenes.Phone.Apps.Mastodon.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components 6 | open Duets.Cli.Components.Commands 7 | open Duets.Entities 8 | open Duets.Cli.Text 9 | open Duets.Simulation 10 | 11 | [] 12 | module PostCommand = 13 | /// Command which allows the player to post a new toot. 14 | let create account mastodonApp = 15 | { Name = "post" 16 | Description = 17 | $"""Allows you to post something new on your current account""" 18 | Handler = 19 | fun _ -> 20 | let tootText = 21 | showTextPrompt 22 | $"What's on your mind? Will be posted as {Styles.highlight account.Handle}" 23 | 24 | SocialNetworks.Post.toMastodon (State.get ()) account tootText 25 | |> Effect.apply 26 | 27 | mastodonApp () } 28 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Commands/SignUp.Mastodon.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Scenes.Phone.Apps.Mastodon.Commands 2 | 3 | open Duets.Cli.Components.Commands 4 | open Duets.Cli.Scenes.Phone.Apps.Mastodon 5 | 6 | [] 7 | module SignUpCommand = 8 | /// Command that allows the player to register another account. 9 | let create mastodonApp = 10 | { Name = "sign up" 11 | Description = 12 | "Allows you to register an account for you or your band, if you haven't done so already" 13 | Handler = fun _ -> SignUp.showSignUpFlow mastodonApp } 14 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Commands/SwitchAccount.Mastodon.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Scenes.Phone.Apps.Mastodon.Commands 2 | 3 | open Duets.Agents 4 | open Duets.Cli 5 | open Duets.Cli.Components.Commands 6 | open Duets.Entities 7 | open Duets.Simulation 8 | 9 | [] 10 | module SwitchAccountCommand = 11 | /// Command to switch between the character and the band's account. 12 | let create mastodonApp = 13 | { Name = "switch account" 14 | Description = 15 | "Switches between your character's and your band's account" 16 | Handler = 17 | fun _ -> 18 | SocialNetworks.Account.switch 19 | (State.get ()) 20 | SocialNetworkKey.Mastodon 21 | |> Effect.applyMultiple 22 | 23 | mastodonApp () } 24 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Commands/Timeline.Mastodon.Command.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Scenes.Phone.Apps.Mastodon.Commands 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.Components.Commands 5 | open Duets.Entities 6 | 7 | [] 8 | module TimelineCommand = 9 | /// Command which shows the current timeline to the player. 10 | let create account mastodonApp = 11 | { Name = "timeline" 12 | Description = "Shows all the toots that you previously posted" 13 | Handler = 14 | fun _ -> 15 | if List.isEmpty account.Posts then 16 | "Nothing to show" |> showMessage 17 | else 18 | account.Posts |> List.iter (showPost account) 19 | 20 | mastodonApp () } 21 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Mastodon/Mastodon.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Cli.Scenes.Phone.Apps.Mastodon.Root 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components.Commands 5 | open Duets.Cli.Text 6 | open Duets.Cli.Components 7 | open Duets.Entities 8 | open Duets.Simulation 9 | open Duets.Cli.Scenes.Phone.Apps 10 | 11 | let rec mastodonApp () = 12 | let currentAccount = 13 | Queries.SocialNetworks.currentAccount 14 | (State.get ()) 15 | SocialNetworkKey.Mastodon 16 | 17 | match currentAccount with 18 | | Some account -> showPrompt account 19 | | None -> SignUp.showInitialSignUpFlow mastodonApp 20 | 21 | and private showPrompt account = 22 | let promptText = 23 | $"""{Emoji.mastodon} {$"@{account.Handle}" |> Styles.highlight} | {$"{Styles.number account.Followers} followers" |> Styles.Level.good}""" 24 | 25 | let appCommands = 26 | [ Mastodon.Commands.TimelineCommand.create account mastodonApp 27 | Mastodon.Commands.SignUpCommand.create mastodonApp 28 | Mastodon.Commands.SwitchAccountCommand.create mastodonApp 29 | Mastodon.Commands.PostCommand.create account mastodonApp 30 | Mastodon.Commands.ExitCommand.get ] 31 | 32 | appCommands 33 | |> (@) [ HelpCommand.createForApp "Mastodon" mastodonApp appCommands ] 34 | |> showCommandPrompt promptText 35 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Statistics/RelationshipsStatistics.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Statistics.Relationships 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.Text 6 | open Duets.Common 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | let rec relationshipsStatisticsSubScene statisticsApp = 11 | let state = State.get () 12 | let relationships = Queries.Relationship.all state |> List.ofMapValues 13 | 14 | let tableColumns = 15 | [ Styles.header "Name" 16 | Styles.header "Relationship type" 17 | Styles.header "Level" ] 18 | 19 | let tableRows = 20 | relationships 21 | |> List.map (fun relationship -> 22 | let npc = Queries.Characters.find state relationship.Character 23 | 24 | [ Styles.person npc.Name 25 | Social.relationshipType relationship.RelationshipType 26 | |> Styles.highlight 27 | $"{relationship.Level |> Styles.Level.from}%%" ]) 28 | 29 | showTable tableColumns tableRows 30 | 31 | statisticsApp () 32 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Statistics/ReviewStatistics.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Statistics.AlbumReviews 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.Text 6 | open Duets.Entities 7 | open Duets.Simulation.Queries 8 | 9 | let rec reviewsStatisticsSubScene statisticsApp = 10 | let state = State.get () 11 | let band = Bands.currentBand state 12 | 13 | let releases = Albums.releasedByBand state band.Id 14 | 15 | if List.isEmpty releases then 16 | Phone.statisticsAppAlbumNoEntries |> showMessage 17 | statisticsApp () 18 | else 19 | showAlbumSelection statisticsApp releases 20 | 21 | and private showAlbumSelection statisticsApp albums = 22 | let selection = 23 | showOptionalChoicePrompt 24 | "Which album do you want to see reviews for?" 25 | Generic.backToPhone 26 | (_.Album.Name) 27 | albums 28 | 29 | match selection with 30 | | Some album -> 31 | if List.isEmpty album.Reviews then 32 | "No one really cared about the album that much to write a review" 33 | |> Styles.error 34 | |> showMessage 35 | else 36 | showReviews album 37 | 38 | reviewsStatisticsSubScene statisticsApp 39 | | None -> statisticsApp () 40 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Statistics/Statistics.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Statistics.Root 2 | 3 | open Duets.Cli.Components 4 | open Duets.Cli.SceneIndex 5 | open Duets.Cli.Text 6 | 7 | type private StatisticsOption = 8 | | Band 9 | | Albums 10 | | Reviews 11 | | Relationships 12 | 13 | let private textFromOption opt = 14 | match opt with 15 | | Band -> "Band statistics" 16 | | Albums -> "Album statistics" 17 | | Reviews -> "Album reviews" 18 | | Relationships -> "Relationships" 19 | 20 | let rec statisticsApp () = 21 | let selectedChoice = 22 | showOptionalChoicePrompt 23 | Phone.statisticsAppSectionPrompt 24 | Generic.backToPhone 25 | textFromOption 26 | [ Band; Albums; Reviews; Relationships ] 27 | 28 | match selectedChoice with 29 | | Some Band -> Band.bandStatisticsSubScene statisticsApp 30 | | Some Albums -> Albums.albumsStatisticsSubScene statisticsApp 31 | | Some Reviews -> AlbumReviews.reviewsStatisticsSubScene statisticsApp 32 | | Some Relationships -> 33 | Relationships.relationshipsStatisticsSubScene statisticsApp 34 | | None -> Scene.Phone 35 | -------------------------------------------------------------------------------- /src/Duets.Cli/Scenes/Phone/Apps/Weather/Weather.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Scenes.Phone.Apps.Weather.Root 2 | 3 | open Duets.Agents 4 | open Duets.Cli.Components 5 | open Duets.Cli.SceneIndex 6 | open Duets.Cli.Text 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | let private weatherIcon weatherCondition = 11 | match weatherCondition with 12 | | WeatherCondition.Sunny -> "☀️" 13 | | WeatherCondition.Cloudy -> "☁️" 14 | | WeatherCondition.Rainy -> "🌧️" 15 | | WeatherCondition.Stormy -> "⛈️" 16 | | WeatherCondition.Snowy -> "❄️" 17 | 18 | let private weatherDescription weatherCondition = 19 | match weatherCondition with 20 | | WeatherCondition.Sunny -> "Sunny" 21 | | WeatherCondition.Cloudy -> "Cloudy" 22 | | WeatherCondition.Rainy -> "Rainy" 23 | | WeatherCondition.Stormy -> "Stormy" 24 | | WeatherCondition.Snowy -> "Snowy" 25 | 26 | let weatherApp () = 27 | let state = State.get () 28 | let currentCity = Queries.World.currentCity state 29 | let currentWeather = Queries.World.currentWeather state 30 | 31 | let icon = weatherIcon currentWeather 32 | let description = weatherDescription currentWeather 33 | 34 | Phone.weatherAppTitle |> Styles.header |> showMessage 35 | Phone.weatherAppContent currentCity.Id icon description |> showMessage 36 | 37 | showContinuationPrompt () 38 | 39 | Scene.Phone 40 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Character.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Text.Character 2 | 3 | open Duets.Entities 4 | 5 | let attributeName attr = 6 | match attr with 7 | | CharacterAttribute.Drunkenness -> "Drunkenness" 8 | | CharacterAttribute.Energy -> "Energy" 9 | | CharacterAttribute.Fame -> "Fame" 10 | | CharacterAttribute.Health -> "Health" 11 | | CharacterAttribute.Hunger -> "Hunger" 12 | | CharacterAttribute.Mood -> "Mood" 13 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Date.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Text.Date 2 | 3 | open Duets.Entities 4 | 5 | let seasonName (season: Season) = 6 | match season with 7 | | Spring -> "Spring" 8 | | Summer -> "Summer" 9 | | Autumn -> "Autumn" 10 | | Winter -> "Winter" 11 | 12 | /// Formats a date to `Season, Year` format. 13 | let seasonYear (date: Date) = 14 | $"{seasonName date.Season}, {date.Year}" 15 | 16 | /// Formats a date to `Day d of Season, Year` format. 17 | let simple (date: Date) = $"Day {date.Day} of {seasonYear date}" 18 | 19 | /// Formats a date to the dd/mm/yyyy format and adds the name of the day in 20 | /// the beginning. 21 | let withDayName (date: Date) = 22 | $"{Calendar.Query.dayOfWeek date}, {simple date}" 23 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Events.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Text.Events 3 | 4 | let healthDepletedFirst = 5 | Styles.danger "You start to feel lightheaded and your vision begins to blur" 6 | 7 | let healthDepletedSecond = 8 | Styles.danger 9 | "The background noise starts to fade into the background and a buzz grows inside your ear..." 10 | 11 | let hospitalized = 12 | Styles.information 13 | "You wake up in the hospital a week later. Your head hurts a bit, but other than that it seems like you will make it this time" 14 | 15 | let feelingTipsy = 16 | Styles.information 17 | "You feel a bit tipsy, your eyes start to lower a bit and you seem to have a fixed smile on your face" 18 | 19 | let feelingDrunk = 20 | Styles.information 21 | "You feel a bit drunk, doing stuff seems a bit more difficult than before" 22 | 23 | let feelingReallyDrunk = 24 | Styles.danger 25 | "You feel really drunk. Your eyes are blurry and your legs don't seem to be able to follow the same pattern. A part of your body asks you to stop, but the other one wants a bit more fun..." 26 | 27 | let soberingTipsy = 28 | Styles.information "You're feeling much better now, just slightly tipsy" 29 | 30 | let soberingDrunk = 31 | Styles.information 32 | "You're starting to get sober, still feeling really drunk, but better than before" 33 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/MainMenu.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Cli.Text.MainMenu 3 | 4 | let incompatibleSavegame message = 5 | Styles.error 6 | $"""Your savegame is incompatible or malformed and was ignored. Error: 7 | {message}""" 8 | 9 | let prompt = "Select an option to begin" 10 | let newGame = "New game" 11 | let loadGame = "Load game" 12 | let settings = "Settings" 13 | let exit = Styles.faded "Exit" 14 | 15 | let savegameNotAvailable = 16 | Styles.error "No savegame available. Create a new game" 17 | 18 | let newGameReplacePrompt = 19 | Styles.danger 20 | "Creating a new game will replace your current savegame and all the progress will be lost, are you sure?" 21 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Prompts/CommonPrompts.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Cli.Text.Prompts 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | [] 7 | module Common = 8 | /// Creates a prompt that improves the response quality of the language model. 9 | /// Currently tuned for Gemma 3, which requires explicit turn markers to get 10 | /// anything useful out of it. 11 | let internal createPrompt prompt = 12 | $""" 13 | user 14 | {prompt} 15 | 16 | model 17 | """ 18 | 19 | let internal itemNameForPrompt item = 20 | let mainProperty = item.Properties |> List.head 21 | 22 | match mainProperty with 23 | | Key(EntranceCard _) -> "entrance card" 24 | | Rideable(RideableItem.Car _) -> $"{item.Brand} {item.Name}" 25 | | _ -> item.Name |> String.lowercase 26 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Social.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Text.Social 2 | 3 | open Duets.Entities 4 | 5 | let actionPrompt date dayMoment attributes npc relationshipLevel = 6 | $"""{Generic.infoBar date dayMoment attributes} 7 | {Emoji.socializing} Talking with {npc.Name |> Styles.person} | {Emoji.relationshipLevel} {relationshipLevel} 8 | What do you want to do?""" 9 | |> Styles.prompt 10 | 11 | let relationshipType = 12 | function 13 | | Friend -> "Friend" 14 | | Bandmate -> "Bandmate" 15 | 16 | let npcSaysPrefix (npcName: string) = $"{Styles.person npcName}: " 17 | 18 | let npcSays npcName text = 19 | $"{npcSaysPrefix npcName}{Styles.dialog text}" 20 | -------------------------------------------------------------------------------- /src/Duets.Cli/Text/Songs.fs: -------------------------------------------------------------------------------- 1 | module Duets.Cli.Text.Songs 2 | 3 | open Duets.Entities 4 | 5 | /// Formats a given length as minutes:seconds. 6 | let length (l: Length) = $"{l.Minutes}:{l.Seconds}" 7 | -------------------------------------------------------------------------------- /src/Duets.Common/Duets.Common.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(DotnetVersion) 4 | true 5 | true 6 | Common 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Duets.Common/Func.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Func 2 | 3 | /// Transforms an F# function into a System.Func. 4 | let toFunc<'a, 'b> f = System.Func<'a, 'b>(f) 5 | 6 | /// Wraps a value in a function that ignores its input and returns the value. 7 | let toConst<'a> (value: 'a) _ = value 8 | -------------------------------------------------------------------------------- /src/Duets.Common/Map.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Map 2 | 3 | open Aether 4 | 5 | /// Prism to a value associated with a key in a map. Similar as Aether's 6 | /// built in but this one always introduces the key in the map regardless 7 | /// whether it already exists or not. 8 | let key_ (k: 'k) : Prism, 'v> = Map.tryFind k, Map.add k 9 | 10 | /// Prism to a value associated with a key in a map that returns a default value 11 | /// if the key could not be found. 12 | let keyWithDefault_ (k: 'k) (defaultValue: 'v) : Prism, 'v> = 13 | Map.tryFind k >> Option.defaultValue defaultValue >> Some, Map.add k 14 | 15 | /// Attempts to find the head of the map. 16 | let tryHead (map: Map<'k, 'v>) = 17 | map |> Seq.tryHead |> Option.map (_.Value) 18 | 19 | /// Returns the head of the map. 20 | let head (map: Map<'k, 'v>) = tryHead map |> Option.get 21 | 22 | /// Merges two maps, resolving conflicts by preserving only the value from the 23 | /// left map. 24 | let merge (left: Map<'a, 'b>) (right: Map<'a, 'b>) = 25 | Map.fold (fun acc key value -> Map.add key value acc) left right 26 | -------------------------------------------------------------------------------- /src/Duets.Common/Operators.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Operators 2 | 3 | /// Returns the result of performing the exclusive between operation on the 4 | /// given values. 5 | let (><) x (min, max) = (x > min) && (x < max) 6 | 7 | /// Returns the result of performing the inclusive between operation on the 8 | /// given values. 9 | let (>=<) x (min, max) = (x >= min) && (x <= max) 10 | -------------------------------------------------------------------------------- /src/Duets.Common/Option.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Option 2 | 3 | /// Returns the value of the given option if it is some and throws an exception 4 | /// otherwise. 5 | let value opt = 6 | match opt with 7 | | Some x -> x 8 | | None -> failwith "Option is None" 9 | 10 | /// Returns a unit option as some if the given boolean is true and None otherwise. 11 | let ofBool res = 12 | match res with 13 | | true -> Some() 14 | | false -> None 15 | 16 | /// Returns a non-empty list option as some if the given list is not empty and 17 | /// None otherwise. 18 | let ofList xs = 19 | match xs with 20 | | [] -> None 21 | | _ -> Some xs 22 | 23 | /// Taps into an option, applying the given function if there is some content 24 | /// and returning the param untouched. Similar to an `iter` and `map` but without 25 | /// changing the value of the content. 26 | let tap func opt = 27 | match opt with 28 | | Some x -> 29 | func x 30 | Some(x) 31 | | None -> None 32 | -------------------------------------------------------------------------------- /src/Duets.Common/Pipe.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Pipe 2 | 3 | /// Executes a function that takes the given element and returns the element 4 | /// to continue the pipe. 5 | let tap fn element = 6 | fn element 7 | element 8 | -------------------------------------------------------------------------------- /src/Duets.Common/README.md: -------------------------------------------------------------------------------- 1 | # Common 2 | 3 | This assembly is a sort of "utilities" one that introduces various wrappers around .NET methods to make them more 4 | functional and F# friendly or adds common functions that make sense to be used in all other assemblies and are general 5 | usage rather than domain dependent. -------------------------------------------------------------------------------- /src/Duets.Common/Random.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Random 2 | 3 | let private seededRandom = System.Random() 4 | 5 | /// Gives back a random boolean. 6 | let boolean () = seededRandom.NextDouble() >= 0.5 7 | 8 | /// Returns a random non-negative number. 9 | let random () = seededRandom.Next() 10 | 11 | /// Returns a random number between the inclusive range of min and max. 12 | let between min max = seededRandom.Next(min, max) 13 | 14 | /// Returns a random non-negative float between min and max. 15 | let floatBetween min max = 16 | seededRandom.NextDouble() * (max - min) + min 17 | -------------------------------------------------------------------------------- /src/Duets.Common/Seq.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Seq 2 | 3 | /// Applies a side-effect to each element of a sequence similar to how Seq.iter 4 | /// works but returning the element that is being processed afterwards. 5 | let tap fn = 6 | Seq.map (fun item -> 7 | fn item 8 | item) 9 | -------------------------------------------------------------------------------- /src/Duets.Common/Serializer.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Serializer 2 | 3 | open System.Text.Json 4 | open System.Text.Json.Serialization 5 | 6 | let private jsonOptions = 7 | let options = JsonSerializerOptions() 8 | options.Converters.Add(JsonFSharpConverter()) 9 | options 10 | 11 | /// Deserializes a string into whichever type is passed. 12 | let deserialize (str: string) = 13 | JsonSerializer.Deserialize<'a>(str, jsonOptions) 14 | 15 | /// Serializes the input into a string. 16 | let serialize input = 17 | JsonSerializer.Serialize(input, jsonOptions) 18 | -------------------------------------------------------------------------------- /src/Duets.Common/Tuple.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Tuple 2 | 3 | /// Creates a tuple of two given its arguments. 4 | let two first second = (first, second) 5 | 6 | /// Returns the first element of a tuple of three elements. 7 | let fst3 (first, _, _) = first 8 | 9 | /// Returns the second element of a tuple of three elements. 10 | let snd3 (_, second, _) = second 11 | -------------------------------------------------------------------------------- /src/Duets.Common/Union.fs: -------------------------------------------------------------------------------- 1 | module Duets.Common.Union 2 | 3 | open FSharp.Reflection 4 | 5 | /// Returns a list with all cases of a discriminated union. This only works for 6 | /// unions with no arguments. For those that have arguments they'll be ignored 7 | /// from the list. 8 | let allCasesOf<'a> () : 'a list = 9 | FSharpType.GetUnionCases typeof<'a> 10 | |> Array.choose (fun uc -> 11 | let constructorArgs = uc.GetFields() 12 | 13 | if constructorArgs.Length > 0 then 14 | None 15 | else 16 | FSharpValue.MakeUnion(uc, [||]) :?> 'a |> Some) 17 | |> List.ofArray 18 | 19 | /// Returns the name of one case of a discriminated union. 20 | let caseName (x: 'a) = 21 | match FSharpValue.GetUnionFields(x, typeof<'a>) with 22 | | case, _ -> case.Name 23 | -------------------------------------------------------------------------------- /src/Duets.Data/ATTRIBUTIONS.md: -------------------------------------------------------------------------------- 1 | ### Adjectives, adverbs, nouns and books 2 | 3 | Taken from the incredibly useful [Corpora project](https://github.com/dariusk/corpora). 4 | -------------------------------------------------------------------------------- /src/Duets.Data/Genres.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Genres 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | /// Contains all available genres in the game 7 | let all: Genre list = ResourceLoader.load Files.Genres 8 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Electronics.Items.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Electronics 2 | 3 | open Duets.Entities 4 | 5 | module Dartboard = 6 | let dartboard: PurchasableItem = 7 | { Brand = "Bull's" 8 | Name = "Dartboard" 9 | Properties = [ Playable(Darts) ] }, 10 | 230m
11 | 12 | module GameConsole = 13 | let xbox: PurchasableItem = 14 | { Brand = "Microsoft" 15 | Name = "Xbox Series X" 16 | Properties = [ Playable(VideoGame) ] }, 17 | 550m
18 | 19 | module Tv = 20 | let lgTv: PurchasableItem = 21 | { Brand = "LG" 22 | Name = "TV" 23 | Properties = [ Watchable ] }, 24 | 850m
25 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/All.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Index 2 | 3 | open Duets.Entities 4 | open Duets.Data.Items.Food 5 | 6 | let all: PurchasableItem list = 7 | Breakfast.all 8 | @ Czech.all 9 | @ Japanese.all 10 | @ Italian.all 11 | @ French.all 12 | @ Mexican.all 13 | @ Snack.all 14 | @ Spanish.all 15 | @ Turkish.all 16 | @ USA.all 17 | @ Vietnamese.all 18 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Breakfast.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Breakfast 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Avocado Egg Sandwich" 150 Healthy 25, 3.4m
7 | 8 | Item.Food.create "Bagel with Cream Cheese" 150 Unhealthy 10, 2.4m
9 | 10 | Item.Food.create "BLT Sandwich" 350 Regular 20, 3.2m
11 | 12 | Item.Food.create "Croissant" 250 Unhealthy 0, 1.2m
13 | 14 | Item.Food.create "Fruit Plate" 200 Healthy 2, 2.8m
15 | 16 | Item.Food.create "Yogurt Granola Bowl" 250 Healthy 2, 3.0m
] 17 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Czech.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Czech 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Roasted Duck" 300 Unhealthy 70, 5.5m
7 | 8 | Item.Food.create "Cabbage Soup" 250 Regular 40, 2.3m
9 | 10 | Item.Food.create "Bramboráky" 200 Regular 45, 3.2m
11 | 12 | Item.Food.create "Svíčková" 550 Unhealthy 85, 5.8m
13 | 14 | Item.Food.create "Smažený sýr" 350 Unhealthy 25, 3.2m
15 | 16 | Item.Food.create "Koleno" 500 Unhealthy 75, 7.5m
17 | 18 | Item.Food.create "Buchtičky s krémem" 200 Unhealthy 55, 3.7m
19 | 20 | Item.Food.create "Hovězí Guláš" 550 Regular 60, 5.3m
21 | 22 | Item.Food.create "Palacinky" 200 Regular 30, 3.8m
] 23 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/French.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.French 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Duck Confit" 300 Unhealthy 80, 14.0m
7 | 8 | Item.Food.create "Bouillabaisse" 500 Regular 85, 13.5m
9 | 10 | Item.Food.create "Salade Niçoise" 250 Healthy 30, 8.0m
11 | 12 | Item.Food.create "Beef Bourguignon" 350 Unhealthy 85, 15.8m
13 | 14 | Item.Food.create "Crème Brûlée" 200 Unhealthy 60, 7.0m
15 | 16 | Item.Food.create "Ratatouille" 300 Healthy 50, 9.5m
17 | 18 | Item.Food.create "Coq au Vin" 350 Regular 75, 13.2m
19 | 20 | Item.Food.create "Escargot" 150 Regular 60, 11.3m
21 | 22 | Item.Food.create "Moules Marinières" 500 Regular 40, 12.6m
23 | 24 | Item.Food.create "Tarte Tatin" 200 Unhealthy 70, 6.5m
] 25 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Japanese.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Japanese 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Gyoza" 100 Regular 50, 3.3m
7 | 8 | Item.Food.create "Miso Ramen" 450 Healthy 70, 6.4m
9 | 10 | Item.Food.create "Tonkotsu Ramen" 450 Healthy 80, 6.3m
11 | 12 | Item.Food.create "Salmon Nigiri" 100 Healthy 55, 7m
13 | 14 | Item.Food.create "Tuna Nigiri" 100 Healthy 55, 7m
15 | 16 | Item.Food.create "Avocado Nigiri" 100 Healthy 45, 7m
17 | 18 | Item.Food.create "Salmon Maki" 100 Healthy 55, 7.2m
19 | 20 | Item.Food.create "Avocado Maki" 100 Healthy 50, 7.2m
21 | 22 | Item.Food.create "California Roll" 150 Healthy 60, 7.8m
23 | 24 | Item.Food.create "Wakame" 100 Healthy 10, 2.5m
] 25 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Mexican.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Mexican 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Tacos al Pastor" 300 Regular 55, 8.0m
7 | 8 | Item.Food.create "Enchiladas" 300 Unhealthy 65, 8.5m
9 | 10 | Item.Food.create "Guacamole" 150 Healthy 15, 4.5m
11 | 12 | Item.Food.create "Chiles Rellenos" 250 Regular 75, 9.0m
13 | 14 | Item.Food.create "Churros" 200 Unhealthy 50, 5.0m
15 | 16 | Item.Food.create "Quesadilla" 250 Regular 20, 7.5m
17 | 18 | Item.Food.create "Carnitas" 350 Unhealthy 70, 10.0m
19 | 20 | Item.Food.create "Pozole" 500 Regular 70, 9.8m
21 | 22 | Item.Food.create "Sopa Azteca" 350 Regular 40, 7.6m
23 | 24 | Item.Food.create "Flan" 150 Unhealthy 60, 4.2m
] 25 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Snack.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Snack 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Potato Chips" 150 Unhealthy 0, 2.49m
7 | 8 | Item.Food.create "Pretzels" 100 Unhealthy 0, 1.99m
9 | 10 | Item.Food.create "Chocolate Bar" 50 Unhealthy 0, 1.25m
11 | 12 | Item.Food.create "Candy Corn" 100 Unhealthy 0, 0.99m
13 | 14 | Item.Food.create "Popcorn" 75 Unhealthy 0, 1.99m
15 | 16 | Item.Food.create "Cheese Puffs" 100 Unhealthy 0, 1.49m
17 | 18 | Item.Food.create "Nachos" 200 Unhealthy 0, 2.75m
19 | 20 | Item.Food.create "Cookies" 50 Unhealthy 0, 1.99m
21 | 22 | Item.Food.create "Gummy Bears" 100 Unhealthy 0, 1.49m
] 23 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Spanish.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Spanish 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Paella" 400 Regular 80, 13.0m
7 | Item.Food.create "Tortilla de Patatas" 250 Healthy 40, 7.0m
8 | Item.Food.create "Gazpacho" 300 Healthy 30, 6.5m
9 | Item.Food.create "Pulpo a la Gallega" 250 Regular 75, 12.0m
10 | Item.Food.create "Patatas Bravas" 200 Unhealthy 30, 5.0m
11 | Item.Food.create "Croquetas de Jamón" 180 Unhealthy 50, 6.2m
12 | Item.Food.create "Churros con Chocolate" 180 Unhealthy 35, 5.5m
13 | Item.Food.create "Fabada Asturiana" 350 Regular 70, 10.5m
14 | Item.Food.create "Calamares a la Romana" 220 Unhealthy 60, 8.0m
15 | Item.Food.create "Pimientos de Padrón" 120 Healthy 20, 4.5m
16 | Item.Food.create "Empanada Gallega" 200 Regular 45, 6.8m
17 | Item.Food.create "Cochinillo Asado" 400 Unhealthy 90, 16.0m
18 | Item.Food.create "Salmorejo" 300 Healthy 35, 6.7m
19 | Item.Food.create "Gambas al Ajillo" 180 Regular 55, 9.0m
20 | Item.Food.create "Callos a la Madrileña" 320 Regular 80, 11.5m
21 | Item.Food.create "Ensaimada" 140 Unhealthy 35, 5.8m
] 22 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Turkish.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Turkish 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Kebab" 500 Unhealthy 45, 5.5m
7 | 8 | Item.Food.create "Durum" 600 Unhealthy 30, 6.5m
9 | 10 | Item.Food.create "Baklava" 100 Unhealthy 80, 3.0m
11 | 12 | Item.Food.create "Manti" 400 Unhealthy 75, 6.0m
] 13 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/USA.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.USA 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Grilled Pork Ribs" 500 Unhealthy 70, 15.0m
7 | 8 | Item.Food.create "BBQ Chicken Wings" 300 Unhealthy 40, 9.5m
9 | 10 | Item.Food.create "Corn on the Cob" 200 Healthy 10, 4.0m
11 | 12 | Item.Food.create "Steak Sandwich" 250 Regular 45, 10.0m
13 | 14 | Item.Food.create "Pulled Pork Bun" 350 Unhealthy 65, 11.0m
15 | 16 | Item.Food.create "Grilled Vegetables" 300 Healthy 20, 8.0m
17 | 18 | Item.Food.create "Beef Brisket" 400 Unhealthy 80, 16.0m
19 | 20 | Item.Food.create "BBQ Baked Beans" 250 Regular 30, 6.0m
21 | 22 | Item.Food.create "Smoked Sausage" 200 Regular 15, 9.5m
23 | 24 | Item.Food.create "Apple Pie" 200 Unhealthy 60, 6.0m
] 25 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Food/Vietnamese.Food.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Food.Vietnamese 2 | 3 | open Duets.Entities 4 | 5 | let all: PurchasableItem list = 6 | [ Item.Food.create "Bún Bò Nam Bộ" 350 Healthy 65, 5.30m
7 | 8 | Item.Food.create "Nem cuốn bò" 100 Healthy 45, 3.50m
9 | 10 | Item.Food.create "Nem cuốn tôm" 100 Healthy 45, 3.35m
11 | 12 | Item.Food.create "Phở Bò" 350 Healthy 75, 5.45m
] 13 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Furniture.Items.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Furniture 2 | 3 | open Duets.Entities 4 | 5 | module Bed = 6 | let ikeaBed: PurchasableItem = 7 | { Brand = "IKEA" 8 | Name = "Bed" 9 | Properties = [ Sleepable ] }, 10 | 450m
11 | 12 | module BilliardTable = 13 | let sonomaTable: PurchasableItem = 14 | { Brand = "Sonoma" 15 | Name = "Billiard table" 16 | Properties = [ Playable(Billiard) ] }, 17 | 3400m
18 | 19 | module Storage = 20 | let samsungFridge: PurchasableItem = 21 | { Brand = "Samsung" 22 | Name = "Fridge" 23 | Properties = [ Storage(Fridge, []) ] }, 24 | 750m
25 | 26 | let ikeaShelf: PurchasableItem = 27 | { Brand = "IKEA" 28 | Name = "Shelf" 29 | Properties = [ Storage(Shelf, []) ] }, 30 | 150m
31 | 32 | module Stove = 33 | let lgStove: PurchasableItem = 34 | { Brand = "LG" 35 | Name = "Stove" 36 | Properties = [ Cookware ] }, 37 | 650m
38 | -------------------------------------------------------------------------------- /src/Duets.Data/Items/Gym.Items.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Items.Gym 2 | 3 | open Duets.Entities 4 | 5 | module WeightMachines = 6 | let benchPress: PurchasableItem = 7 | { Brand = "GymTech" 8 | Name = "Bench Press" 9 | Properties = [ FitnessEquipment ] }, 10 | 650m
11 | 12 | let squatRack: PurchasableItem = 13 | { Brand = "GymTech" 14 | Name = "Power Rack" 15 | Properties = [ FitnessEquipment ] }, 16 | 550m
17 | 18 | let legPress: PurchasableItem = 19 | { Brand = "GymTech" 20 | Name = "Leg Press" 21 | Properties = [ FitnessEquipment ] }, 22 | 450m
23 | 24 | module Treadmills = 25 | let treadmill: PurchasableItem = 26 | { Brand = "GymTech" 27 | Name = "Treadmill" 28 | Properties = [ FitnessEquipment ] }, 29 | 550m
30 | 31 | let elliptical: PurchasableItem = 32 | { Brand = "GymTech" 33 | Name = "Elliptical" 34 | Properties = [ FitnessEquipment ] }, 35 | 450m
36 | 37 | let all = 38 | [ WeightMachines.benchPress 39 | WeightMachines.squatRack 40 | WeightMachines.legPress 41 | Treadmills.treadmill 42 | Treadmills.elliptical ] 43 | -------------------------------------------------------------------------------- /src/Duets.Data/Npcs.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Npcs 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | /// Contains all pre-defined NPCs of the game. 7 | let all: (string * Gender) list = ResourceLoader.load Files.Npcs 8 | 9 | /// Returns a random NPC from the pre-defined list. 10 | let random () = all |> List.sample 11 | -------------------------------------------------------------------------------- /src/Duets.Data/ResourceLoader.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.ResourceLoader 2 | 3 | open Duets.Common 4 | 5 | /// Loads the content of a given file key. 6 | let internal load key = 7 | Files.dataFile key 8 | |> Files.readAll 9 | |> Option.defaultValue "" 10 | |> Serializer.deserialize 11 | -------------------------------------------------------------------------------- /src/Duets.Data/Resources/genres.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Ambient", 3 | "Black Metal", 4 | "Blackgaze", 5 | "Electronic", 6 | "Folk", 7 | "Hip-Hop", 8 | "Jazz", 9 | "Pop", 10 | "Rock", 11 | "Shoegaze" 12 | ] -------------------------------------------------------------------------------- /src/Duets.Data/Roles.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Roles 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | /// List of all available roles in the game. 7 | let all = Union.allCasesOf () 8 | 9 | /// Returns the usual roles that are used for a specific genre. 10 | let forGenre genre = 11 | match genre with 12 | | "Ambient" -> [ Guitar ] 13 | | "Electronic" -> [ Drums; Bass; Vocals ] 14 | | "Folk" -> [ Guitar; Vocals ] 15 | | "Hip-Hop" -> [ Vocals ] 16 | | "Black Metal" 17 | | "Blackgaze" 18 | | "Jazz" 19 | | "Pop" 20 | | "Rock" 21 | | "Shoegaze" 22 | | _ -> all 23 | -------------------------------------------------------------------------------- /src/Duets.Data/Savegame/Migrations/0_MigrateFromVersionless.fs: -------------------------------------------------------------------------------- 1 | module Data.Savegame.Migrations.MigrateFromVersionless 2 | 3 | open Duets.Data.Savegame.Types 4 | open FSharp.Data 5 | 6 | /// Migration that takes a root that contains an object without a version and 7 | /// transforms it into an object that has version 0 and the current data in a 8 | /// "Data" field. 9 | let migrate (root: JsonValue) = 10 | match root with 11 | | JsonValue.Record values -> 12 | Ok( 13 | JsonValue.Record 14 | [| ("Version", JsonValue.Number(0m)) 15 | ("Data", JsonValue.Record(values)) |] 16 | ) 17 | | _ -> Error(InvalidStructure("Root of savegame should be an object")) 18 | -------------------------------------------------------------------------------- /src/Duets.Data/Savegame/Types.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Savegame.Types 2 | 3 | open Duets.Entities 4 | 5 | /// Contents of the savegame file, which contains a version for migration 6 | /// purposes and the actual data. 7 | type SavegameContents = { Version: uint; Data: State } 8 | 9 | /// Errors that can occur during the application of migrations. 10 | type MigrationError = 11 | | InvalidStructure of message: string 12 | | InvalidVersion of parsedVersion: string 13 | -------------------------------------------------------------------------------- /src/Duets.Data/Skills.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Skills 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | /// Generates all the available skills in the game, scoped to a given genre and 7 | /// instrument. 8 | let allFor (genre: Genre) (instrument: InstrumentType) = 9 | let skillsWithoutParams = Union.allCasesOf () 10 | 11 | SkillId.Genre genre :: SkillId.Instrument instrument :: skillsWithoutParams 12 | |> List.map Skill.create 13 | -------------------------------------------------------------------------------- /src/Duets.Data/Util/NpcGen.fsx: -------------------------------------------------------------------------------- 1 | (* 2 | This is a very dirty, very quick script that generates NPCs of a given gender based on a list of names. 3 | It attempts to generate a JSON array that contains the NPCs in the specified format, but I'm too lazy 4 | to handle the commas properly so it outputs one extra comma that has to be removed in the last array 5 | element. 6 | *) 7 | 8 | // Add the list of names as a string, divided by a new-line. 9 | let input = 10 | """ 11 | First Name 12 | Second Name 13 | """ 14 | 15 | // Same type as entity: Male, Female or Other. 16 | let gender = "Other" 17 | 18 | input.Split("\n") 19 | |> Array.map (_.Trim()) 20 | |> Array.fold 21 | (fun output name -> 22 | if System.String.IsNullOrEmpty name then 23 | output 24 | else 25 | output 26 | + $""" 27 | [ 28 | "{name}", 29 | {{ 30 | "Case": "{gender}" 31 | }} 32 | ],""") 33 | "[\n" 34 | |> fun output -> output + "\n]" 35 | |> System.Console.WriteLine 36 | -------------------------------------------------------------------------------- /src/Duets.Data/VocalStyles.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.VocalStyles 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | let all = Union.allCasesOf () 7 | 8 | let allNames = all |> List.map (fun vs -> vs, vs.ToString()) 9 | -------------------------------------------------------------------------------- /src/Duets.Data/Words.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Words 2 | 3 | open Duets.Common 4 | 5 | let adjectives: string list = ResourceLoader.load Files.Adjectives 6 | let adverbs: string list = ResourceLoader.load Files.Adverbs 7 | let nouns: string list = ResourceLoader.load Files.Nouns 8 | -------------------------------------------------------------------------------- /src/Duets.Data/World/Cities/LosAngeles/Ids.LosAngeles.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.World.Cities.LosAngeles.Ids 2 | 3 | module Street = 4 | let hollywoodBoulevard = "Hollywood Boulevard" 5 | let figueroaStreet = "Figueroa Street" 6 | let grandAvenue = "Grand Avenue" 7 | let highlandAvenue = "Highland Avenue" 8 | let sunsetBoulevardHollywood = "Sunset Boulevard (Hollywood)" 9 | let wilshireBoulevardKoreatown = "Wilshire Boulevard (Koreatown)" 10 | let westernAvenue = "Western Avenue" 11 | let oceanAvenue = "Ocean Avenue" 12 | let mainStreet = "Main Street" 13 | let picoBoulevard = "Pico Boulevard" 14 | let centuryBoulevard = "Century Boulevard" 15 | 16 | module Zone = 17 | let hollywood = "Hollywood" 18 | let downtownLA = "Downtown LA" 19 | let koreatown = "Koreatown" 20 | let santaMonica = "Santa Monica" 21 | let lax = "LAX" 22 | -------------------------------------------------------------------------------- /src/Duets.Data/World/Cities/NewYork/Ids.NewYork.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.World.Cities.NewYork.Ids 2 | 3 | module Street = 4 | let broadway = "Broadway" 5 | let seventhAvenue = "7th Avenue" 6 | let fiftySeventhStreet = "57th Street" 7 | let bleeckerStreet = "Bleecker Street" 8 | let bowery = "Bowery" 9 | let irvingPlace = "Irving Place" 10 | let lafayetteAtlantic = "Lafayette Ave / Atlantic Ave" 11 | let frostStreet = "Frost Street" 12 | let vanWyckExpressway = "Van Wyck Expressway" 13 | let sixthAvenue = "6th Avenue" 14 | let lowerEastSide = "Lower East Side" 15 | let bedfordAvenue = "Bedford Avenue" 16 | 17 | module Zone = 18 | let midtownWest = "Midtown West" 19 | let lowerManhattan = "Lower Manhattan" 20 | let brooklyn = "Brooklyn" 21 | let jamaica = "Jamaica" 22 | -------------------------------------------------------------------------------- /src/Duets.Data/World/Cities/NewYork/NewYork.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Data.World.Cities.NewYork.Root 2 | 3 | open Duets.Entities 4 | 5 | let generate () = 6 | let jamaica = Jamaica.zone 7 | 8 | let city = 9 | World.City.create NewYork 6.0 -5 jamaica 10 | 11 | let brooklyn = Brooklyn.zone city 12 | let midtownWest = MidtownWest.createZone city 13 | let lowerManhattan = LowerManhattan.createZone city 14 | 15 | let blueMetroLine = 16 | { Id = Blue 17 | Stations = 18 | [ (midtownWest.Id, OnlyNext(lowerManhattan.Id)) 19 | (lowerManhattan.Id, PreviousAndNext(midtownWest.Id, brooklyn.Id)) 20 | (brooklyn.Id, PreviousAndNext(lowerManhattan.Id, jamaica.Id)) 21 | (jamaica.Id, OnlyPrevious(brooklyn.Id)) ] 22 | |> Map.ofList 23 | UsualWaitingTime = 10 } 24 | 25 | city 26 | |> World.City.addZone midtownWest 27 | |> World.City.addZone lowerManhattan 28 | |> World.City.addZone brooklyn 29 | |> World.City.addMetroLine blueMetroLine 30 | -------------------------------------------------------------------------------- /src/Duets.Data/World/Cities/Prague/Ids.Prague.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.World.Cities.Prague.Ids 2 | 3 | module Street = 4 | let staroměstskéNáměstí = "Staroměstské Náměstí" 5 | let václavskéNáměstí = "Václavské Náměstí" 6 | let národní = "Národní" 7 | let evropská = "Evropská" 8 | let janáčkovoNábřeží = "Janáčkovo Nábřeží" 9 | let dlouhá = "Dlouhá" 10 | let karlova = "Karlova" 11 | let náměstíMíru = "Náměstí Míru" 12 | let krymská = "Krymská" 13 | let jiříhozPoděbrad = "Jiřího z Poděbrad" 14 | 15 | module Zone = 16 | let holešovice = "Holešovice" 17 | let libeň = "Libeň" 18 | let novéMěsto = "Nové Město" 19 | let ruzyně = "Ruzyně" 20 | let smíchov = "Smíchov" 21 | let staréMěsto = "Staré Město" 22 | let vinohrady = "Vinohrady" 23 | let vršovice = "Vršovice" 24 | -------------------------------------------------------------------------------- /src/Duets.Data/World/Ids.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.World.Ids 2 | 3 | module Common = 4 | let bar = "bar" 5 | let cafe = "cafe" 6 | let lobby = "lobby" 7 | let restaurant = "restaurant" 8 | 9 | module Airport = 10 | let securityControl = "security_control" 11 | let boardingGate = "boarding_gate" 12 | 13 | module Bookstore = 14 | let readingRoom = "reading_room" 15 | 16 | module CarDealer = 17 | let showRoom = "show_room" 18 | 19 | module Casino = 20 | let casinoFloor = "casino_floor" 21 | 22 | module ConcertSpace = 23 | let backstage = "backstage" 24 | let stage = "stage" 25 | 26 | module Gym = 27 | let changingRoom = "changing_room" 28 | let gym = "gym" 29 | 30 | module Home = 31 | let kitchen = "kitchen" 32 | let livingRoom = "living_room" 33 | let bedroom = "bedroom" 34 | 35 | module Metro = 36 | let platform = "platform" 37 | 38 | module Studio = 39 | let masteringRoom = "mastering_room" 40 | let recordingRoom = "recording_room" 41 | 42 | module RehearsalRoom = 43 | let room (n: int<_>) = $"rehearsal_room_{n}" 44 | 45 | module Workshop = 46 | let workshop = "workshop" 47 | 48 | module Restaurant = 49 | let kitchen = "kitchen" 50 | -------------------------------------------------------------------------------- /src/Duets.Entities/Amount.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Amount 2 | 3 | /// Creates an amount from a decimal value. 4 | let fromDecimal (amount: decimal) : Amount = amount * 1m
5 | 6 | /// Creates an amount from a float value. 7 | let fromFloat (amount: float) : Amount = amount |> decimal |> fromDecimal 8 | 9 | /// Concerts an amount to a decimal value. 10 | let toDecimal (amount: Amount) : decimal = amount / 1m
11 | -------------------------------------------------------------------------------- /src/Duets.Entities/BankAccount.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.BankAccount 2 | 3 | /// Creates a bank account for the given character ID. 4 | let forCharacter id = 5 | { Holder = Character id 6 | Balance = 0m
} 7 | 8 | /// Creates a bank account for the given character ID with an initial transaction 9 | /// of the given balance. 10 | let forCharacterWithBalance id balance = 11 | { Holder = Character id 12 | Balance = balance } 13 | 14 | /// Creates a bank account for the given band ID. 15 | let forBand id = { Holder = Band id; Balance = 0m
} 16 | -------------------------------------------------------------------------------- /src/Duets.Entities/CalendarEvent.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.CalendarEvent 2 | 3 | /// Returns the date and day moment of the event 4 | let date eventType = 5 | let date, dayMoment = 6 | match eventType with 7 | | CalendarEventType.Flight flight -> flight.Date, flight.DayMoment 8 | | CalendarEventType.Concert concert -> concert.Date, concert.DayMoment 9 | 10 | Calendar.Transform.resetDayMoment date, dayMoment 11 | -------------------------------------------------------------------------------- /src/Duets.Entities/Career.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Career 2 | 3 | /// Returns the shift duration of the given job. 4 | let jobDuration job = 5 | match job.CurrentStage.Schedule with 6 | | JobSchedule.Free duration -> duration 7 | | JobSchedule.Fixed (_, _, duration) -> duration 8 | 9 | /// Retrieves the list of skills required for the given job. 10 | let jobSkills job = 11 | job.CurrentStage.Requirements 12 | |> List.choose (function 13 | | CareerStageRequirement.Skill(skillId, _) -> Some skillId 14 | | _ -> None) 15 | -------------------------------------------------------------------------------- /src/Duets.Entities/Flight.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Flight 2 | 3 | let create origin destination price date dayMoment = 4 | { Id = Identity.create () 5 | Origin = origin 6 | Destination = destination 7 | Price = price 8 | Date = date 9 | DayMoment = dayMoment 10 | AlreadyUsed = false } 11 | -------------------------------------------------------------------------------- /src/Duets.Entities/Identity.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Identity 2 | 3 | open System 4 | 5 | /// Creates a new identity GUID. 6 | let create = Guid.NewGuid 7 | 8 | /// Attempts to parse the given Identity GUID. 9 | let from (str: string) = Guid.Parse str 10 | 11 | module Reproducible = 12 | /// Creates a Base64 encoded based on the given input. This is useful for 13 | /// IDs that need to be reproducible, like the ones in Places or Zones that 14 | /// are based on the name of the place or zone. 15 | let create (input: string) = 16 | input |> Text.Encoding.ASCII.GetBytes |> Convert.ToBase64String 17 | 18 | /// Decodes a previously reproducible ID. 19 | let decode (identity: string) = 20 | identity |> Convert.FromBase64String |> Text.Encoding.ASCII.GetString 21 | -------------------------------------------------------------------------------- /src/Duets.Entities/Instrument.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Instrument 2 | 3 | /// Creates an instrument given its type. 4 | let createInstrument instrumentType = 5 | { Id = InstrumentId <| Identity.create () 6 | Type = instrumentType } 7 | 8 | module Type = 9 | /// Creates an instrument type given its type as a string. Defaults to vocals if 10 | /// an invalid string is given. 11 | let from str = 12 | match str with 13 | | "Guitar" -> Guitar 14 | | "Drums" -> Drums 15 | | "Bass" -> Bass 16 | | _ -> Vocals 17 | -------------------------------------------------------------------------------- /src/Duets.Entities/Interaction.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Interaction 2 | 3 | /// 4 | /// Invokes the given chooser when the interaction is of type FreeRoam, 5 | /// otherwise it returns None. 6 | /// 7 | let chooseFreeRoam chooser interactions = 8 | interactions 9 | |> List.choose (fun interaction -> 10 | match interaction with 11 | | Interaction.FreeRoam freeRoamInteraction -> 12 | chooser freeRoamInteraction 13 | | _ -> None) 14 | -------------------------------------------------------------------------------- /src/Duets.Entities/Inventory.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Inventory 2 | 3 | /// A record containing to empty inventories. 4 | let empty = 5 | { Character = List.empty 6 | Band = Map.empty } 7 | -------------------------------------------------------------------------------- /src/Duets.Entities/MiniGame.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.MiniGame 2 | 3 | open Duets.Common 4 | 5 | let allSuits = Union.allCasesOf () 6 | 7 | let allRanks = Union.allCasesOf () 8 | 9 | /// Contains all the possible cards in a deck. 10 | let allCards = 11 | List.allPairs allSuits allRanks 12 | |> List.map (fun (suit, rank) -> { Suit = suit; Rank = rank }) 13 | 14 | module Blackjack = 15 | /// Creates a new blackjack game given a bet. 16 | let create bet = 17 | { DealerHand = 18 | { Cards = [] 19 | Score = ScoreType.Single 0 } 20 | PlayerHand = 21 | { Cards = [] 22 | Score = ScoreType.Single 0 } 23 | Bet = bet } 24 | -------------------------------------------------------------------------------- /src/Duets.Entities/Moodlet.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Moodlet 2 | 3 | /// Creates a moodlet with the given type, start date, and expiration. 4 | let create t startDate expiration = 5 | { MoodletType = t 6 | StartedOn = startDate 7 | Expiration = expiration } 8 | 9 | /// Returns the days since the moodlet started. 10 | let daysSinceStart moodlet currentDate = 11 | Calendar.Query.daysBetween moodlet.StartedOn currentDate 12 | 13 | /// Returns the day moments since the moodlet started. 14 | let dayMomentsSinceStart moodlet currentDate = 15 | Calendar.Query.dayMomentsBetween moodlet.StartedOn currentDate 16 | -------------------------------------------------------------------------------- /src/Duets.Entities/README.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | Entities represent the domain of the game and that are used in all the rest of the layers. These entities contain only 4 | the type definition (inside of the `Types.fs` file) and validation logic through their `create` functions which 5 | encapsulate the entity creation to only allow valid data. 6 | 7 | ## Lenses 8 | 9 | All (or almost all) the types defined in `Types.fs` will have a lens defined in the `Lenses.fs` file. These lenses are pairs of getters and setters that define how to access and update the inner properties of a record as well as let us combine them to easily access deeply nested properties on records, which is a must given that the whole core of Duets is inmutable by default and there's a lot of nesting in the main State. These lenses are used everywhere in the codebase through a library called Aether that exposes utilities for combining, querying and updating lenses. 10 | 11 | For more details read the [Aether's guide for Lenses](https://xyncro.tech/aether/guides/lenses.html). 12 | -------------------------------------------------------------------------------- /src/Duets.Entities/Relationships.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Relationships 2 | 3 | /// An empty object with no relationships. 4 | let empty = 5 | { ByCharacterId = Map.empty 6 | ByMeetingCity = Map.empty } 7 | -------------------------------------------------------------------------------- /src/Duets.Entities/Rental.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Rental 2 | 3 | /// Retrieves the due date from the rental 4 | let dueDate rental = 5 | match rental.RentalType with 6 | | Seasonal nextPaymentDate 7 | | OneTime(_, nextPaymentDate) -> nextPaymentDate 8 | -------------------------------------------------------------------------------- /src/Duets.Entities/Social.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Social 2 | 3 | open Aether 4 | 5 | module State = 6 | let timesDoneAction socializingState action = 7 | Optic.get Lenses.SocializingState.actions_ socializingState 8 | |> List.filter (fun a -> a = action) 9 | |> List.length 10 | |> (*) 1 11 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Attribute.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module AttributeTypes = 5 | /// Identifier of an attribute of a character. 6 | [] 7 | type CharacterAttribute = 8 | | Drunkenness 9 | | Energy 10 | | Fame 11 | | Health 12 | | Hunger 13 | | Mood 14 | 15 | /// Wraps an int to define the amount of an attribute. These are always 16 | /// between 0 and 100. 17 | type CharacterAttributeAmount = int 18 | 19 | /// Gathers all the different needs and statuses of the character that 20 | /// increase and decrease with the normal flow of the game. All these attributes 21 | /// are represented from 0 to 100 and the absence of an attribute in the 22 | /// map indicates that the value is 0. 23 | type CharacterAttributes = Map 24 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Bank.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module BankTypes = 5 | /// Holder of an account in the in-game bank. 6 | type BankAccountHolder = 7 | | Character of CharacterId 8 | | Band of BandId 9 | 10 | /// Represents a transaction between two accounts in the game. 11 | type BankTransaction = 12 | | Incoming of amount: Amount * updatedBalance: Amount 13 | | Outgoing of amount: Amount * updatedBalance: Amount 14 | 15 | /// Represents a bank account in the game. We only keep track of accounts 16 | /// from the main character and its bands. 17 | type BankAccount = 18 | { Holder: BankAccountHolder 19 | Balance: Amount } 20 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Book.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module BookTypes = 5 | /// Defines the effect that reading a specific book has on a character. 6 | type BookEffect = 7 | | SkillGain of skill: SkillId * amount: int 8 | | MoodletGain of 9 | moodlet: MoodletType * 10 | expiration: MoodletExpirationTime 11 | 12 | /// Defines a book that can be purchased and read. 13 | type Book = 14 | { Title: string 15 | Author: string 16 | BookEffects: BookEffect list 17 | ReadProgress: int } 18 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/CalendarEvent.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module CalendarEventTypes = 5 | /// Represents a calendar event. 6 | [] 7 | type CalendarEventType = 8 | | Flight of Flight 9 | | Concert of Concert 10 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Car.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module CarTypes = 5 | [] 6 | type horsepower 7 | 8 | /// Defines a car that can be purchased and driven. 9 | type Car = { Power: int } 10 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Character.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module CharacterTypes = 5 | /// Defines the gender of the character. 6 | type Gender = 7 | | Male 8 | | Female 9 | | Other 10 | 11 | /// Unique identifier of a character. 12 | type CharacterId = CharacterId of Identity 13 | 14 | /// Defines a character, be it the one that the player is controlling or any 15 | /// other NPC of the world. 16 | type Character = 17 | { Id: CharacterId 18 | Name: string 19 | Birthday: Date 20 | Gender: Gender 21 | Attributes: CharacterAttributes 22 | Moodlets: CharacterMoodlets } 23 | 24 | /// Collection of skills by character. 25 | type CharacterSkills = Map> 26 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/City.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module CityTypes = 5 | /// ID for a country in the game world, which declared every possible country 6 | /// available in the game. 7 | type CountryId = 8 | | CzechRepublic 9 | | England 10 | | Spain 11 | | UnitedStates 12 | 13 | /// ID for a city in the game world, which declared every possible city 14 | /// available in the game. 15 | type CityId = 16 | | London 17 | | LosAngeles 18 | | Madrid 19 | | NewYork 20 | | Prague 21 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Common.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module CommonTypes = 5 | /// Defines the type that all entities with an ID should use. 6 | type Identity = System.Guid 7 | 8 | /// Defines the before and after of an action. 9 | type Diff<'a> = Diff of before: 'a * after: 'a 10 | 11 | /// Measure for the in-game currency. DD as in DuetsDollars. Imagination 12 | /// at its best. 13 | [] 14 | type dd 15 | 16 | /// Measure for counting number of times. 17 | [] 18 | type times 19 | 20 | /// Defines an amount in the in-game currency. 21 | type Amount = decimal
22 | 23 | /// Measure for the quality of something, as a percentage. 24 | [] 25 | type quality 26 | 27 | /// Measure for how many things of something. 28 | [] 29 | type quantity 30 | 31 | type Quality = int 32 | type MaxQuality = int 33 | 34 | /// Measure for counting milliliters of something. 35 | [] 36 | type milliliter 37 | 38 | /// Measure for counting grams of something. 39 | [] 40 | type gram 41 | 42 | /// Measure for counting kilometers. 43 | [] 44 | type km 45 | 46 | /// Measure for percentages. 47 | [] 48 | type percent 49 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Flight.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module FlightTypes = 5 | /// Defines a flight ticket that the character holds to travel between 6 | /// two cities in the game. 7 | type Flight = 8 | { Id: Identity 9 | Origin: CityId 10 | Destination: CityId 11 | Price: Amount 12 | Date: Date 13 | DayMoment: DayMoment 14 | AlreadyUsed: bool } 15 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Genre.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module GenreTypes = 5 | /// Defines a musical genre. This basic type is just an alias for the name of 6 | /// the genre, there's more specific types depending on the type of information 7 | /// that we want to query. 8 | type Genre = string 9 | 10 | /// Defines the relation between a genre and its popularity in a moment 11 | /// in time. 12 | type GenrePopularity = Genre * int 13 | 14 | /// Defines the percentage compatibility of two genres between 0 and 100. 15 | type GenreCompatibility = Genre * Genre * int 16 | 17 | /// Defines the potential market of a genre by: 18 | /// - Market point: modifier between 0.1 and 5 that, multiplied by the default 19 | /// the market size, gives the total amount of people willing to listen to 20 | /// the genre. 21 | /// - Fluctuation: modifier between 1 and 1.1 that indicates how much the 22 | /// market point will vary yearly. This fluctuation can randomly happen 23 | /// in a positive or negative way. 24 | type GenreMarket = 25 | { MarketPoint: float 26 | Fluctuation: float } 27 | 28 | /// Defines the genre market by genre. 29 | type GenreMarketByGenre = Map 30 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Instrument.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module InstrumentTypes = 5 | /// Unique identifier of an instrument. 6 | type InstrumentId = InstrumentId of Identity 7 | 8 | /// Defines what kind of instrument we're defining to be able to query different 9 | /// information about it. 10 | type InstrumentType = 11 | | Guitar 12 | | Drums 13 | | Bass 14 | | Vocals 15 | 16 | /// Represents the archetype instrument that a character can use. 17 | type Instrument = 18 | { Id: InstrumentId 19 | Type: InstrumentType } 20 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Merch.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module rec MerchTypes = 5 | /// Defines the prices for each kind of merch that the band can sell. 6 | type BandMerchPrices = Map> 7 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/MiniGames/Blackjack.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module BlackjackTypes = 5 | /// Defines the type of score a player or dealer has. This is needed 6 | /// because aces can be worth 1 or 11. 7 | [] 8 | type ScoreType = 9 | | Single of int 10 | | Multiple of int * int 11 | 12 | /// Defines the hand of a player or dealer. 13 | type Hand = { Cards: Card list; Score: ScoreType } 14 | 15 | /// Defines the current state of a game of blackjack. 16 | type BlackJackGame = 17 | { DealerHand: Hand 18 | PlayerHand: Hand 19 | Bet: Amount } 20 | 21 | /// Defines the current state of a game of blackjack. 22 | type BlackJackGameState = 23 | | Betting 24 | | Playing of BlackJackGame 25 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/MiniGames/MiniGame.Shared.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module MiniGameSharedTypes = 5 | /// Defines the suits of a standard deck of cards. 6 | type Suit = 7 | | Clubs 8 | | Diamonds 9 | | Hearts 10 | | Spades 11 | 12 | /// Defines the ranks of a standard deck of cards. 13 | type Rank = 14 | | Ace 15 | | Two 16 | | Three 17 | | Four 18 | | Five 19 | | Six 20 | | Seven 21 | | Eight 22 | | Nine 23 | | Ten 24 | | Jack 25 | | Queen 26 | | King 27 | 28 | /// Defines a card in a standard deck of cards. 29 | type Card = { Suit: Suit; Rank: Rank } 30 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/MiniGames/MiniGame.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module MiniGameTypes = 5 | /// Defines the types of mini-games that the game supports. 6 | [] 7 | type MiniGameId = Blackjack 8 | 9 | /// Defines the types of mini-games that the game supports, with their 10 | /// associated state. 11 | type MiniGameState = Blackjack of BlackJackGameState 12 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Moodlet.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module MoodletTypes = 5 | /// Defines the time it takes for a moodlet to expire. 6 | [] 7 | type MoodletExpirationTime = 8 | | Never 9 | | AfterDayMoments of int 10 | | AfterDays of int 11 | 12 | /// Defines all types of moodlet that can be applied to a character. 13 | [] 14 | type MoodletType = 15 | | JetLagged 16 | | NotInspired 17 | | TiredOfTouring 18 | 19 | /// Defines a moodlet that can be applied to a character. 20 | type Moodlet = 21 | { MoodletType: MoodletType 22 | StartedOn: Date 23 | Expiration: MoodletExpirationTime } 24 | 25 | /// Defines a list of moodlets that have been applied to a character. 26 | type CharacterMoodlets = Set 27 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Notification.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module NotificationTypes = 5 | /// Represents a notification related to rentals. 6 | [] 7 | type RentalNotificationType = 8 | | RentalDueInOneWeek of Rental 9 | | RentalDueTomorrow of Rental 10 | 11 | /// Represents the type of delivery that is being notified. 12 | [] 13 | type DeliveryType = Merchandise 14 | 15 | /// Represents a notification that needs to be raised to the player. 16 | [] 17 | type Notification = 18 | | CalendarEvent of CalendarEventType 19 | | DeliveryArrived of CityId * PlaceId * DeliveryType 20 | | RentalNotification of RentalNotificationType 21 | 22 | /// Defines all notifications that have to be raised at a certain date and 23 | /// day moment. Dates should have their time erased so that they're easily 24 | /// comparable. 25 | type Notifications = Map> 26 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/ConcertSpace.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module ConcertSpaceTypes = 5 | /// Represents a place where the user can have concerts. 6 | type ConcertSpace = { Capacity: int } 7 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/Hotel.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module HotelTypes = 5 | /// Defines a hotel with the price of a room per night. 6 | type Hotel = { PricePerNight: Amount } 7 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/RadioStudio.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module RadioStudioTypes = 5 | /// Defines a radio studio in which the band can go get interviewed and 6 | /// characters can go to work as reporters. 7 | type RadioStudio = 8 | { 9 | /// Genre of music that the radio station plays. 10 | MusicGenre: Genre 11 | } 12 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/RehearsalSpace.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module RehearsalSpaceTypes = 5 | /// Defines a rehearsal space in which the band can go to practice their 6 | /// songs and compose new ones. 7 | type RehearsalSpace = { Price: Amount } 8 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/Shop.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module ShopTypes = 5 | /// Measure for the price multiplier of shops. 6 | [] 7 | type multiplier 8 | 9 | /// Defines the type of cuisine a restaurant serves. 10 | type RestaurantCuisine = 11 | | American 12 | | Czech 13 | | Italian 14 | | French 15 | | Japanese 16 | | Mexican 17 | | Turkish 18 | | Vietnamese 19 | | Spanish 20 | 21 | /// Defines the type of cars that a dealer sells. 22 | type CarPriceRange = 23 | | Budget 24 | | MidRange 25 | | Premium 26 | 27 | /// Represents a specific type of shop where the character can buy cars, which 28 | /// has a dealer that manages the purchase. 29 | type CarDealer = 30 | { Dealer: Character 31 | PriceRange: CarPriceRange } 32 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Places/Studio.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module StudioTypes = 5 | /// Represents the owner of a studio and the character that eventually 6 | /// produces the album. Their skills determine the final level of the album. 7 | type Producer = Character 8 | 9 | /// Unique identifier of a studio. 10 | type StudioId = StudioId of Identity 11 | 12 | /// Represents a recording studio where bands can record and produce their 13 | /// albums before releasing them to the public. 14 | type Studio = 15 | { Producer: Producer 16 | PricePerSong: Amount } 17 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Relationship.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module RelationshipTypes = 5 | /// Defines the type of relationship between the character and an NPC. 6 | type RelationshipType = 7 | | Friend 8 | | Bandmate 9 | 10 | [] 11 | type relationshipLevel 12 | 13 | /// Defines a relationship between the main character and an NPC. 14 | type Relationship = 15 | { Character: CharacterId 16 | MeetingCity: CityId 17 | LastIterationDate: Date 18 | RelationshipType: RelationshipType 19 | Level: int } 20 | 21 | type RelationshipsByCharacterId = Map 22 | type RelationshipsByMeetingCity = Map> 23 | 24 | /// Defines all relationships for a character. A non-existent key means 25 | /// that the character has no relationship with that character ID. 26 | type Relationships = 27 | { ByCharacterId: RelationshipsByCharacterId 28 | ByMeetingCity: RelationshipsByMeetingCity } 29 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Rental.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module RentalTypes = 5 | /// Defines when the rental has to be paid. Seasonal is for rentals like a 6 | /// flat, where a fee has to be paid repeatedly each season. OneTime is for 7 | /// rentals like a hotel room, where there's just one payment. 8 | type RentalType = 9 | | Seasonal of nextPaymentDate: Date 10 | | OneTime of from: Date * until: Date 11 | 12 | /// Defines a rental that the character holds over some place. 13 | type Rental = 14 | { Amount: Amount 15 | Coords: PlaceCoordinates 16 | RentalType: RentalType } 17 | 18 | /// Associates the rentals that the character currently holds, grouped by 19 | /// their location in the world. 20 | type CharacterRentals = Map 21 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Situations.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | module SituationTypes = 4 | /// Situations that happen while on an airport/airplane. 5 | type AirportSituation = Flying of Flight 6 | 7 | /// Situations that happen while on a concert. 8 | type ConcertSituation = 9 | | Preparing of ConcertPreparationChecklist 10 | | InConcert of OngoingConcert 11 | 12 | /// Situations that happen while the character is travelling. 13 | type TravelSituation = 14 | | TravellingByCar of currentCarPosition: RoomCoordinates * car: Item 15 | | TravellingByMetro 16 | 17 | /// Defines all situations in which the character can be in. 18 | type Situation = 19 | /// Player is exploring the world in no specific situation. 20 | | FreeRoam 21 | /// Player is inside of an airport about to flight or flying. 22 | | Airport of AirportSituation 23 | /// Player is performing a concert. 24 | | Concert of ConcertSituation 25 | /// Playing a mini-game. 26 | | PlayingMiniGame of MiniGameState 27 | /// Player is in a conversation with an NPC. 28 | | Socializing of SocializingState 29 | /// Player is travelling in a vehicle. 30 | | Travelling of TravelSituation 31 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Skill.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module SkillTypes = 5 | /// Identifier of a skill which represents its internal type. 6 | [] 7 | type SkillId = 8 | (* Music. *) 9 | | Composition 10 | | Genre of Genre 11 | | Instrument of InstrumentType 12 | (* Production. *) 13 | | MusicProduction 14 | (* Character. *) 15 | | Fitness 16 | | Speech 17 | | Cooking 18 | (* Job. *) 19 | | Barista 20 | | Bartending 21 | | Presenting 22 | 23 | /// Defines all possible categories to which skills can be related to. 24 | [] 25 | type SkillCategory = 26 | | Character 27 | | Job 28 | | Music 29 | | Production 30 | 31 | /// Represents a skill that the character can have. This only includes the base 32 | /// fields of the skill, more specific types are available depending on what 33 | /// information we need. 34 | type Skill = 35 | { Id: SkillId; Category: SkillCategory } 36 | 37 | /// Defines the relation between a skill and its level. 38 | type SkillWithLevel = Skill * int 39 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Social.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module SocialTypes = 5 | /// Defines all the possible social actions that can be performed during 6 | /// a social interaction. 7 | [] 8 | type SocialActionKind = 9 | | Greet 10 | | Chat 11 | | AskAboutDay 12 | | TellStory 13 | | Compliment 14 | | TellJoke 15 | | Gossip 16 | | Argue 17 | | Hug 18 | | Flirt 19 | | DiscussInterests 20 | | AskAboutCareer 21 | | ShareMemory 22 | 23 | /// Defines a state for a current social interaction. 24 | type SocializingState = 25 | { Npc: Character 26 | Relationship: Relationship option 27 | Actions: SocialActionKind list } 28 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/Weather.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | 4 | [] 5 | module WeatherTypes = 6 | /// Represents difference weather conditions that can happen in the game. 7 | [] 8 | type WeatherCondition = 9 | | Sunny 10 | | Cloudy 11 | | Rainy 12 | | Stormy 13 | | Snowy 14 | 15 | /// Maps a city ID to its current weather condition. 16 | type WeatherConditionPerCity = Map 17 | 18 | /// Defines the transition probabilities between different weather conditions 19 | /// in a specific city, depending on the season. 20 | type CityWeatherTransitionMatrix = 21 | { AutumnWinter: Map 22 | SpringSummer: Map } 23 | -------------------------------------------------------------------------------- /src/Duets.Entities/Types/World.Coordinates.Types.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Entities 2 | 3 | [] 4 | module WorldCoordinatesTypes = 5 | /// Unique ID of a node. 6 | type NodeId = string 7 | 8 | /// ID for a room in a place in the game world. 9 | type RoomId = NodeId 10 | 11 | /// ID for a place in the game world. 12 | type PlaceId = string 13 | 14 | /// ID for a street inside a zone. 15 | type StreetId = NodeId 16 | 17 | /// ID for a zone in a city. 18 | type ZoneId = string 19 | 20 | /// Defines a position in the world, including up to the room inside of 21 | /// the place. 22 | type RoomCoordinates = CityId * PlaceId * RoomId 23 | 24 | /// Defines a position inside a specific zone that can resolve a place. These 25 | /// coordinates are used to reference places inside a city, but they can't 26 | /// resolve a place globally due to the lack of a city ID. 27 | type ZonedPlaceCoordinates = ZoneId * StreetId * PlaceId 28 | 29 | /// Simplified coordinates that only contain the city and the place. 30 | type PlaceCoordinates = CityId * PlaceId 31 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Albums/FanIncrease.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Albums.FanIncrease 2 | 3 | open Duets.Entities 4 | open Duets.Common 5 | open Duets.Simulation 6 | 7 | /// Increases the number of fans for the day based on the previous' day non-fan 8 | /// streams. 9 | let calculateFanIncrease nonFanStreams = 10 | float nonFanStreams * Config.MusicSimulation.fanIncreasePercentage 11 | |> Math.ceilToNearest 12 | |> (*) 1 13 | 14 | /// Applies the given fan increase to all cities in the fan base. 15 | let applyFanIncrease band fanIncrease = 16 | // Ensure that we always have at least one city in the fan base, otherwise 17 | // it will be impossible to ever increase the number of fans. 18 | let fanBase = 19 | if band.Fans |> Map.isEmpty then 20 | [ band.OriginCity, 0 ] |> Map.ofList 21 | else 22 | band.Fans 23 | 24 | fanBase |> Map.map (fun _ fans -> fans + fanIncrease) 25 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Albums/Hype.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Albums.Hype 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | 6 | /// Re-calculates the hype for the given album. Initially only decreases by 0.1 7 | /// and keeps 0.1 as the lowest value possible. In the future this might take 8 | /// into account the band's fame as well as other events such as ads, new 9 | /// releases, etc. 10 | let reduceDailyHype releasedAlbum = 11 | releasedAlbum.Hype - 0.1 |> Math.clampFloat 0.1 1.0 12 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Albums/Revenue.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Albums.Revenue 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Calculates the daily revenue of the given album based on the streams that 7 | /// were generated the previous day. 8 | let albumRevenue (previousDayStreams: int) = 9 | float previousDayStreams * Config.Revenue.revenuePerStream 10 | |> decimal 11 | |> Amount.fromDecimal 12 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Bands/FundDistribution.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Simulation.Bands.FundDistribution 3 | 4 | open Duets.Entities 5 | open Duets.Simulation 6 | open Duets.Simulation.Bank.Operations 7 | 8 | let distribute state (amount: Amount) = 9 | let characterAccount = Queries.Bank.playableCharacterAccount state 10 | let band = Queries.Bands.currentBand state 11 | let bandAccount = Band band.Id 12 | let bandAccountBalance = Queries.Bank.balanceOf state bandAccount 13 | 14 | let characterAmount = amount / (band.Members |> List.length |> decimal) 15 | 16 | let incomeEffect = income state characterAccount characterAmount 17 | 18 | let updatedBandBalance = bandAccountBalance - amount 19 | 20 | withBalanceChecking 21 | state 22 | bandAccount 23 | amount 24 | [ incomeEffect 25 | BalanceUpdated( 26 | bandAccount, 27 | Diff(bandAccountBalance, updatedBandBalance) 28 | ) ] 29 | |> Result.map (fun effects -> effects, characterAmount) 30 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Bands/SwitchGenre.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Bands.SwitchGenre 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Switches the currently selected band genre if the updated genre is different 7 | /// from the current one, otherwise returns None. 8 | let switchGenre state updatedGenre = 9 | let currentBand = Queries.Bands.currentBand state 10 | 11 | if currentBand.Genre <> updatedGenre then 12 | BandSwitchedGenre(currentBand, Diff(currentBand.Genre, updatedGenre)) 13 | |> Some 14 | else 15 | None 16 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Careers/Employment.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Simulation.Careers.Employment 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Gives the character the specified job. If there were any previous jobs, it'll 7 | /// be overriden by this one. 8 | let acceptJob state job = 9 | let currentCharacter = Queries.Characters.playableCharacter state 10 | let currentJob = Queries.Career.current state 11 | 12 | [ yield! 13 | match currentJob with 14 | | Some currentJob -> 15 | [ CareerLeave(currentCharacter.Id, currentJob) 16 | yield! dropRequiredItems currentJob ] 17 | | None -> [] 18 | 19 | yield! addRequiredItemsToInventory job 20 | CareerAccept(currentCharacter.Id, job) ] 21 | 22 | let private addRequiredItemsToInventory job = 23 | gatherJobRequiredItems job |> List.map ItemAddedToCharacterInventory 24 | 25 | let private dropRequiredItems job = 26 | gatherJobRequiredItems job |> List.map ItemRemovedFromCharacterInventory 27 | 28 | let private gatherJobRequiredItems job = 29 | let room = job.Location |||> Queries.World.roomById 30 | 31 | room.RequiredItemsForEntrance 32 | |> Option.map _.Items 33 | |> Option.defaultValue [] 34 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Careers/RequirementCharacterUpgrade.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Careers.RequirementCharacterUpgrade 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | open Duets.Simulation.Skills.Improve.Common 6 | 7 | /// This funkily named function takes care of applying a 25% chance of 8 | /// the requirements for the next stage of the job improving, meaning skills 9 | /// and fame where applicable. 10 | let rec applyRequirementUpgradeChange job state = 11 | job.CurrentStage.Requirements 12 | |> List.collect (function 13 | | CareerStageRequirement.Skill(skill, _) -> 14 | improveCharacterSkillAfterShift skill state 15 | | CareerStageRequirement.Fame _ -> improveCharacterFameAfterShift state) 16 | 17 | and private improveCharacterFameAfterShift state = 18 | let character = Queries.Characters.playableCharacter state 19 | let chanceAwarded = RandomGen.chance 25 20 | 21 | Character.Attribute.conditionalAdd 22 | character 23 | CharacterAttribute.Fame 24 | chanceAwarded 25 | 1 26 | 27 | and private improveCharacterSkillAfterShift skill state = 28 | let character = Queries.Characters.playableCharacter state 29 | 30 | applySkillModificationChance 31 | state 32 | {| CharacterId = character.Id 33 | Skills = [ skill ] 34 | Chance = 25 35 | ImprovementAmount = 1 |} 36 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Character/AttributeChange.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Character.AttributeChange 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Applies the attribute change that happens after every time advance. 7 | let applyAfterTimeChange state = 8 | let playableCharacter = Queries.Characters.playableCharacter state 9 | 10 | Attribute.add 11 | playableCharacter 12 | CharacterAttribute.Hunger 13 | Config.LifeSimulation.hungerReductionRate 14 | 15 | /// Applies the attribute change that happens after waiting. 16 | let applyAfterWait dayMoments state = 17 | let playableCharacter = Queries.Characters.playableCharacter state 18 | let multiplier = dayMoments / 1 19 | 20 | Attribute.add 21 | playableCharacter 22 | CharacterAttribute.Energy 23 | (multiplier * Config.LifeSimulation.energyReductionRate) 24 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Character/Moodlets.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Character.Moodlets 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Creates a moodlet of the given type and expiration that starts from the 7 | /// current date. 8 | let createFromNow state moodletType expiration = 9 | let currentDate = Queries.Calendar.today state 10 | Moodlet.create moodletType currentDate expiration 11 | 12 | /// Applies the given moodlet to the playable character. If the character 13 | /// has already a moodlet of the same type it will be replaced to ensure that 14 | /// the `StartedOn` and `Expiration` fields are updated. 15 | let apply state moodlet = 16 | let character = Queries.Characters.playableCharacter state 17 | let currentMoodlets = character |> Queries.Characters.moodlets 18 | 19 | let updatedMoodlets = 20 | currentMoodlets 21 | |> Set.filter (fun m -> m.MoodletType <> moodlet.MoodletType) 22 | |> Set.add moodlet 23 | 24 | (character.Id, Diff(currentMoodlets, updatedMoodlets)) 25 | |> CharacterMoodletsChanged 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Character/Npc.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Character.Npc 2 | 3 | open Duets.Entities 4 | open Duets.Data 5 | open Duets.Simulation 6 | 7 | let private randomAdultBirthday state = 8 | let age = RandomGen.genBetween 18 65 9 | let dayVariation = RandomGen.genBetween -30 0 |> (*) 1 10 | let currentDate = Queries.Calendar.today state 11 | 12 | currentDate 13 | |> Calendar.Ops.addYears -(age * 1) 14 | |> Calendar.Ops.addDays dayVariation 15 | 16 | /// Generates a random NPC with a name and a gender from the database and a 17 | /// random birthday between 18 to 65 years ago. 18 | let generateRandom state = 19 | let name, gender = Npcs.random () 20 | let birthday = randomAdultBirthday state 21 | 22 | Character.from name gender birthday 23 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Concerts/Preparation/StartConcertPreparation.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Concerts.Preparation.Start 2 | 3 | open Duets.Entities 4 | open Duets.Entities.SituationTypes 5 | open Duets.Simulation 6 | 7 | /// Checks whether the given coords are from a concert space in which the band 8 | /// currently has a concert scheduled, and if so, sets the situation to 9 | /// preparing for the concert. 10 | let startIfNeeded coords state = 11 | let cityId, placeId, _ = coords 12 | let place = Queries.World.placeInCityById cityId placeId 13 | 14 | match place.PlaceType with 15 | | ConcertSpace _ -> 16 | let band = Queries.Bands.currentBand state 17 | 18 | let scheduledConcert = 19 | Queries.Concerts.scheduleForTodayInPlace state band.Id place.Id 20 | 21 | let currentSituation = Queries.Situations.current state 22 | 23 | match scheduledConcert, currentSituation with 24 | | Some concert, Situation.Concert _ -> 25 | [] (* Already inside a concert situation, do nothing. *) 26 | | Some concert, _ -> 27 | (* Transition to preparing the concert. *) 28 | [ Situations.preparingConcert ] 29 | | _ -> [] (* No concert in the current place, do nothing. *) 30 | | _ -> [] (* Not a concert space, do nothing. *) 31 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Cooking/Cooking.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Cooking 2 | 3 | open Duets.Simulation 4 | open Duets.Entities 5 | open Duets.Simulation.Skills.Improve.Common 6 | 7 | /// Attempts to cook an item, checking if the character has enough money, and 8 | /// granting a 25% chance of increasing their cooking skills. 9 | let cook state itemWithPrice = 10 | let orderResult = Shop.order state itemWithPrice 11 | 12 | match orderResult with 13 | | Error e -> Error e 14 | | Ok shoppingEffects -> 15 | let character = Queries.Characters.playableCharacter state 16 | 17 | let skillImprovementEffects = 18 | applySkillModificationChance 19 | state 20 | {| CharacterId = character.Id 21 | Skills = [ SkillId.Cooking ] 22 | Chance = 25 23 | ImprovementAmount = 1 |} 24 | 25 | Ok(shoppingEffects @ skillImprovementEffects) 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/EffectModifiers/EffectModifiers.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Simulation.EffectModifiers.EffectModifiers 2 | 3 | open Duets.Entities 4 | 5 | /// Applies a list of possible modifiers to an effect based on the current state 6 | /// of the game. For example, if the character currently has the `NotInspired` 7 | /// moodlet then all song composition and improvement effects will have a lower 8 | /// chance of success. The state passed into all of the modifiers is frozen to 9 | /// right before the effect is applied. 10 | let modify state effect = 11 | match effect with 12 | | GameCreated _ -> 13 | effect (* We cannot apply anything if we've just created the game. *) 14 | | _ -> modify' state effect 15 | 16 | let private modify' state effect = 17 | [ Moodlets.modify ] 18 | |> List.fold (fun effect modifier -> modifier state effect) effect 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Band/Band.Events.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Simulation.Events.Band.Band 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | open Duets.Simulation.Events 6 | 7 | /// Runs all the events associated with bands. For example, when the fan base 8 | /// changes, the engine might generate new reviews for their albums. 9 | let internal run effect = 10 | match effect with 11 | | BandFansChanged(band, Diff(prevFans, currentFans)) -> 12 | let previousFans = Queries.Bands.totalFans prevFans 13 | let currentFans = Queries.Bands.totalFans currentFans 14 | 15 | let minimumFanBaseForReviews = 16 | Config.MusicSimulation.minimumFanBaseForReviews * 1 17 | 18 | let hasEnoughFansForReviews = 19 | previousFans < minimumFanBaseForReviews 20 | && currentFans >= minimumFanBaseForReviews 21 | 22 | if hasEnoughFansForReviews then 23 | [ Reviews.generateReviewsAfterFanIncrease band.Id ] 24 | |> ContinueChain 25 | |> Some 26 | else 27 | None 28 | | MemberHired(_, character, _, _) -> 29 | [ Relationships.addWithMember character ] |> ContinueChain |> Some 30 | | MemberFired(_, bandMember, _) -> 31 | [ Relationships.removeWithMember bandMember.CharacterId ] 32 | |> ContinueChain 33 | |> Some 34 | | _ -> None 35 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Band/Relationships.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Band.Relationships 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Adds a relationship between the player and the given NPC as band mates after 7 | /// they've been hired to the band. 8 | let addWithMember (npc: Character) state = 9 | let currentBand = Queries.Bands.currentBand state 10 | 11 | Social.Relationship.createWith npc currentBand.OriginCity state 12 | |> List.singleton 13 | 14 | /// Removes a relationship between the player and the given NPC after they've 15 | /// been fired from the band. 16 | let removeWithMember characterId state = 17 | let npc = Queries.Characters.find state characterId 18 | 19 | let currentRelationship = 20 | Queries.Relationship.withCharacter characterId state 21 | 22 | match currentRelationship with 23 | | Some relationship -> 24 | RelationshipChanged(npc, relationship.MeetingCity, None) 25 | |> List.singleton 26 | | None -> 27 | [] (* Shouldn't happen, but nothing to do since it's already removed. *) 28 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Band/Reviews.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Band.Reviews 2 | 3 | open Duets.Simulation.Albums 4 | 5 | let generateReviewsAfterFanIncrease bandId state = 6 | ReviewGeneration.generateReviewsForBand state bandId 7 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Career.Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Career 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Runs all the events associated with effects of a career. For example, 7 | /// finishing a career shift will improve the character's skills and also 8 | /// give a chance of getting promoted if the character has the required skills 9 | /// for the next level. 10 | let internal run effect = 11 | match effect with 12 | | CareerShiftPerformed(job, _, _) -> 13 | [ Careers.RequirementCharacterUpgrade.applyRequirementUpgradeChange job 14 | Careers.Promotion.promoteIfNeeded job ] 15 | |> ContinueChain 16 | |> Some 17 | | _ -> None 18 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Character/Drunkenness.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Character.Drunkenness 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Sobers up the character after each passing time unit. 7 | let soberUpAfterTime state = 8 | let character = Queries.Characters.playableCharacter state 9 | 10 | Character.Attribute.conditionalAdd 11 | character 12 | CharacterAttribute.Drunkenness 13 | (Character.Attribute.moreThanZero 14 | character 15 | CharacterAttribute.Drunkenness) 16 | Config.LifeSimulation.drunkennessReduceRate 17 | 18 | /// Reduces the health of the character when they're too drunk. 19 | let reduceHealth state = 20 | let character = Queries.Characters.playableCharacter state 21 | 22 | Character.Attribute.conditionalAdd 23 | character 24 | CharacterAttribute.Health 25 | (Character.Attribute.moreThan 26 | character 27 | CharacterAttribute.Drunkenness 28 | 85) 29 | Config.LifeSimulation.drunkHealthReduceRate 30 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Character/Fame.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Character.Fame 2 | 3 | open Duets.Entities 4 | open Duets.Common 5 | open Duets.Simulation 6 | 7 | /// Updates the playable character's fame to be at least half of the band's fame. 8 | let followBandsFame bandId state = 9 | let currentCharacterFame = 10 | Queries.Characters.playableCharacterAttribute 11 | state 12 | CharacterAttribute.Fame 13 | 14 | let estimatedBandFame = Queries.Bands.estimatedFameLevel state bandId 15 | 16 | let minimumFame = float estimatedBandFame * 0.5 |> Math.ceilToNearest 17 | 18 | if currentCharacterFame < minimumFame then 19 | Character.Attribute.setToPlayable 20 | CharacterAttribute.Fame 21 | minimumFame 22 | state 23 | else 24 | [] 25 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Character/Hunger.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Character.Hunger 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Reduces the health of the character the hunger is too low. 7 | let reduceHealth state = 8 | let character = Queries.Characters.playableCharacter state 9 | 10 | Character.Attribute.conditionalAdd 11 | character 12 | CharacterAttribute.Health 13 | (Character.Attribute.lessThan character CharacterAttribute.Hunger 5) 14 | Config.LifeSimulation.hungerHealthReduceRate 15 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Concert.Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Concert 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Runs all the events associated with effects of a concert. For example, 7 | /// finishing a concert will start an event to calculate merch sales for that 8 | /// specific concert. 9 | let internal run effect = 10 | match effect with 11 | | ConcertFinished(band, pastConcert, income) -> 12 | [ Merchandise.Sell.afterConcert band pastConcert ] 13 | |> ContinueChain 14 | |> Some 15 | | _ -> None 16 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Events 2 | 3 | open Duets.Simulation.Events.Band 4 | open Duets.Simulation.Events.Character 5 | open Duets.Simulation.Events.Moodlets 6 | 7 | /// Retrieves all associated effects with the given one. 8 | let associatedEffects effect = 9 | [ Band.run effect 10 | Career.run effect 11 | Character.run effect 12 | Concert.run effect 13 | Moodlets.run effect 14 | NonInteractiveGame.run effect 15 | Skill.run effect 16 | Time.run effect 17 | World.run effect ] 18 | |> List.choose id 19 | 20 | /// Retrieves all the effects that have to happen at the end of an effect chain. 21 | let endOfChainEffects = [ Place.ClosingTime.checkCurrentPlace ] 22 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Moodlets/Cleanup.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Moodlets.Cleanup 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Removes any moodlets from the playable character that might have expired. 7 | let cleanup state = 8 | let currentDate = Queries.Calendar.today state 9 | let character = Queries.Characters.playableCharacter state 10 | let currentMoodlets = Queries.Characters.moodlets character 11 | 12 | let updatedMoodlets = 13 | currentMoodlets 14 | |> Set.filter (fun moodlet -> 15 | match moodlet.Expiration with 16 | | MoodletExpirationTime.Never -> true 17 | | MoodletExpirationTime.AfterDays daysToExpire -> 18 | let daysSinceStart = Moodlet.daysSinceStart moodlet currentDate 19 | 20 | daysSinceStart <= daysToExpire 21 | | MoodletExpirationTime.AfterDayMoments dayMomentsToExpire -> 22 | let dayMomentsSinceStart = 23 | Moodlet.dayMomentsSinceStart moodlet currentDate 24 | 25 | dayMomentsSinceStart <= dayMomentsToExpire) 26 | 27 | (character.Id, Diff(currentMoodlets, updatedMoodlets)) 28 | |> CharacterMoodletsChanged 29 | |> List.singleton 30 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Moodlets/JetLagged.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Moodlets.JetLagged 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Checks if the character has travelled to a new city with a time difference of 7 | /// more than 4 hours from the previous city, and in that case applies the 8 | /// JetLagged moodlet. 9 | let applyIfNeeded prevCityId currCityId state = 10 | let prevCity = Queries.World.cityById prevCityId 11 | let currCity = Queries.World.cityById currCityId 12 | 13 | (* 14 | Apply jet lag when the cities' timezones differ by more than 4 hours. 15 | *) 16 | let (Utc prevCityOffset) = prevCity.Timezone 17 | let (Utc currCityOffset) = currCity.Timezone 18 | 19 | let shouldApplyMoodlet = 20 | abs (prevCityOffset - currCityOffset) > 4 21 | 22 | if shouldApplyMoodlet then 23 | let moodlet = 24 | Character.Moodlets.createFromNow 25 | state 26 | MoodletType.JetLagged 27 | (MoodletExpirationTime.AfterDays 3) 28 | 29 | [ Character.Moodlets.apply state moodlet ] 30 | else 31 | [] 32 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Moodlets/Moodlets.Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Moodlets.Moodlets 2 | 3 | open Duets.Entities 4 | open Duets.Simulation.Events 5 | 6 | /// Runs all the events associated with moodlets. For example, when a band 7 | /// composes more than two songs in a week, a "NotInspired" moodlet is given 8 | /// to the character for another week to slow them down a bit. 9 | let internal run effect = 10 | match effect with 11 | | ConcertFinished(band, _, _) -> 12 | ContinueChain [ TiredOfTouring.applyIfNeeded band.Id ] |> Some 13 | | SongFinished(band, _, _) -> 14 | ContinueChain [ NotInspired.applyIfNeeded band.Id ] |> Some 15 | | TimeAdvanced _ -> ContinueChain [ Cleanup.cleanup ] |> Some 16 | | WorldMoveToPlace(Diff((prevCityId, _, _), (currCityId, _, _))) -> 17 | ContinueChain [ JetLagged.applyIfNeeded prevCityId currCityId ] |> Some 18 | | _ -> None 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/NonInteractiveGame.Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.NonInteractiveGame 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Runs all the events associated with certain effects that have happened that 7 | /// require the engine to improve the skills of the band or the character. For 8 | /// example, starting a song applies a 50% chance of improving the skills of the 9 | /// character and the rest of the band. 10 | let internal run effect = 11 | match effect with 12 | | GamePlayed(PlayResult.Darts result) 13 | | GamePlayed(PlayResult.Pool result) -> 14 | match result with 15 | | SimpleResult.Win -> 16 | [ Character.Attribute.addToPlayable 17 | CharacterAttribute.Mood 18 | Config.LifeSimulation.Mood.winningNonInteractiveGameIncrease ] 19 | | SimpleResult.Lose -> 20 | [ Character.Attribute.addToPlayable 21 | CharacterAttribute.Mood 22 | Config.LifeSimulation.Mood.losingNonInteractiveGameIncrease ] 23 | |> ContinueChain 24 | |> Some 25 | | _ -> None 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Skill.Events.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Events.Skill 2 | 3 | open Duets.Entities 4 | open Duets.Simulation.Skills.Improve 5 | 6 | /// Runs all the events associated with certain effects that have happened that 7 | /// require the engine to improve the skills of the band or the character. For 8 | /// example, starting a song applies a 50% chance of improving the skills of the 9 | /// character and the rest of the band. 10 | let internal run effect = 11 | match effect with 12 | | SongStarted(band, _) -> 13 | [ Composition.improveBandSkillsChance band ] |> ContinueChain |> Some 14 | | SongImproved(band, _) -> 15 | [ Composition.improveBandSkillsChance band ] |> ContinueChain |> Some 16 | | SongPracticed(band, _) -> 17 | [ Composition.improveBandSkillsChance band ] |> ContinueChain |> Some 18 | | _ -> None 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Events/Types.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Simulation.Events.Types 3 | 4 | open Duets.Entities 5 | 6 | /// A function that when given the current game state, returns a list of effects 7 | /// that happen after the action. 8 | type EffectFn = State -> Effect list 9 | 10 | /// Defines what kind of effects are wrapped in the associated effects. BreakChain 11 | /// should apply only the given list and discard the rest, continue chain executes 12 | /// the given effects and then the rest. 13 | type AssociatedEffectType = 14 | | BreakChain of EffectFn list 15 | | ContinueChain of EffectFn list 16 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Flights/Booking.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Flights.Booking 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | open Duets.Simulation.Bank.Operations 6 | 7 | let private generatePayment state bill = 8 | let characterAccount = Queries.Bank.playableCharacterAccount state 9 | 10 | expense state characterAccount bill 11 | 12 | /// Books a flight for the current character if they have enough money on their 13 | /// account. 14 | let bookFlight state (flight: Flight) = 15 | generatePayment state flight.Price 16 | |> Result.map (fun effects -> [ FlightBooked flight ] @ effects) 17 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Flights/TicketGeneration.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Simulation.Flights.TicketGeneration 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Generates the tickets available for a given date between two cities. 7 | let ticketsAvailable origin destination date = 8 | (* Day moments in which they can fly. *) 9 | [ Morning; Midday; Afternoon ] 10 | |> List.map (createTicket origin destination date) 11 | 12 | let private createTicket origin destination date dayMoment = 13 | let ticketPrice = 14 | Queries.World.distanceBetween origin destination 15 | |> decimal 16 | |> (*) Config.Travel.pricePerKm 17 | |> Amount.fromDecimal 18 | 19 | Flight.create origin destination ticketPrice date dayMoment 20 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Gym/PayEntrance.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Gym.Entrance 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | open Duets.Simulation.Bank.Operations 6 | 7 | /// Pays the one-time entrance fee to the gym, which deducts the amount from the 8 | /// player's account and adds a chip to access the gym to their inventory. 9 | let pay state amount = 10 | let cityId, placeId, _ = Queries.World.currentCoordinates state 11 | 12 | let characterAccount = Queries.Bank.playableCharacterAccount state 13 | 14 | expense state characterAccount amount 15 | |> Result.map (fun effects -> 16 | let entranceChip = Item.Key.createGymChipFor cityId placeId 17 | 18 | effects @ [ ItemAddedToCharacterInventory entranceChip ]) 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Interactions/Items/Actions/Exercise.Action.fs: -------------------------------------------------------------------------------- 1 | module rec Duets.Simulation.Interactions.Actions.Exercise 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Applies the effects of exercising with an item. 7 | let exercise item character state = 8 | [ yield Exercised item 9 | yield! 10 | Character.Attribute.add 11 | character 12 | CharacterAttribute.Energy 13 | Config.LifeSimulation.Energy.exerciseIncrease 14 | yield! 15 | Character.Attribute.add 16 | character 17 | CharacterAttribute.Health 18 | Config.LifeSimulation.Health.exerciseIncrease 19 | yield! 20 | Skills.Improve.Common.applySkillModificationChance 21 | state 22 | {| Chance = 30 23 | CharacterId = character.Id 24 | ImprovementAmount = 1 25 | Skills = [ SkillId.Fitness ] |} ] 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Market/GenreMarket.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Simulation.Market.GenreMarket 3 | 4 | open Duets.Common 5 | open Duets.Entities 6 | 7 | let private createGenreMarket () = 8 | { MarketPoint = Random.floatBetween 0.1 5.0 9 | Fluctuation = Random.floatBetween 0.1 1.1 } 10 | 11 | let private updateGenreMarket market = 12 | { market with 13 | MarketPoint = 14 | Random.boolean () 15 | |> fun increasing -> 16 | if increasing then 17 | market.MarketPoint + market.Fluctuation 18 | else 19 | market.MarketPoint - market.Fluctuation 20 | |> Math.clampFloat 2.0 5.0 } 21 | 22 | /// Creates the genre market by calculating its initial market point and the 23 | /// fluctuation of each of the genres available. 24 | let create (genres: Genre list) = 25 | List.map (fun genre -> (genre, createGenreMarket ())) genres |> Map.ofList 26 | 27 | /// Updates all the genres of the market by updating its market point to the 28 | /// previously calculated fluctuation. All the market points will be between 29 | /// 2 and 5. 30 | let update (genreMarket: GenreMarketByGenre) = 31 | genreMarket |> Map.map (fun _ -> updateGenreMarket) |> GenreMarketsUpdated 32 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Merchandise/PickUp.Merchandise.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Merchandise.PickUp 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Adds to the inventory any deliverable item from the given list and removes 7 | /// them from the world. All the items that are not deliverable will be ignored 8 | /// and kept in the world. 9 | let pickUpOrder state items = 10 | let coords = Queries.World.currentCoordinates state 11 | let currentBand = Queries.Bands.currentBand state 12 | 13 | items 14 | |> List.collect (fun deliveryItem -> 15 | let order = 16 | deliveryItem 17 | |> Item.Property.tryPick (function 18 | | Deliverable(_, item) -> Some item 19 | | _ -> None) 20 | 21 | match order with 22 | | Some(DeliverableItem.Description(merchItem, quantity)) -> 23 | [ ItemAddedToBandInventory(currentBand, merchItem, quantity) 24 | ItemRemovedFromWorld(coords, deliveryItem) ] 25 | | _ -> []) 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Merchandise/SetPrice.Merchandise.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Merchandise.SetPrice 2 | 3 | open Duets.Entities 4 | 5 | type MerchPriceError = InvalidPrice 6 | 7 | /// Sets the price of the given item for the current band, if the price is valid. 8 | let setPrice band item price = 9 | if price <= 1m
|| price > 1000m
then 10 | Error InvalidPrice 11 | else 12 | MerchPriceSet(band, item, price) |> Ok 13 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Bank.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | module Bank = 4 | open Aether 5 | open Duets.Entities 6 | 7 | /// Returns the playable character's bank account. 8 | let playableCharacterAccount state = 9 | let character = Characters.playableCharacter state 10 | 11 | Character character.Id 12 | 13 | /// Returns the account balance of the given holder. 14 | let balanceOf state holder = 15 | state 16 | |> Optic.get (Lenses.FromState.BankAccount.balanceOf_ holder) 17 | |> Option.defaultValue 0m
18 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Career.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Queries.Career 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | /// Returns the current career in which the character works, if any. 7 | let current = Optic.get Lenses.State.career_ 8 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Genres.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | open Aether 4 | open Duets.Common 5 | open Duets.Data 6 | open Duets.Entities 7 | open Duets.Simulation 8 | 9 | module Genres = 10 | /// Returns the genre market of the given genre. If the given genre does not 11 | /// have a market (maybe the given genre is not valid) it'll throw an 12 | /// exception. 13 | let from state genre = 14 | let genreMarketLens = Lenses.FromState.GenreMarkets.genreMarket_ genre 15 | 16 | Optic.get genreMarketLens state |> Option.get 17 | 18 | /// Calculates the useful market of a genre, which basically multiplies 19 | /// the market point by the default market size. 20 | let usefulMarketOf state genre = 21 | let market = from state genre 22 | 23 | market.MarketPoint 24 | |> (*) (float Config.MusicSimulation.defaultMarketSize) 25 | 26 | /// Returns all the available genres in the game and includes their popularity 27 | /// as a percentage based off the market point of the genre. 28 | let allWithPopularity state = 29 | Genres.all 30 | |> List.map (fun genre -> 31 | let market = from state genre 32 | 33 | let popularity = 34 | ((market.MarketPoint - 0.1) / (5.0 - 0.1)) * 100.0 35 | |> Math.ceilToNearest 36 | 37 | genre, popularity) 38 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Gym.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | open Duets.Entities 4 | 5 | module Gym = 6 | /// Calculates the cost of entering a gym based on the quality of the place 7 | /// and the cost of living in the city. 8 | let calculateEntranceCost cityId (place: Place) = 9 | let city = World.cityById cityId 10 | 11 | let baseCost = 12 | match place.Quality with 13 | | q when q < 25 -> 0.1m
14 | | q when q < 50 -> 0.3m
15 | | q when q < 75 -> 0.5m
16 | | _ -> 0.8m
17 | 18 | baseCost * (decimal city.CostOfLiving) 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Interactions/Interactions.Career.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries.Internal.Interactions 2 | 3 | open Duets.Common.Tuple 4 | open Duets.Entities 5 | open Duets.Simulation 6 | 7 | module Career = 8 | let internal interactions state (currentPlace: Place) = 9 | let currentJob = Queries.Career.current state 10 | 11 | match currentJob with 12 | | Some job when job.Location |> snd3 = currentPlace.Id -> 13 | [ CareerInteraction.Work job |> Interaction.Career ] 14 | | _ -> [] 15 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Interactions/Interactions.Gym.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries.Internal.Interactions 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | module Gym = 7 | /// Gather all available interactions inside a gym. 8 | let internal interactions cityId place roomType = 9 | match roomType with 10 | | RoomType.Lobby -> 11 | let entranceFee = 12 | (cityId, place) ||> Queries.Gym.calculateEntranceCost 13 | 14 | [ Interaction.Gym(GymInteraction.PayEntrance entranceFee) ] 15 | | _ -> [] 16 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Inventory.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | module Inventory = 9 | /// Returns the content of the character's inventory. 10 | let character = 11 | Optic.get (Lenses.State.inventories_ >-> Lenses.Inventories.character_) 12 | 13 | /// Returns the content of a specific band's inventory. 14 | let band id state = 15 | Optic.get 16 | (Lenses.State.inventories_ 17 | >-> Lenses.Inventories.band_ 18 | >-> Map.keyWithDefault_ id Map.empty) 19 | state 20 | |> Option.defaultValue Map.empty 21 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Notifications.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | module Notifications = 7 | /// Returns all the currently scheduled notifications for the given date and 8 | /// day moment. 9 | let forDate state date = 10 | let normalizedDate = date |> Calendar.Transform.resetDayMoment 11 | let dayMoment = Calendar.Query.dayMomentOf date 12 | 13 | let lens = 14 | Lenses.FromState.Notifications.forDateDayMoment_ 15 | normalizedDate 16 | dayMoment 17 | 18 | Optic.get lens state |> Option.defaultValue [] 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Relationships.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Entities 6 | 7 | module Relationship = 8 | /// Returns all the relationships of the current character. 9 | let all state = state.Relationships |> _.ByCharacterId 10 | 11 | /// Returns the relationship between the current character and the given 12 | /// character ID. If no relationship exists, returns `None`. 13 | let withCharacter characterId = 14 | let lens = 15 | Lenses.State.relationships_ 16 | >-> Lenses.Relationships.byCharacterId_ 17 | >-> Map.key_ characterId 18 | 19 | Optic.get lens 20 | 21 | /// Returns a list of all the relationships of the current character that 22 | /// were met in the given city. 23 | let fromCity cityId state = 24 | let lens = 25 | Lenses.State.relationships_ 26 | >-> Lenses.Relationships.byMeetingCityId_ 27 | >-> Map.key_ cityId 28 | 29 | Optic.get lens state 30 | |> Option.defaultValue Set.empty 31 | |> List.ofSeq 32 | |> List.choose (fun characterId -> withCharacter characterId state) 33 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Situations.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | module Situations = 4 | open Aether 5 | open Duets.Entities 6 | 7 | /// Returns the current situation that the character is in. 8 | let current state = Optic.get Lenses.State.situation_ state 9 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Queries/Skills.fs: -------------------------------------------------------------------------------- 1 | namespace Duets.Simulation.Queries 2 | 3 | module Skills = 4 | open Aether 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | /// Returns all skills from all characters in the game. 9 | let characterSkills state = 10 | state |> Optic.get Lenses.State.characterSkills_ 11 | 12 | /// Queries all the skills of a given character. 13 | let characterSkillsWithLevel state characterId = 14 | characterSkills state 15 | |> Map.tryFind characterId 16 | |> Option.defaultValue Map.empty 17 | 18 | /// Queries the skills of a given character. Attempts to resolve a value, if 19 | /// the character has no skills associated with the given ID, then it will 20 | /// return a skill for the given ID with a level of 0. 21 | let characterSkillWithLevel state characterId skillId = 22 | characterSkillsWithLevel state characterId 23 | |> Map.tryFind skillId 24 | |> Option.defaultValue (Skill.createWithDefaultLevel skillId) 25 | 26 | /// Calculates the average skill level of the given character. 27 | let averageSkillLevel state characterId = 28 | characterSkillsWithLevel state characterId 29 | |> List.ofMapValues 30 | |> List.averageByOrDefault (snd >> float) 0 31 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Rentals/PayUpcoming.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Rentals.PayUpcoming 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | open Duets.Simulation.Bank.Operations 6 | open FsToolkit.ErrorHandling 7 | 8 | /// Attempts to pay for an upcoming seasonal rental, expensing the money from the 9 | /// character's account and then updating the rental's next payment date to 10 | /// next season. 11 | let payRental state (rental: Rental) = 12 | let characterAccount = Queries.Bank.playableCharacterAccount state 13 | 14 | result { 15 | let! expenseEffects = rental.Amount |> expense state characterAccount 16 | 17 | let nextPaymentDate = Rental.dueDate rental |> Calendar.Ops.addSeasons 1 18 | 19 | let updatedRental = 20 | { rental with 21 | RentalType = Seasonal nextPaymentDate } 22 | 23 | return updatedRental, expenseEffects @ [ RentalUpdated updatedRental ] 24 | } 25 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Skills/ImproveSkills.Composition.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Skills.Improve.Composition 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Grants a 50% chance of improving the composition, genre and instrument of 7 | /// all members of the band between 0 and 5, generated random for each member. 8 | let improveBandSkillsChance band state = 9 | Queries.Bands.currentBandMembers state 10 | |> List.collect (fun currentMember -> 11 | Common.applySkillModificationChance 12 | state 13 | {| CharacterId = currentMember.CharacterId 14 | Skills = 15 | [ SkillId.Composition 16 | SkillId.Genre(band.Genre) 17 | SkillId.Instrument(currentMember.Role) ] 18 | Chance = 30 19 | ImprovementAmount = 1 |}) 20 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Social/Relationship.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Social.Relationship 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Creates a `RelationshipChanged` effect that adds a new relationship between 7 | /// the current character and the given one. 8 | let createWith (character: Character) meetingCity state = 9 | let currentDate = Queries.Calendar.today state 10 | 11 | let relationship = 12 | { Character = character.Id 13 | Level = 0 14 | MeetingCity = meetingCity 15 | RelationshipType = Bandmate 16 | LastIterationDate = currentDate } 17 | 18 | RelationshipChanged(character, meetingCity, Some relationship) 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Songs/Composition/ComposeSong.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Songs.Composition.ComposeSong 2 | 3 | open Common 4 | open Duets.Common 5 | open Duets.Simulation.Queries 6 | open Duets.Entities 7 | open Duets.Simulation 8 | open Duets.Simulation.Time 9 | 10 | /// Orchestrates the song composition, which calculates the qualities of a song 11 | /// and adds them with the song to the band's unfinished songs. 12 | let composeSong state song = 13 | let band = Bands.currentBand state 14 | let maximumQuality = qualityForBand state band 15 | 16 | let initialUnfinishedSong = Unfinished(song, maximumQuality, 0) 17 | 18 | let initialQuality = calculateQualityIncreaseOf initialUnfinishedSong 19 | 20 | [ Unfinished(song, maximumQuality, initialQuality) 21 | |> Tuple.two band 22 | |> SongStarted ] 23 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Songs/Composition/DiscardSong.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Songs.Composition.DiscardSong 2 | 3 | open Duets.Entities 4 | 5 | /// Removes a song from the band's unfinished repertoire. 6 | let discardSong band unfinishedSong = (band, unfinishedSong) |> SongDiscarded 7 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Songs/Composition/FinishSong.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Songs.Composition.FinishSong 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Orchestrates the finishing of a song, which moves it from the map of 7 | /// unfinished songs into the map of finished songs. 8 | let finishSong state band (Unfinished(song, _, currentQuality)) = 9 | let currentDate = Queries.Calendar.today state 10 | 11 | (band, Finished(song, currentQuality), currentDate) |> SongFinished 12 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Songs/Practice.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Songs.Practice 2 | 3 | open Aether 4 | open Duets.Common 5 | open Duets.Entities 6 | open Duets.Simulation 7 | open Duets.Simulation.Time 8 | 9 | type PracticeSongResult = 10 | | SongImproved of effects: Effect list 11 | | SongAlreadyImprovedToMax of song: Finished 12 | 13 | /// Adds 20 of practice to the given song if it hasn't reached the maximum already. 14 | /// This returns a SongPracticed effect which also advances time accordingly. 15 | let practiceSong state band (finishedSong: Finished) = 16 | let (Finished(song, quality)) = finishedSong 17 | 18 | if song.Practice >= 100 then 19 | SongAlreadyImprovedToMax finishedSong 20 | else 21 | let updatedPractice = 22 | song.Practice + 20 |> Math.clamp 0 100 23 | 24 | let updatedSong = Optic.set Lenses.Song.practice_ updatedPractice song 25 | 26 | let songWithPractice = Finished(updatedSong, quality) 27 | 28 | [ SongPracticed(band, songWithPractice) ] |> SongImproved 29 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Bank.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Bank 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let setBalance account transaction = 7 | let balanceLens = Lenses.FromState.BankAccount.balanceOf_ account 8 | 9 | let updatedBalance = 10 | match transaction with 11 | | Incoming(_, balance) -> balance 12 | | Outgoing(_, balance) -> balance 13 | 14 | Optic.set balanceLens updatedBalance 15 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Calendar.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Calendar 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let setTime time = Optic.set Lenses.State.today_ time 7 | 8 | let setTurnMinutes time = 9 | Optic.set Lenses.State.turnMinutes_ time 10 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Career.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Career 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let set job = Optic.set Lenses.State.career_ job 7 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Characters.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Characters 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Entities 6 | 7 | let add (character: Character) = 8 | let lens = Lenses.State.characters_ 9 | 10 | Optic.map lens (Map.add character.Id character) 11 | 12 | let setAttribute (characterId: CharacterId) attribute amount = 13 | let lens = 14 | Lenses.State.characters_ >-> Map.key_ characterId 15 | >?> Lenses.Character.attribute_ attribute 16 | 17 | Optic.set lens amount 18 | 19 | let setMoodlets (characterId: CharacterId) moodlets = 20 | let lens = 21 | Lenses.State.characters_ >-> Map.key_ characterId 22 | >?> Lenses.Character.moodlets_ 23 | 24 | Optic.set lens moodlets 25 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Concerts.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Concerts 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Entities 6 | 7 | let addScheduledConcert (band: Band) (concert: ScheduledConcert) = 8 | let concertsLens = 9 | Lenses.FromState.Concerts.allByBand_ band.Id 10 | >?> Lenses.Concerts.Timeline.scheduled_ 11 | 12 | Optic.map concertsLens (Concert.Timeline.addScheduled concert) 13 | 14 | let addPastConcert (band: Band) (concert: PastConcert) = 15 | let concertsLens = 16 | Lenses.FromState.Concerts.allByBand_ band.Id 17 | >?> Lenses.Concerts.Timeline.pastEvents_ 18 | 19 | Optic.map concertsLens (Concert.Timeline.addPast concert) 20 | 21 | let removeScheduledConcert (band: Band) (concert: Concert) = 22 | let concertsLens = 23 | Lenses.FromState.Concerts.allByBand_ band.Id 24 | >?> Lenses.Concerts.Timeline.scheduled_ 25 | 26 | Optic.map 27 | concertsLens 28 | (List.filter (fun (ScheduledConcert(c, _)) -> c.Id <> concert.Id)) 29 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Flights.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Flights 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let addBooking flight = 7 | Optic.map Lenses.State.flights_ (List.append [ flight ]) 8 | 9 | let change (updatedFlight: Flight) = 10 | Optic.map 11 | Lenses.State.flights_ 12 | (List.map (fun flight -> 13 | if flight.Id = updatedFlight.Id then 14 | updatedFlight 15 | else 16 | flight)) 17 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Inventory.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Inventory 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | let private charactersLenses = 9 | Lenses.State.inventories_ >-> Lenses.Inventories.character_ 10 | 11 | let private bandsLenses id = 12 | Lenses.State.inventories_ 13 | >-> Lenses.Inventories.band_ 14 | >-> Map.keyWithDefault_ id Map.empty 15 | 16 | let addToCharacter item = 17 | Optic.map charactersLenses (List.append [ item ]) 18 | 19 | let removeFromCharacter item = 20 | Optic.map charactersLenses (List.removeFirstOccurrenceOf item) 21 | 22 | let addToBand bandId item quantity = 23 | Optic.map 24 | (bandsLenses bandId) 25 | (Map.change item (fun q -> 26 | match q with 27 | | Some q -> Some(q + quantity) 28 | | None -> Some quantity)) 29 | 30 | let reduceForBand bandId item quantity = 31 | Optic.map 32 | (bandsLenses bandId) 33 | (Map.change item (function 34 | | Some q -> Some(q - quantity) 35 | | None -> None)) 36 | 37 | let removeFromBand bandId item = 38 | Optic.map (bandsLenses bandId) (Map.remove item) 39 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Market.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Market 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let set genreMarkets = 7 | Optic.set Lenses.State.genreMarkets_ genreMarkets 8 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Merch.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Merch 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | let setPrice bandId itemProperty price state = 9 | let mainItemProperty = Item.Property.tryMain itemProperty 10 | 11 | match mainItemProperty with 12 | | Some property -> 13 | let lens = 14 | Lenses.State.merchPrices_ >-> Map.keyWithDefault_ bandId Map.empty 15 | >?> Map.keyWithDefault_ property 0m
16 | 17 | Optic.set lens price state 18 | | None -> state 19 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Notifications.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Notifications 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let schedule date dayMoment notification = 7 | let lens = Lenses.FromState.Notifications.forDateDayMoment_ date dayMoment 8 | Optic.map lens (fun ns -> notification :: ns) 9 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Relationships.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Relationships 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | let changeForCharacterId 9 | (npcId: CharacterId) 10 | (relationship: Relationship option) 11 | = 12 | let changeById = Map.change npcId (fun _ -> relationship) 13 | 14 | Optic.map 15 | (Lenses.State.relationships_ >-> Lenses.Relationships.byCharacterId_) 16 | changeById 17 | 18 | let changeForCityId 19 | (npcId: CharacterId) 20 | (cityId: CityId) 21 | (relationship: Relationship option) 22 | = 23 | let lens = 24 | Lenses.State.relationships_ 25 | >-> Lenses.Relationships.byMeetingCityId_ 26 | >-> Map.keyWithDefault_ cityId Set.empty 27 | 28 | let update = 29 | match relationship with 30 | | Some relationship -> Set.add relationship.Character 31 | | None -> Set.remove npcId 32 | 33 | Optic.map lens update 34 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Rentals.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Rentals 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let add (rental: Rental) = 7 | let addRental = Map.add rental.Coords rental 8 | Optic.map Lenses.State.rentals_ addRental 9 | 10 | let remove (rental: Rental) = 11 | let removeRental = Map.remove rental.Coords 12 | Optic.map Lenses.State.rentals_ removeRental 13 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/Skills.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.Skills 2 | 3 | open Aether 4 | open Aether.Operators 5 | open Duets.Common 6 | open Duets.Entities 7 | 8 | let add (characterId: CharacterId) (skillWithLevel: SkillWithLevel) = 9 | let (skill, _) = skillWithLevel 10 | 11 | let skillLens = 12 | Lenses.State.characterSkills_ 13 | >-> Map.keyWithDefault_ characterId Map.empty 14 | 15 | let addSkill map = Map.add skill.Id skillWithLevel map 16 | 17 | Optic.map skillLens addSkill 18 | 19 | let addMultiple 20 | (characterId: CharacterId) 21 | (skillsWithLevel: SkillWithLevel list) 22 | state 23 | = 24 | skillsWithLevel 25 | |> List.fold (fun acc skill -> add characterId skill acc) state 26 | -------------------------------------------------------------------------------- /src/Duets.Simulation/State/World.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.State.World 2 | 3 | open Aether 4 | open Duets.Entities 5 | 6 | let move cityId placeId roomId = 7 | Optic.set Lenses.State.currentPosition_ (cityId, placeId, roomId) 8 | 9 | let add coords item = 10 | let addItem = 11 | Map.change coords (function 12 | | Some list -> item :: list |> Some 13 | | None -> Some [ item ]) 14 | 15 | Optic.map Lenses.State.worldItems_ addItem 16 | 17 | let remove coords item = 18 | let removeItem = 19 | Map.change coords (function 20 | | Some list -> List.except [ item ] list |> Some 21 | | None -> None) 22 | 23 | Optic.map Lenses.State.worldItems_ removeItem 24 | 25 | let setPeople people = 26 | Optic.set Lenses.State.peopleInCurrentPosition_ people 27 | 28 | let setWeather cityId = 29 | Optic.set (Lenses.FromState.Weather.forCity_ cityId) 30 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Studio/ReleaseAlbum.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Studio.ReleaseAlbum 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | open Duets.Simulation 6 | 7 | /// Releases an album to the public, which marks the album as released and starts 8 | /// the release chain. 9 | let releaseAlbum state band album = 10 | Album.Released.fromUnreleased album (Queries.Calendar.today state) 1.0 11 | |> Tuple.two band 12 | |> AlbumReleased 13 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Studio/RenameAlbum.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Studio.RenameAlbum 2 | 3 | open Duets.Entities 4 | 5 | /// Renames an album to a given name, validating that the name is correct. 6 | let renameAlbum band album name = 7 | Album.Unreleased.modifyName album name 8 | |> fun album -> AlbumUpdated(band, album) 9 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Time/AdvanceTime.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Time.AdvanceTime 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Advances the current time to the next day moment by the given number of 7 | /// times. For example, if currently it's morning, this will advance to midday. 8 | /// Also handles the cases in which it's already midnight, in which case it'll 9 | /// return the dawn of next day. 10 | let advanceDayMoment (currentTime: Date) (times: int) = 11 | let timeEffects = 12 | [ 1 .. (times / 1) ] 13 | |> List.mapFold 14 | (fun time _ -> 15 | Calendar.Query.next time 16 | |> fun advancedTime -> 17 | (TimeAdvanced advancedTime, advancedTime)) 18 | currentTime 19 | |> fst 20 | // Important! Reset the turn time after advancing time to make sure 21 | // the next turn time is not shorter than expected. 22 | [ yield! timeEffects; TurnTimeUpdated 0 ] 23 | 24 | /// Same as advanceDayMoment but queries the current time automatically. 25 | let advanceDayMoment' state times = 26 | let currentDate = Queries.Calendar.today state 27 | 28 | advanceDayMoment currentDate times 29 | -------------------------------------------------------------------------------- /src/Duets.Simulation/Travel/Metro.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Travel.Metro 2 | 3 | open Duets.Entities 4 | open Duets.Simulation 5 | 6 | /// Attempts to wait for the next train to arrive if the character is currently 7 | /// in a metro station and not overlapping with a train. Otherwise, does nothing. 8 | let waitForNextTrain state = 9 | // TODO: We only consider the first line for now. Maybe take the one with the shortest time? 10 | Queries.Metro.currentStationLines state 11 | |> List.tryHead 12 | |> Option.map (fun line -> 13 | let minutesToNextTrain = 14 | Queries.Metro.timeToOverlapWithTrain state line 15 | 16 | let currentTurnMinutes = Queries.Calendar.currentTurnMinutes state 17 | let total = currentTurnMinutes + minutesToNextTrain 18 | 19 | [ TurnTimeUpdated total ]) 20 | |> Option.defaultValue [] 21 | -------------------------------------------------------------------------------- /src/Duets.Simulation/WorldNavigation/Policies/OpeningHours.Policies.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Navigation.Policies.OpeningHours 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | open Duets.Simulation 6 | 7 | /// Returns whether the given place is opened right now according to their 8 | /// opening hours. 9 | let canMove state cityId placeId = 10 | let place = (cityId, placeId) ||> Queries.World.placeInCityById 11 | 12 | Queries.World.placeCurrentlyOpen' state place 13 | |> Result.ofBool PlaceEntranceError.CannotEnterOutsideOpeningHours 14 | -------------------------------------------------------------------------------- /src/Duets.Simulation/WorldNavigation/Policies/Rental.Policies.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Navigation.Policies.Rental 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | open Duets.Simulation 6 | 7 | /// Returns whether the character can enter in a place by checking if they 8 | /// are actually renting it currently or not. 9 | let canMove state cityId placeId = 10 | let place = (cityId, placeId) ||> Queries.World.placeInCityById 11 | let placeRental = (cityId, placeId) |> Queries.Rentals.getForCoords state 12 | 13 | match place.PlaceType, placeRental with 14 | | PlaceType.Home, None -> false 15 | | _ -> true 16 | |> Result.ofBool PlaceEntranceError.CannotEnterWithoutRental 17 | 18 | /// Returns whether the character can enter in a room by checking if they 19 | /// are actually renting it currently or not. 20 | let canEnter state cityId placeId roomId = 21 | let place = Queries.World.placeInCityById cityId placeId 22 | let room = Queries.World.roomById cityId placeId roomId 23 | let placeRental = (cityId, placeId) |> Queries.Rentals.getForCoords state 24 | 25 | match place.PlaceType, placeRental with 26 | | PlaceType.Hotel _, None when room.RoomType = RoomType.Bedroom -> false 27 | | _ -> true 28 | |> Result.ofBool RoomEntranceError.CannotEnterHotelRoomWithoutBooking 29 | -------------------------------------------------------------------------------- /src/Duets.Simulation/WorldNavigation/Policies/Room.Policies.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Navigation.Policies.Room 2 | 3 | open Duets.Common 4 | open Duets.Entities 5 | open Duets.Simulation 6 | 7 | /// Returns whether the character can enter a room by checking if it requires 8 | /// any item and disallowing the entry if the character does not have it. 9 | let canEnter currentRoomId state cityId placeId nextRoomId = 10 | let room = Queries.World.roomById cityId placeId nextRoomId 11 | let inventory = Queries.Inventory.character state 12 | 13 | match room.RequiredItemsForEntrance with 14 | | Some requiredItems when requiredItems.ComingFrom = currentRoomId -> 15 | requiredItems.Items 16 | |> List.forall (fun item -> inventory |> List.contains item) 17 | |> Result.ofBool ( 18 | RoomEntranceError.CannotEnterWithoutRequiredItems 19 | requiredItems.Items 20 | ) 21 | | _ -> Ok() 22 | -------------------------------------------------------------------------------- /src/Duets.Simulation/WorldNavigation/TravelTime.fs: -------------------------------------------------------------------------------- 1 | [] 2 | module Duets.Simulation.Navigation.TravelTime 3 | 4 | open Duets.Common 5 | open Duets.Entities 6 | open Duets.Simulation 7 | 8 | /// Returns the approximate travel time it takes to perform the given path by 9 | /// walk/public transport. 10 | let byPublicTransport path = 11 | (0, path) 12 | ||> List.fold (fun acc action -> 13 | let hintTime = 14 | match action with 15 | | Pathfinding.Enter _ 16 | | Pathfinding.GoOut _ -> 3 17 | | Pathfinding.TakeMetro _ -> 18 | Config.Time.travelTimeBetweenDifferentZones 19 | | Pathfinding.Walk _ -> Config.Time.travelTimeBetweenSameZone 20 | 21 | acc + hintTime) 22 | 23 | /// Returns the approximate travel time it takes to perform the given path by 24 | /// taxi. 25 | let byTaxi path = 26 | let regularTravelTime = byPublicTransport path 27 | float regularTravelTime / 2.0 |> Math.roundToNearest |> (*) 1 28 | 29 | /// Returns the approximate travel time it takes to perform the given path by 30 | /// car (similar to taxi but player is driving). 31 | let byCar path = 32 | let regularTravelTime = byPublicTransport path 33 | float regularTravelTime / 2.0 |> Math.roundToNearest |> (*) 1 34 | -------------------------------------------------------------------------------- /tests/Agents.Tests/Agents.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0 4 | false 5 | false 6 | State.Tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/Agents.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = let [] main _ = 0 2 | -------------------------------------------------------------------------------- /tests/Data.Tests/Data.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(DotnetVersion) 5 | 6 | false 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Data.Tests/Items.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Tests.Items 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | 6 | open Duets.Common 7 | open Duets.Entities 8 | open Duets.Data 9 | 10 | [] 11 | let ``all city IDs have at least one beer defined in the byLocation map`` () = 12 | Union.allCasesOf () 13 | |> List.forall (fun cityId -> 14 | Items.Drink.Beer.byLocation |> Map.containsKey cityId) 15 | |> should equal true 16 | -------------------------------------------------------------------------------- /tests/Data.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Duets.Data.Test.Program 2 | 3 | [] 4 | let main _ = 0 5 | -------------------------------------------------------------------------------- /tests/Entities.Tests/Entities.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(DotnetVersion) 5 | 6 | false 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Entities.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program 2 | 3 | [] 4 | let main _ = 0 5 | -------------------------------------------------------------------------------- /tests/Entities.Tests/SocialNetwork.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Entities.Tests.SocialNetwork 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | open Test.Common 6 | 7 | open Duets.Entities 8 | 9 | [] 10 | let ``createEmpty should remove ats in the beginning of the handle`` () = 11 | [ "@test", "test"; "test@", "test@" ] 12 | |> List.iter (fun (input, expected) -> 13 | let account = 14 | SocialNetwork.Account.createEmpty 15 | (SocialNetworkAccountId.Band dummyBand.Id) 16 | input 17 | 18 | account.Handle |> should equal expected) 19 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Bands/FireMember.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.Bands.FireMember 2 | 3 | open Test.Common 4 | open NUnit.Framework 5 | open FsUnit 6 | 7 | open Duets.Common 8 | open Duets.Entities 9 | open Duets.Simulation 10 | open Duets.Simulation.Bands.Members 11 | 12 | let bandMember = 13 | let hiredCharacter = 14 | Character.from 15 | "Test" 16 | Other 17 | (Calendar.Ops.addYears -28 Calendar.gameBeginning) 18 | 19 | Band.Member.from hiredCharacter.Id Guitar dummyToday 20 | 21 | let state = dummyState |> State.Bands.addMember dummyBand bandMember 22 | 23 | [] 24 | let FireMemberFailsIfGivenMemberIsPlayableCharacter () = 25 | let playableMember = Band.Member.from dummyCharacter.Id Guitar dummyToday 26 | 27 | fireMember state dummyBand playableMember 28 | |> Result.unwrapError 29 | |> should be (ofCase <@ AttemptToFirePlayableCharacter @>) 30 | 31 | [] 32 | let FireMemberGeneratesMemberFiredEffect () = 33 | fireMember state dummyBand bandMember 34 | |> Result.unwrap 35 | |> should 36 | be 37 | (ofCase 38 | <@ 39 | MemberFired( 40 | dummyBand, 41 | bandMember, 42 | Band.PastMember.fromMember bandMember dummyToday 43 | ) 44 | @>) 45 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Bank/Queries.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.Bank.Queries 2 | 3 | open NUnit.Framework 4 | open FsUnit 5 | open Test.Common 6 | 7 | open Duets.Entities 8 | open Duets.Simulation.Queries.Bank 9 | 10 | [] 11 | let balanceOfShouldReturnCorrectBalance () = 12 | balanceOf dummyState dummyCharacterBankAccount.Holder 13 | |> should equal 1000 14 | 15 | [] 16 | let balanceOfUnknownShouldReturn0 () = 17 | balanceOf dummyState (Character(CharacterId <| Identity.create ())) 18 | |> should equal 0 19 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Program.fs: -------------------------------------------------------------------------------- 1 | module Program = 2 | [] 3 | let main _ = 0 4 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Songs/DiscardSong.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.Songs.DiscardSong 2 | 3 | open Duets.Entities 4 | open Test.Common 5 | open NUnit.Framework 6 | open FsUnit 7 | 8 | open Duets.Simulation.Songs.Composition.DiscardSong 9 | 10 | [] 11 | let DiscardSongShouldGenerateSongDiscarded () = 12 | let unfinishedSong = 13 | Unfinished(dummySong, 35, 7) 14 | 15 | discardSong dummyBand unfinishedSong 16 | |> should be (ofCase <@ SongDiscarded(dummyBand, unfinishedSong) @>) 17 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Songs/FinishSong.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.Songs.FinishSong 2 | 3 | open Duets.Entities 4 | open Test.Common 5 | open NUnit.Framework 6 | open FsUnit 7 | 8 | open Duets.Simulation.Songs.Composition.FinishSong 9 | 10 | [] 11 | let FinishSongShouldGenerateSongFinishedEffect () = 12 | finishSong 13 | dummyState 14 | dummyBand 15 | (Unfinished(dummySong, 35, 7)) 16 | |> should be (ofCase <@ SongFinished @>) 17 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/State/State.Inventory.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.State.Inventory 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | open Test.Common 6 | 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | let private dummyMerch = 11 | { Brand = "DuetsMerch" 12 | Name = "T-shirt" 13 | Properties = [ Wearable TShirt ] } 14 | 15 | [] 16 | let ``ItemAddedToBandInventory adds the item to the inventory`` () = 17 | let state = 18 | ItemAddedToBandInventory(dummyBand, dummyMerch, 200) 19 | |> State.Root.applyEffect dummyState 20 | 21 | Queries.Inventory.band dummyBand.Id state 22 | |> Map.find dummyMerch 23 | |> should equal 200 24 | 25 | [] 26 | let ``ItemAddedToInventory sums the new quantity to the previous if the item was already in the map`` 27 | () 28 | = 29 | let state = 30 | ItemAddedToBandInventory(dummyBand, dummyMerch, 200) 31 | |> State.Root.applyEffect dummyState 32 | 33 | let state = 34 | ItemAddedToBandInventory(dummyBand, dummyMerch, 100) 35 | |> State.Root.applyEffect state 36 | 37 | Queries.Inventory.band dummyBand.Id state 38 | |> Map.find dummyMerch 39 | |> should equal 300 40 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/State/State.Merch.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.State.Merch 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | open Test.Common 6 | 7 | open Duets.Entities 8 | open Duets.Simulation 9 | 10 | let private dummyMerch = 11 | { Brand = "DuetsMerch" 12 | Name = "T-shirt" 13 | Properties = [ Wearable TShirt ] } 14 | 15 | [] 16 | let ``MerchSold updates the band inventory removing the sold items`` () = 17 | let state = 18 | ItemAddedToBandInventory(dummyBand, dummyMerch, 200) 19 | |> State.Root.applyEffect dummyState 20 | 21 | let state = 22 | MerchSold(dummyBand, [ dummyMerch, 91 ], 200m
) 23 | |> State.Root.applyEffect state 24 | 25 | Queries.Inventory.band dummyBand.Id state 26 | |> Map.find dummyMerch 27 | |> should equal 109 28 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/State/State.Setup.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.State.Setup 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | open Test.Common 6 | open Duets.Entities 7 | open Duets.Simulation 8 | 9 | [] 10 | let GameCreatedShouldInitializeState () = 11 | GameCreated dummyState 12 | |> State.Root.applyEffect dummyState 13 | |> should equal dummyState 14 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/State/State.Skills.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.State.Skills 2 | 3 | 4 | 5 | open FsUnit 6 | open NUnit.Framework 7 | open Test.Common 8 | 9 | open Duets.Common 10 | open Duets.Entities 11 | open Duets.Simulation 12 | open Duets.Simulation.Queries 13 | 14 | let skill = Skill.createWithLevel SkillId.Composition 10 15 | 16 | let querySkills character state = 17 | Skills.characterSkillsWithLevel state character 18 | 19 | [] 20 | let ``SkillImproved should add skill if not present`` () = 21 | let skills = 22 | SkillImproved(dummyCharacter, Diff(skill, skill)) 23 | |> State.Root.applyEffect dummyState 24 | |> querySkills dummyCharacter.Id 25 | 26 | skills |> should haveCount 1 27 | skills |> Map.head |> should equal skill 28 | 29 | [] 30 | let ``SkillImproved should add skill even if character is not present in the map`` 31 | () 32 | = 33 | let madeUpCharacter = 34 | Character.from 35 | "Made Up" 36 | Male 37 | (Calendar.Ops.addYears -25 Calendar.gameBeginning) 38 | 39 | let skills = 40 | SkillImproved(madeUpCharacter, Diff(skill, skill)) 41 | |> State.Root.applyEffect dummyState 42 | |> querySkills madeUpCharacter.Id 43 | 44 | skills |> should haveCount 1 45 | skills |> Map.head |> should equal skill 46 | -------------------------------------------------------------------------------- /tests/Simulation.Tests/Studio/RenameAlbum.Tests.fs: -------------------------------------------------------------------------------- 1 | module Duets.Simulation.Tests.Studio.RenameAlbum 2 | 3 | open FsUnit 4 | open NUnit.Framework 5 | open Test.Common 6 | 7 | open Duets.Common 8 | open Duets.Entities 9 | open Duets.Simulation.Studio.RenameAlbum 10 | 11 | [] 12 | let rec ``validateName should fail if name is empty`` () = 13 | Album.validateName "" 14 | |> Result.unwrapError 15 | |> should be (ofCase <@ Album.NameTooShort @>) 16 | 17 | [] 18 | let ``validateName should fail if name is too long`` () = 19 | Album.validateName 20 | "Nothing to Go Home To, Nothing There to Come Home For, No Home to Return To, Nothing to Go Home To, Nothing There to Come Home For, No Home to Return To" 21 | |> Result.unwrapError 22 | |> should be (ofCase <@ Album.NameTooLong @>) 23 | 24 | [] 25 | let ``renameAlbum should generate AlbumRenamed effect`` () = 26 | renameAlbum dummyBand dummyUnreleasedAlbum "Great Mass Of Color" 27 | |> fun effect -> 28 | match effect with 29 | | AlbumUpdated(_, unreleasedAlbum) -> 30 | let album = unreleasedAlbum |> Album.fromUnreleased 31 | album.Name |> should equal "Great Mass Of Color" 32 | | _ -> raise <| invalidOp "Not possible" 33 | -------------------------------------------------------------------------------- /tests/Test.Common/Generators/Character.Generator.fs: -------------------------------------------------------------------------------- 1 | module Test.Common.Generators.Character 2 | 3 | open FsCheck 4 | 5 | open Duets.Entities 6 | 7 | type CharacterGenOptions = { Id: CharacterId option } 8 | 9 | let defaultOptions = { Id = None } 10 | 11 | let generator (opts: CharacterGenOptions) = 12 | gen { 13 | let! character = Arb.generate 14 | let id = defaultArg opts.Id character.Id 15 | 16 | return { character with Id = id } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Test.Common/Generators/Date.Generator.fs: -------------------------------------------------------------------------------- 1 | module Test.Common.Generators.Date 2 | 3 | open Duets.Entities 4 | open FsCheck 5 | 6 | let dateGenerator (fromDate: Date) (toDate: Date) = 7 | if fromDate = toDate then 8 | Gen.constant fromDate 9 | else 10 | Calendar.Query.datesBetween fromDate toDate |> Gen.elements 11 | -------------------------------------------------------------------------------- /tests/Test.Common/Test.Common.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(DotnetVersion) 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | --------------------------------------------------------------------------------